一道看起来是"矩阵运算"的逆向题,真正的难点不在算法本身,而在你那套复刻出来的算法到底对不对。 本文记录从「复刻 5 轮全部失败」到「黑盒测量 + 线性代数一次出解」的完整过程。
0. 题目与结果
| 项 | 值 |
|---|---|
| 附件 | MatrixHunter.exe |
| 大小 | 92,672 字节 |
| 类型 | x86_64 PE |
| ImageBase | 0x140000000 |
| 输入长度 | 29 字节 |
| 正确输入 | bugku{M4tr1x_0v3r_GF2_1s_fun} |
先给结论:程序把 29 字节输入 变换成 32 字节输出 ,再与两个 16 字节常量逐字节比较,全等才打印 Correct!。
关键发现是:这个变换对输入是「仿射」的 (线性 + 常数偏移)。一旦确认这一点,就不需要理解 M 表、perm、splitmix64 RNG 的任何细节------只需要测 232 次,就能完整刻画整个映射,然后用高斯消元解出来。

1. 静态分析:程序到底在做什么
用 radare2 定位 main(0x140001420),先把几个关键地址记下来:

1.1 反调试
序言里有两处:
asm
call IsDebuggerPresent ; 非 0 直接 return
call GetTickCount
cmp eax, 0xfff ; <= 0xfff(即启动后 4 秒内)直接 return
jbe exit
这两处决定了后面做动态仿真时必须给它们返回值 (IsDebuggerPresent → 0、GetTickCount → 0x20000)。
1.2 输入与长度校验
asm
lea rcx, "Enter the flag: "
call printf
lea rcx, [rbp-0x60]
mov edx, 0x40
mov r8, stdin
call fgets
; 循环剥掉尾部 \r \n 并清零
call strlen
cmp eax, 0x1d ; 必须等于 29
jne wrong
长度不等于 29 就直接 Wrong!,所以未知量恰好是 29 字节 = 232 比特。
1.3 输入装配与置换
asm
movaps xmm0, [rbp-0x60] ; input[0..15]
movaps [rbp+0x00], xmm0
movdqu xmm0, [rbp-0x53] ; input[13..28]
movdqu [rbp+0x0d], xmm0
mov byte [rbp+0x1f], 0
mov word [rbp+0x1d], 0
得到 32 字节工作缓冲:
W = input[0..28] || 0x00 0x00 0x00
之后从 0x1400019e0 开始的一段是纯字节 gather (rax 从 3 步进 4 到 0x23):
V[i] = W[perm[i]]
X = V ; 仍是 32 字节
注意这里没有任何位运算,置换只重排字节顺序。这一点很重要:它意味着后面的"位级"结构全部来自轮函数,而不是置换。
1.4 轮密钥与 256 轮变换
asm
; K0 = dword[0x1400070a0 .. 0x1400070af] (8 个 dword)
; K1 = dword[0x1400070b0 .. 0x1400070bf] (8 个 dword)
u0 = X[0:16] ^ K0
u1 = X[16:32] ^ K1
然后 0x140001ab0 起是 256 轮 ,每轮处理 M 表的一行 32 字节(8 个 u32):
t = (M[j] & u0) | (M[j+4] & u1) ; 4 个 dword
t ^= ... ; 折叠
t >>= 16; t >>= 8; t >>= 4; t >>= 2; t >>= 1
bit = t & 1 ; 取奇偶位
out_bit[j] = bit ; shl edx, cl 写入 32 字节输出
即:每一轮把 32 字节压缩成 1 个奇偶位,256 轮得到 256 位 = 32 字节。
整条数据流如下(静态示意图):

同一份数据流的交互版(Archify 渲染,可在线路图里逐节点查看):

