NSSCTF 2025 re wp

ez_tea

ez_tea不ez

没改附件版本:

动调有几个地方需要绕过

对isDebuggerPrensent进行交叉引用,对isDebuggerPrensent的绕过方法基本是改zf标志位,此外还有一个地方绕过需要把0x70改成0x71修改寄存器

此题别用patch方法,因为上面那个0xcccc貌似是crc校验

image-20251103223128204

image-20251105201744462

image-20251105201759098

image-20251105201819875

同时看到这有个exit也交叉引用下

image-20251105202022110

这里也有个exit

绕过后经过动调发现,这个方法会对key进行修改,我们可以断再fmt这个方法上就可以发现dst的地址是key的地址

image-20251103223427732

这块如何修改的key? 是tea_encrypt_wrapper这个方法的机器码值+78然后patch到了key

另一种发现hook的方法,当你写了解密脚本发现不对,可以到处交叉引用看看

image-20251103223633887

可以返现这个方法被神奇的其它地方交叉引用了

最后写解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
delta = 0x0D000721
rounds = 32
key_ascii = "1384774E4E13CFC3"
key_bytes = key_ascii.encode("ascii")
key_words = [int.from_bytes(key_bytes[i:i + 4], "little") for i in range(0, 16, 4)]

cipher = bytes([
0x7d, 0x91, 0xeb, 0x59, 0x8d, 0xd1, 0xf9, 0x47,
0x86, 0x76, 0xef, 0xcd, 0xa1, 0x6b, 0x07, 0x6f,
0xb8, 0x3e, 0x6a, 0x04, 0x5f, 0x63, 0xbb, 0x21,
0x69, 0xc3, 0x34, 0xa3, 0x07, 0x64, 0x33, 0xde,
0xf6, 0x70, 0xec, 0x4d, 0x5b, 0x7e, 0x2b, 0x33,
0xcc, 0xa7, 0xd3, 0x4a, 0x00, 0x00, 0x00, 0x00,
])


def tea_decrypt_block(v0, v1):
total = (delta * rounds) & 0xFFFFFFFF
for _ in range(rounds):
idx2 = (((total >> 11) & 0xFF) ^ 1) & 3
mix2 = (key_words[idx2] + ((v0 << 4) & 0xFFFFFFFF)) & 0xFFFFFFFF
v1 = (v1 - (((v0 >> 5) ^ mix2) & 0xFFFFFFFF)) & 0xFFFFFFFF
idx1 = (total >> 11) & 3
mix1 = (key_words[idx1] + ((v1 << 4) & 0xFFFFFFFF)) & 0xFFFFFFFF
v0 = (v0 - (((v1 >> 5) ^ mix1) & 0xFFFFFFFF)) & 0xFFFFFFFF
total = (total - delta) & 0xFFFFFFFF
return v0, v1


mutated = bytearray()
for off in range(0, len(cipher), 8):
w0 = int.from_bytes(cipher[off:off + 4], "little")
w1 = int.from_bytes(cipher[off + 4:off + 8], "little")
p0, p1 = tea_decrypt_block(w0, w1)
mutated.extend(p0.to_bytes(4, "little"))
mutated.extend(p1.to_bytes(4, "little"))

plain = bytearray(len(mutated))
for i in range(0, len(mutated), 2):
b0 = mutated[i]
b1 = mutated[i + 1]
plain[i] = ((b0 ^ 0x72) - 1) & 0xFF
plain[i + 1] = ((b1 ^ 0x02) + 2) & 0xFF

print("flag bytes (hex):", plain.hex())
print("flag (latin-1):", plain.decode("latin-1"))