1.5 比较
asm
pcmpeqb xmm1, [0x140004100] ; out[16:32] vs 3d785f47a59be7f301e23017ca912d2c
pcmpeqb xmm0, [0x140004110] ; out[0:16] vs 200cd90738d03bea348b4337a81b3ae1
pand xmm0, xmm1
pmovmskb eax, xmm0
cmp eax, 0xffff
32 字节全等才 Correct!。
目标向量拼起来就是:
target = 200cd90738d03bea348b4337a81b3ae13d785f47a59be7f301e23017ca912d2c
2. 为什么"复刻算法"这条路走不通
一开始的直觉是:既然是矩阵运算,那把 M 表、perm、splitmix64 RNG、round_key 全部复刻出来,正向算一遍,然后对 GF(2) 求逆就行了。
结果连续 5 轮全部失败:
| 轮次 | 做法 | 结果 |
|---|---|---|
| 1 | 复刻 M 表 + perm | 正向与真机 3 次逐字节一致,但求逆后不可打印 |
| 2 | 修正 perm 推导顺序 | 方程不一致(rank < 232) |
| 3 | 用 frida dump 真机中间量校准 | 中间量对齐了,最终仍不可打印 |
| 4 | 更换求逆库(sympy / 自写) | 同上 |
| 5 | 开始怀疑题目本身 | 判定 target_not_reachable_from_29char_input(其实是自己模型错了) |
矛盾点很刺眼:正向能对上,反向解不出 。这说明复刻的模型在某个细节(perm 的某处、或字节装配的某一步)有偏差,而"正向巧合对上"并不能排除这种偏差------因为正向只验证了 3 个点。
教训:当"模型正向一致、反向无解"时,不要继续修模型,要换一个不依赖模型的判据。
3. 换思路:用 Unicorn 把校验函数变成黑盒 oracle
既然模型不可信,那就直接执行真实机器码。用 Unicorn 铺一个最小 PE 运行环境:
python
# 按 PE 节表铺映像到 0x140000000(14 个节)
# 45 个 IAT 槽全部指向 0x30000000 stub 页(每槽一个 0xC3 = ret)
# 用 UC_HOOK_CODE 拦截,按函数名实现最小语义
HOOKS = {
'printf': lambda: 0,
'fflush': lambda: 0,
'__acrt_iob_func': lambda: FAKE_FILE,
'fgets': lambda buf, n, fp: write(buf, INPUT + b'\n\x00'),
'strlen': real_strlen,
'puts': lambda: 0,
'IsDebuggerPresent': lambda: 0,
'GetTickCount': lambda: 0x20000, # > 0xfff,绕过 4s 反调试
}
从 main(0x140001420)一直跑到 0x140001b3d(cmp eax, 0xffff)停下,读 [rbp-0x20, rbp) 的 32 字节和 eax。
整个求解过程中五方的交互时序(Archify 时序图):

3.1 先自证 oracle 等价真机
这一步不能省。用两个固定输入和历史上的 frida 真机 dump 对比:
input = b'B'*29 -> 1545226b789928c236fd8fbed5f2f3320f12460c6296d1b97d80e076401e95bb
input = b'A'*29 -> 8933b2d92643d3fc732c25ed0468eaa69a2de5d2ba56bcfcb4514efe8a772d66
两份结果与真机 dump 逐字节一致 ⇒ oracle 与真机等价。

4. 仿射性 ⇒ 232 次测量刻画整个映射
4.1 先假设是仿射的
如果这个映射 F: {0,1}^232 -> {0,1}^256 满足
F(x) = A·x ⊕ b
那么只要测出 b = F(0) 和 A 的 232 个列向量,整个函数就完全确定了:
col[k][b] = F(base ^ (1 << (k*8 + b))) ^ F(base)
取 base = b'B'*29(全 'B',避免 0 字节带来的边界情况),一共需要 232 次 oracle 调用(实测缓存下来约 2 分钟)。
4.2 再验证仿射性成立
用 3 个随机的 29 字节输入:
python
pred = out0
for k in range(29):
for b in range(8):
if (x[k] >> b) & 1:
pred ^= col[k][b]
assert pred == oracle(x) # 3/3 通过
3/3 全部通过 ⇒ 仿射性成立,模型可以放心用。
这一步是整个解法里最关键的一步 。它把"要不要相信自己的复刻"这个问题彻底绕开了:无论作者用了什么 RNG、什么表、什么置换,只要这个函数是仿射的,232 次测量就是完备的。
5. GF(2) 高斯消元
现在把问题写成方程组。设未知位向量为 x(232 位),则
A·x = target ⊕ out0
其中 A 是 256×232 的 0/1 矩阵,每列就是测出来的 col[k][b]。

矩阵的列权重分布(每列 1 的个数)也在正常范围内,既不稀疏也不稠密,符合随机线性映射的特征:

5.1 消元
标准的 GF(2) 高斯消元(XOR 代替加减):
python
rows = [ (int.from_bytes(target ^ out0, 'big') << 232) | ((1 << c)) for ... ]
# 按位主元消元
rank = 0
for col in range(232):
piv = next((r for r in range(rank, 256) if (rows[r] >> (231 - col)) & 1), None)
if piv is None:
continue
rows[rank], rows[piv] = rows[piv], rows[rank]
for r in range(256):
if r != rank and (rows[r] >> (231 - col)) & 1:
rows[r] ^= rows[rank]
rank += 1
结果:
rank = 232 / 232
自由变量 = 0 ← 唯一解
由于秩满,解是唯一的,不需要任何猜测。
6. 结果
[+] 唯一解输入 = b'bugku{M4tr1x_0v3r_GF2_1s_fun}'
[+] 全可打印 = True
[*] eax=0xffff? True
[*] 真机输出 = 'Enter the flag: Correct!'
[+] FLAG = bugku{M4tr1x_0v3r_GF2_1s_fun}

回代 oracle:eax == 0xffff(32 字节全等)✓ 真机运行 MatrixHunter.exe 并喂入该输入:输出 Enter the flag: Correct! ✓
FLAG:bugku{M4tr1x_0v3r_GF2_1s_fun}
注意这道题用 bugku{...} 前缀,不是常见的 flag{...}。
7. 踩坑清单
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | 复刻 RNG/表去求逆 | 正向对上、反向不可打印 | 改用黑盒测量,不建模 |
| 2 | 只验证 3 个点就认为模型对 | 正向一致但反向无解 | 必须做线性自检(随机输入交叉验证) |
| 3 | Unicorn 只铺代码节 | IAT 调用直接崩 | 把 45 个 IAT 槽改写成 ret,用 hook 接管 |
| 4 | 忘了反调试 | 运行 4 秒内直接 return,拿不到输出 | GetTickCount hook 返回 0x20000 |
| 5 | 用 fgets 时没加 \n\0 |
输入长度不对,直接 Wrong! |
hook 里写入 INPUT + b'\n\x00' |
| 6 | 以为置换是位运算 | 推导方向偏 | 反汇编确认是 movzx 字节 gather |
| 7 | 假设平台是 flag{} |
提交失败 | 该题为 bugku{} |
8. 一句话总结
逆向题里最贵的成本不是"看不懂算法",而是"以为看懂了算法 "。 当正向能对上、反向解不出时,正确做法是把函数当成黑盒,用足够多的测量点去刻画它------ 本题只要 232 次测量,就把一个绕了 5 轮的迷宫变成了一道线性代数填空题。
9. 图形附件(Archify 交互图)
两张交互图已就近嵌入正文(截图形式,见 §1.4 与 §3);可离线打开的独立 HTML 原件:
| 图 | 文件 | 说明 |
|---|---|---|
| 数据流(输入→输出) | matrixhunter-dataflow.html(交互图,CSDN 已省略 HTML 版) |
5 段(Input→Assemble→Permute→Key Mix→Parity+Compare)节点与流向,标注每步为何保持 GF(2) 线性 |
| 求解时序 | matrixhunter-sequence.html(交互图,CSDN 已省略 HTML 版) |
分析者 / MatrixHunter.exe / Unicorn 仿真 / cols.json / GF(2) 消元 五方的 13 条交互与三段阶段划分 |
生成、校验与截图:
text
archify finalize dataflow ... --quality showcase --json -> ok=true, status=pass
archify finalize sequence ... --quality showcase --json -> ok=true, status=pass
archify check 两张图 -> issues: []
archify visual-check <html> --out-dir wp_figs/archify_shots
-> checks: containment/readability/viewerChrome/themeStates/captures 全部 pass
CSDN 不支持托管 HTML,故正文嵌入的是
visual-check的真实渲染截图(2048×1320,深色主题), 交互原件保留在交付目录内。
本文所有数据均来自对附件的本地分析与自写脚本实测,未引用任何外部题解。