flag (latin-1): NSSCTF{13ce5888-01b4-4287-8593-eb975ab6cf{±­F\¡Æ

有点怪

看了下密文可能是4个0000000字节搞的鬼

可以爆破,一开始有一个爆破思路因为是诸字节爆破,我们可以使用测信道攻击,因为诸位比较,提前一位退出和新比较一位退出的时间上有区别,我们计算这个时间长度来判别即可

后来验证不行

第二个方法是爆破,因为一开始没看出来只觉得有点眼熟,后来看出来了是个uuid,对比下就知道还少两个字节,因此其实只有两个字节需要爆破,再加上最后的}就行了

image-20251103224311574

爆破exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import itertools
import subprocess

BINARY = "./ez_tea.exe" # 目标程序路径
PREFIX = "NSSCTF{13ce5888-01b4-4287-8593-eb975ab6cf"
CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"
LAST_CHAR = "}" # 最后一位固定


def run_candidate(candidate: str) -> bool:
proc = subprocess.run(
[BINARY],
input=candidate.encode("latin-1", errors="ignore"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return b"Good" in (proc.stdout + proc.stderr)


def main():
for tail in itertools.product(CHARSET, repeat=2):
candidate = PREFIX + "".join(tail) + LAST_CHAR
if run_candidate(candidate):
print(f"[+] Hit: {candidate}")
return
else:
print(f"[-] {candidate}")
print("枚举完毕仍未命中,请检查前缀或字符集。")

if __name__ == "__main__":
main()

[+] Hit: NSSCTF{13ce5888-01b4-4287-8593-eb975ab6cf6a}

runner

解法1:跑图

不如直接跑图快

QRazyBox - QR Code Analysis and Recovery Toolkit

image-20251105212313982

解法2:分析dat

非预期

直接看globalxxx.dat

因为assertstudio解出来没有二维码资源说明是动态生成的,一般在这个文件中,直接滑能找到大面积的01,写个脚本可以提取恢复

image-20251105204657663

解法3:静态分析

太详细的步骤没有保存

关于il2cppDump的使用可以看unity引擎基于Windows下的il2cpp逆向初探——以CTF赛题为例-先知社区

一般pc逆向的il2cpp的unity,主逻辑在GameAssembly.dll里,但是发现有upx,直接脱壳失败

看了下发现了,居然魔改了

用010editor打开然后把里面的NSS标志恢复成UPX就行了

然后直接脱壳,拖入ida即可静态分析

静态分析大部分没有函数名

因为il2cpp的unity函数名等资源都在globalxxx.dat中

image-20251105205203134

我们直接用il2cppDump这个工具

Perfare/Il2CppDumper: Unity il2cpp reverse engineer

执行Il2CppDumper.exe GameAssembly.dll 这类命令

即可dump出一个dump.cs和一堆dll

image-20251105205509816

比较重要的的就这个Assembly-CSharp.dll

直接dnspy64打开

我们直接ctrl+alt+f搜索qrcode类似这种

可以搜到

image-20251105205655991

然后这几个地址比较重要可能是构建二维码的地方

然后把dump出来的dump.cs和script.json导入(用于恢复函数名和符号,这个过程可能比较久,耐心等待)

具体怎么导入不介绍,可以看il2cppdump的使用,就是上面的那个先知的链接

下面的截图是我的分析过程,但是做题的时候没保存,所以我后面重新截了,但是做题时其实是有函数名的

上面几个地址此时有用了

我们可以依次跳转到这几个函数分析

image-20251105210444925

具体我后面分析出来是这样的:

生成二维码的核心在 QRCodeBuilder(GameAssembly.dll 中的 sub_1808118B0 和 sub_180810F90)。构造函数里把一个 29×29 的常量矩阵塞进 qrMatrix,ConstructQRCode 再按矩阵值实例化黑/白模块。借定位常量矩阵

QRCodeBuilder::.ctor 调用 sub_181652960,后者沿 IL2CPP 元数据把字段初值拷到 qrMatrix。

使用采样脚本扫描 global-metadata.dat 的默认值段,发现长为 29×29、元素仅 0/1 的块起始索引为 19740(整数视图),即矩阵。

QRCodeBuilder.qrMatrix 是 int[,],初值并不在 Assembly-CSharp.dll 里,而是存放在 IL2CPP 的全局元数据段(相当于 C# 的 static readonly 初始化块)中。构造函数 GameAssembly.dll:0x1808118B0 调用 sub_181652960,该函数沿元数据读取字段默认值,把一块连续内存写进 qrMatrix。

矩阵的出处与定位

QRCodeBuilder.qrMatrix 是 int[,],初值并不在 Assembly-CSharp.dll 里,而是存放在 IL2CPP 的全局元数据段(相当于 C# 的 static readonly 初始化块)中。

具体来说:

  1. script.json让我们知道 QRCodeBuilder 的字段顺序与 qrMatrix 的类型,从而确定它使用了字段默认值(FieldRVA)。
  2. global-metadata.dat 头部第 18、19 项是 “Field & Parameter Default Value Data”的偏移与长度。扫描这一段时我查找 841 (=29×29) 个 32 位整数全部为 0/1 的区块;正好在偏移 data_offset + 19740*4 处找到独一无二的块,这就是 qrMatrix 的初始内容。之所以能定位到索引 19740,是因为 Il2CppDumper 的 FieldRVA

sub_181652960 为什么判定为把字段默认值抄进 qrMatrix?

在 GameAssembly.dll:0x1808118B0(QRCodeBuilder::.ctor)里,r9 被赋为 qrMatrix,随后调用 sub_181652960(r9, qword_182F726A8, …)。

反编译 sub_181652960 可见,它先调用 sub_181633EE0 / sub_18180BD40 检查数组状态,之后求得一个临时数组并调用 sub_181652A40。

sub_181652A40 直接把两个指针传给 sub_1805CA600,而 sub_1805CA600 里先取 arg1 的数组句柄,再调用 sub_180450230(arg2)——这是 Unity/IL2CPP 的“读取 FieldRVA 数据”例程。它往下会调用 sub_180558480 → sub_1805584E0 -> (中间可能还有两个函数) → sub_180769120,正是枚举 global-metadata.dat 元数据并按字段索引取默认值的标准流程。因此可以确认 sub_181652960 就是在把元数据里预存的 int[,] 常量搬到 qrMatrix。

data_offset 的来源与数值

global-metadata.dat 头部是 32 个 uint32。第 18、19 个元素分别是 FieldAndParameterDefaultValueDataOffset 和 FieldAndParameterDefaultValueDataCount。

用 Python 解出的值:

field_and_param_default_data_offset = struct.unpack_from(“<I”, meta, 184)[0] # = 1782496

field_and_param_default_data_size = struct.unpack_from(“<I”, meta, 19*4)[0] # = 89632

这就是我代码里 data_offset 的来源,它不是扫描出来的,而是直接从元数据头读取的标准字段。

为什么定位到 data_offset + 19740*4

  • 19740 是 qrMatrix 的 FieldRVA 在默认值数组中的索引,Il2CppDumper 会在 script.json 的 ScriptMetadataMethod / ScriptMethod 等条目里把同一块地址列出来。
  • 我第一遍是扫整段默认值数据,把所有“连续 841 个 uint32 只含 0/1”的片段列出来,确实只有索引 19740 附近满足条件(对应 29×29 阵列)。之后把这个索引写死在脚本里,方便重复利用。
  • 结合 sub_181652960 的调用链,就能确认这块数据正好就是构造函数读取的二维码矩阵。

我怎么知道怎么知道第 18、19 个元素分别是 FieldAndParameterDefaultValueDataOffset 和FieldAndParameterDefaultValueDataCount指向二维码的地方

那两个数不是随便猜的,而是来自 Unity/IL2CPP 的公开结构:global-metadata.dat 文件开头固定是 32 个 uint32,顺序在 Unity 官方的 Il2CppGlobalMetadataHeader(il2cpp/libil2cpp/vm/GlobalMetadata.h)里写得一清二楚。第 18、19 个成员就叫 fieldAndParameterDefaultValueDataOffset/Count,说的是“字段默认值与参数默认值的数据区”在文件里的起始位置和长度。

二维码为什么会在这里?因为 QRCodeBuilder.qrMatrix 是个 int[,] 字段,Il2CPP 在生成原生代码时把它当成“带初值的数组字段”,这类字段的初始内容会被落到 FieldAndParameterDefaultValueData 段。构造函数 sub_181652960 → sub_1805CA600 → sub_180450230 → sub_180558480 … 的链条正是 Il2CPP 的“从 metadata 里读FieldRVA,拷贝默认值到托管对象”逻辑,说明 qrMatrix 的实际数据就存放在该段。

所以流程是:

  1. 按官方结构读取 header,得到 fieldAndParameterDefaultValueDataOffset(=1782496)和 fieldAndParameterDefaultValueDataSize。
  2. 在这段数据里寻找 29×29(=841)个 uint32 全部为 0/1 的块;Il2CppDumper 给出的 FieldRVA 索引 (script.json) 也会指向同一个位置,于是能确认这就是 qrMatrix 的初始值。
  3. 再用从 metadata 里取出的偏移(例如 offset = data_offset + 19740*4)去读取整块数据,生成二维码矩阵。

换句话说:第 18、19 个字段并不是我推出来的,而是 Unity 在元数据格式里就定义好的;二维码位于那段,是因为 QRCodeBuilder.qrMatrix 属于“带默认值的字段”,Il2CPP 的写法就是把这些默认值统一放在这个段里

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import struct
from pathlib import Path

import cv2
import numpy as np

#
# 1) 读出 29x29 的二维码矩阵(整型 0/1)
#
META_PATH = Path("global-metadata.dat")
assert META_PATH.exists(), "global-metadata.dat not found in current dir"

meta = META_PATH.read_bytes()

# metadata header: see Il2Cpp global-metadata layout
field_and_param_default_data_offset = struct.unpack_from("<I", meta, 18 * 4)[0]
field_and_param_default_data_size = struct.unpack_from("<I", meta, 19 * 4)[0]
field_and_param_default_data = meta[
field_and_param_default_data_offset:
field_and_param_default_data_offset + field_and_param_default_data_size
]

SIZE = 29
BLOCK_INTS = SIZE * SIZE

# 这个索引 19740 就是 FieldRVA 的条目指到的地方;也可以通过脚本扫描所有 0/1 区块得到
start_index = 19740

block = struct.unpack_from(
f"<{BLOCK_INTS}I",
field_and_param_default_data,
start_index * 4
)

matrix = np.array(block, dtype=np.uint8).reshape((SIZE, SIZE))

# 2) 生成几张帮助分析的图片
#
def write_qr(arr: np.ndarray, path: Path, scale: int = 20) -> None:
"""把 0/1 阵列保存成放大后的黑白 PNG."""
img = np.where(arr == 1, 0, 255).astype(np.uint8)
img = cv2.resize(img, (SIZE * scale, SIZE * scale), interpolation=cv2.INTER_NEAREST)
cv2.imwrite(str(path), img)


# 原始矩阵直接存成二维码(1 表示黑模块)
write_qr(matrix, Path("qr_final.png"))
print("处理完成,已生成 qr_final.png ")
print(hex(1782496+ 19740*4))

image-20251105211440808

扫出的flag

NSSCTF{f7c244c0-5f43-4829-83da-ebbc713324f5}

解法4:CE动调+半静态

其实跟静态分析没啥区别了,不知道有没有更好的方法

就是之前静态分析完,基于这些知识我们去ce下断点dump这个矩阵

之前的静态分析已经知道了偏移和 RVA。

要拿到在 Unity 游戏里,QRCodeBuilder 脚本实例中的字段:

1
this + 0x48 → qrMatrix (int[,])

构建二维码时,这个 qrMatrix 会被填满,然后立刻被释放。

所以流程是:

  1. Attach 游戏进程
  2. 找到 GameAssembly.dll 基址
  3. 在 ConstructQRCode (0x810F90) 附近下断
  4. 查看 this 指针
  5. 顺着 this+0x48 拿出矩阵地址
  6. Dump 出矩阵内容

第一步:Attach 游戏进程

先启动游戏,但不要进入地图

  1. 打开 CE;
  2. 点左上角;
  3. 成功后 CE 窗口左下角会显示「附加到进程」,选attach

image-20251105212352395

拿 GameAssembly.dll 基址

image-20251105212747610

  1. 在 CE 里打开「内存视图」(Memory View);
  2. 菜单栏 View → Memory Regions
  3. 找到一行名字含有 GameAssembly.dll
  4. 记下它的起始地址(如7FF864330000 );这就是模块基址。

image-20251105212835208

第三步:跳到 ConstructQRCode

RVA 是 0x810F90。在 CE 的汇编窗口(Memory View)里按:

1
Ctrl+G → 输入 GameAssembly.dll+810F90

这就是函数入口(QRCodeBuilder.ConstructQRCode())。

image-20251105212940801

第四步:下断点

image-20251105213002640

右键第一条指令 → 「断点 → 在此处设置断点」。

然后:

  1. 回到游戏;
  2. 做那个动作让二维码生成(Enter);
  3. 游戏会在断点处暂停

image-20251105213049463

第五步:看 this 指针(RCX)

在 CE 的 寄存器窗口

  • RCX。在 IL2CPP 下,RCX 通常是 this 指针(对象指针)。
  • 右键 RCX → 「在内存中查看此地址」。

这就是 QRCodeBuilder 实例。

image-20251105213115888

第六步:看字段偏移

在「内存视图」里:

1
2
3
4
5
6
this + 0x20 → blackMaterial
this + 0x28 → whiteMaterial
this + 0x30 → moduleSize
this + 0x34 → origin
this + 0x40 → thickness
this + 0x48 → qrMatrix

直接在 CE 里:

  1. 右键 → 「转到地址」;

  2. 输入:

    1
    RCX+48
  3. 看这一处存的 8 字节内容,就是一个指针(qrMatrix 对象地址)。

image-20251105213503882

image-20251105213517821

image-20251105213543762

可以看到下面全是01字节

第七步:跟进 qrMatrix

右键这 8 字节 → 「在内存中查看此地址」。这里是 int[,] 结构体:

一般是:

1
2
3
4
5
00: vtable指针
08: rank (维度)
10: length1
14: length2
18: 数据区...

(不同 Unity 版本可能略有不同)

能看到成片的 00 00 00 00 / 01 00 00 00 模式,这就是二维码的黑白格。

第八步:dump

save指定内存区域

image-20251105213644382

image-20251105213734518

这个49 03 是元素总数 0x349 = 841(小端的 49 03 00 00,841 是 29×29,说明这是一个 29×29 的二维码矩阵

我们从这四个字节后开始dump

定位到 49 03 00 00 这一段的地址是 **200D898A018**。这说明这是二维码矩阵数据前面的计数字段(841个元素)。

我们要从它后面紧接着的 01 00 00 00 ... 开始 dump,长度大约 841 × 4 字节。

自己算下,然后填进去就可以dump了

把ce的头去掉就是这样的

image-20251105214357210

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import numpy as np
import cv2
from pathlib import Path

dump_path = Path("dump.bin") # 你的dump文件
out_path = Path("qr.png")

SIZE = 29
COUNT = SIZE * SIZE # 841

raw = dump_path.read_bytes()

def extract_segment(data: bytes, count: int):
"""在所有字节对齐(0..3)下,寻找长度为 count、元素仅为{0,1}的 int32 段"""
for align in range(4):
if len(data) - align < 4:
continue
# 裁成4字节对齐长度
usable = data[align: len(data) - ((len(data) - align) % 4)]
if len(usable) < 4:
continue
ints = np.frombuffer(usable, dtype="<i4")
# 完全匹配
if len(ints) == count and np.all((ints == 0) | (ints == 1)):
return ints
# 滑窗搜索
if len(ints) >= count:
mask01 = (ints == 0) | (ints == 1)
valid = mask01.astype(np.int32)
csum = np.cumsum(valid)
wins = csum[count-1:] - np.concatenate(([0], csum[:-count]))
idxs = np.where(wins == count)[0]
if idxs.size > 0:
start = int(idxs[0])
return ints[start:start+count]
return None

seg = extract_segment(raw, COUNT)
if seg is None:
raise RuntimeError("未在 dump 中找到连续的 0/1 int32 段;请检查 dump 起点/长度")

# 1 -> 黑(0),0 -> 白(255)
matrix = seg.reshape(SIZE, SIZE).astype(np.uint8)
img = np.where(matrix == 1, 0, 255).astype(np.uint8)

# 放大保存
scale = 20
img_big = cv2.resize(img, (SIZE*scale, SIZE*scale), interpolation=cv2.INTER_NEAREST)
cv2.imwrite(str(out_path), img_big)
print("saved", out_path)

image-20251105214442184