写在ai题解前:格式不会调喵所以就这样发了,复制过来的好乱(),题目质量挺好,ai运气不错,大概猜测了一下方向丢给ai就出了,下面都是ai写的了,markdown文件我放附件吧()
# AntiAi Writeup
> Category: Reverse Engineering
> Platform: Windows x64 PE
> Method: Full static analysis + deterministic reimplementation + algebraic inversion + bounded exhaustive search / Meet-in-the-Middle
> No symbolic execution, no SMT/Z3, no execution of the target binary.
## Flag
```text
flag{v4_opaque_trace_2026}
```
---
## 0. TL;DR
这题最阴的地方并不是 VM 本身,而是 **最终正确条件不是四个 worker 都成功**。
程序会对同一份输入运行 4 个 VM variant,然后把四份 worker record 再送进一个 aggregator。其中只有 **variant 2** 需要走完整 clean-success 路径;variant 0/1/3 的失败状态本身也是最终正确 digest 的组成部分。
整体数据流可以概括为:
```text
26-byte input
│
▼
invertible preprocessing
│
▼
32-byte transformed state
│
├── worker variant 0 ──┐
├── worker variant 1 ──┤
├── worker variant 2 ──┼──> 4 records
└── worker variant 3 ──┘
│
▼
aggregator
│
▼
128-bit final target
│
▼
ok / no
```
最终通过纯静态求解得到 transformed state:
```text
f1b4801d0d18245b437c05da5b354020
262485099321b50a6615bbd15e601d3f
```
将 preprocessing 逆回原始输入:
```text
flag{v4_opaque_trace_2026}
```
完整静态模型最终得到:
```text
outer mismatch = 0
return bytes = 6f 6b 0d 0a
= "ok\r\n"
```
---
# 1. 基本信息
目标是一个原生 Windows x64 PE:
```text
AntiAi.exe
```
SHA-256:
```text
b867b0c61156d7372ac084016ad98ad0bbc78f92ccf137811996f2e51b44e157
```
程序不是 .NET。
主要函数大致如下:
| 地址 | 功能 |
| ------------- | --------------------------- |
| `0x140002588` | anti-debug helper |
| `0x1400025ec` | `.text` integrity hash |
| `0x140002880` | 固定 seed 生成 |
| `0x140002940` | 四 worker record aggregator |
| `0x140002ba0` | 26-byte input preprocessing |
| `0x140002da4` | 辅助 hash |
| `0x140002f2c` | 主输入输出逻辑 |
| `0x14000306c` | 外层 wrapper/checker |
| `0x14000330c` | worker / VM |
主函数逻辑很简单:
```c
printf("input flag: ");
read_input(buf, 64);
strip_crlf(buf);
check(buf, len, result);
write(result, 4);
```
外层明确要求输入长度:
```text
len == 26
```
否则最终 mismatch 中会混入:
```c
len ^ 26
```
所以正确输入固定为 **26 字节**。
---
# 2. 完整性校验
程序有一段 `.vseal`:
```text
SEALVMTX...
```
完整性函数位于:
```text
0x1400025ec
```
它会:
1. 找到 `.text`;
2. 取 `min(VirtualSize, SizeOfRawData)`;
3. 对 `.text` 做自定义双状态 hash;
4. 如果扫描到映像内绝对地址,会将其 canonicalize 为 RVA,以兼容 ASLR;
5. 和 `.vseal` 中保存的结果比较。
原文件静态计算得到:
```text
calc:
98aef31e 57644cb6
expected:
98aef31e 57644cb6
```
因此原始文件完整性检查通过。
这部分主要用于阻止随意 patch,不参与 flag 的核心数学约束。
---
# 3. Anti-Debug
anti-debug helper 位于:
```text
0x140002588
```
它综合检查:
- `IsDebuggerPresent`
- PEB flags
- Trap Flag 等状态
最终形成一个 bitmask。
正常无调试环境下:
```text
anti_debug_mask = 0
```
静态求解时直接使用 clean path 即可。
---
# 4. 固定 Seed
函数:
```text
0x140002880
```
最终生成两个固定值:
```text
seed0 = 0xb0adeff3
seed1 = 0x03db2dd2
```
worker 的初始状态中:
```c
r13 = seed0 ^ 0x70732f2d;
```
所以:
```text
r13_init = 0xc0dec0de
```
另外一个 persistent seed:
```text
0x31415926
```
以及 opcode XOR key 的低字节:
```text
0x1f
```
这里能看到大量故意设计的人眼 magic:
```text
C0DEC0DE
31415926
A5A55A5A
C3D2E1F0
```
但直接拼这些常量并不能得到正确 flag。
---
# 5. 输入预处理
真正送入 VM 的不是 26-byte 原始输入。
预处理函数位于:
```text
0x140002ba0
```
它先将输入构造成 8 个 little-endian dword。
设输入为 `input[0..25]`,则:
```c
d0 = LE32(input + 0);
d1 = LE32(input + 4);
d2 = LE32(input + 8);
d3 = LE32(input + 12);
d4 = LE32(input + 16);
d5 = LE32(input + 20);
d6 = input[24]
| ((uint32_t)input[25] << 8)
| 0x005a0000
| 0xa5000000;
d7 = 0xc3d2e1f0;
```
也就是说,合法 preprocessing 初始状态一定满足:
```text
d6 >> 16 = 0xa55a
d7 = 0xc3d2e1f0
```
这两个 sentinel 在后续反解时是非常强的合法性约束。
## 5.1 Whitening
8 个 dword 会先经过逐 word whitening。
其中包含典型常量:
```text
0x61c88647
0x6d2b79f5
0x3c6ef372
0x7f4a7c15
0xa5a55a5a
```
所有运算均为:
- `+/- mod 2^32`
- XOR
- ROL / ROR
没有信息损失。
## 5.2 8 轮 ARX
之后执行 8 轮 8×32-bit ARX permutation。
其核心操作仍然只有:
```text
ADD mod 2^32
XOR
ROL / ROR
```
因此整个 preprocessing 是严格可逆的。
我实现了完整 `inverse_preprocess()`,并做随机 round-trip:
```text
input
↓
preprocess
↓
inverse_preprocess
↓
input
```
100 组随机输入全部通过。
因此,只要能求出 VM 使用的 32-byte transformed state,就可以直接逆回 26-byte flag。
---
# 6. VM 总体结构
worker 位于:
```text
0x14000330c
```
其中实现了一套 23 opcode 的 VM。
opcode 大致包括:
```text
0 / 11 读取 transformed byte
1 混合
2 / 14 rotate
3 add
4 / 13 写输出
5 更新 r13
6 / 16 更新 x34 / persistent seed
7 / 17 pos++ + 动态跳转
8 成功终止 opcode
9 错误终止
10 accumulator
12 mix
15 store + mix
18~21 四种 8-bit 可逆 primary transform
22 secondary-byte relation / stack group
```
---
# 7. 最容易踩的坑:op7 / op17
最开始静态翻译时,很容易把 VM 误判成 8 步后 OOB。
原因是 `op7/op17` 的跳转混合中存在两次右移。
相关汇编:
```asm
mov r8d, seed
shr r8d, 0xc
xor r8d, seed
shr r8d, 0x7
```
正确表达式是:
```c
r8 = ((seed >> 12) ^ seed) >> 7;
```
如果漏掉第二个 `>> 7`,VM 会在模型中迅速跳出 opcode 表,从而产生错误结论。
修正后,四个 variant 都正好运行:
```text
257 steps
pos = 32
```
最终 PC:
```text
v0 -> 0x408
v1 -> 0x410
v2 -> 0x400
v3 -> 0x418
```
这四个 PC 与程序尾部生成的目标 PC 完全一致。
---
# 8. 将 257 条 VM 指令压缩成 32 个 Block
继续观察 VM trace,可以发现每处理一个 output byte,都遵循相对固定的控制结构:
```text
op0
│
▼
动态选择 transformed[index]
│
▼
18 / 19 / 20 / 21 中的一种字节双射
│
▼
可选 op22
│
▼
写 output[pos]
│
▼
更新 r13
│
▼
op6 / op16 更新 x34 / seed
│
▼
op7 / op17 跳到下一 block
```
因此 257-step VM 可以压缩成 **32 个 logical block**。
## 8.1 动态 primary index
每个 block 首先根据:
```text
variant
pos
r13
x34
helper constant
```
计算:
```text
idx ∈ [0,31]
```
然后读取:
```c
b = transformed[idx];
```
成功路径要求 32 个 primary index 恰好构成完整排列。
因此最终:
```text
maskA = 0xffffffff
```
## 8.2 Primary transform
四种核心字节变换是 opcode:
```text
18
19
20
21
```
它们都是 8-bit 双射。
形式大致为:
```c
op18:
out = rol8(b ^ r8, k) + r10;
op19:
out = ror8(r10 + b, k) ^ r8;
op20:
out = rol8(bl * b + r10, k) ^ r8;
op21:
out = (ror8(b ^ r8, k) - r10) * bl;
```
其中 `bl` 始终为奇数,因此在模 `2^8` 下存在乘法逆元。
所以这四种变换都可逆。
## 8.3 op22
部分 block 后还有 opcode 22。
它引入第二个 transformed byte:
```text
transformed[j]
```
最后 output 形如:
```c
output[pos] =
primary_transform(transformed[idx])
^ rol8(transformed[j], rr + 1);
```
`op22` 还维护:
```text
stack
flag
```
并且存在两个四联块:
```text
pos 12 ~ 15
pos 24 ~ 27
```
这是后续搜索中自由度最大的部分。
---
# 9. 验证 Block 模型
为了防止第二次发生“漏一条汇编”的问题,我没有直接相信压缩模型。
我同时保留:
1. 完整 257-step VM interpreter;
2. 32-block compressed model。
然后随机生成:
```text
hash12
transformed[32]
variant
```
逐 block 比较:
```text
r13
x34
stack
flag
output[pos]
```
500 组随机测试全部一致:
```text
RELAXED 32-BLOCK CROSSCHECK PASS 500 trials
```
因此后续求解使用的 32-block 模型与实际 VM 数据路径一致。
---
# 10. Worker 尾部目标生成
每个 worker 跑完 VM 后,还要检查:
```text
r13
persistent seed
x34
PC
steps
pos
stack
若干计数器
maskA / maskB
```
目标不是明文存储,而是由:
```text
seed0
seed1
variant
常量表
ROL
fmix 风格整数混合
```
动态生成。
独立重算得到:
```text
variant 0:
r13 = 17cac189
seed = 0c49f956
x34 = ef8b64c2
PC = 00000408
variant 1:
r13 = 072183de
seed = c8782b4d
x34 = d520b1db
PC = 00000410
variant 2:
r13 = e6a68d04
seed = 408cf867
x34 = 2e8c989c
PC = 00000400
variant 3:
r13 = 7247a3a0
seed = 934004d6
x34 = ecdc4e20
PC = 00000418
```
---
# 11. VM 输出后还有 4 轮 ARX
32-byte VM output 并不会直接与表比较。
worker 尾部会把它视为:
```text
8 × uint32_t
```
然后执行 4 轮可逆 ARX。
最终与 `.text` 中:
```text
0x1400021a0
```
附近的目标表比较。
这层 ARX 同样只有:
```text
ADD
XOR
ROL/ROR
```
所以可逆。
我实现了 `inv_arx_core()`,并用 1000 组随机 round-trip 验证通过。
---
# 12. Variant 2 的原始 VM 输出目标
variant 2 最终 8 个 mixed dword 目标:
```text
7f3a3627
92e3afa5
94944e09
fead080a
b84615b9
8d6de414
8c64782f
ff692b07
```
逆掉尾部四轮 ARX,得到 v2 的 VM raw output 必须是:
```text
40e086d0ceff2d270de6e28bd49f57af
beb5316bd8322983820999bccb16ab1d
```
也就是:
```text
40 e0 86 d0 ce ff 2d 27
0d e6 e2 8b d4 9f 57 af
be b5 31 6b d8 32 29 83
82 09 99 bc cb 16 ab 1d
```
---
# 13. Outer Wrapper
外层函数:
```text
0x14000306c
```
逻辑为:
```text
input
│
├── preprocess -> transformed[32]
│
├── worker(v0)
├── worker(v1)
├── worker(v2)
└── worker(v3)
│
▼
records[4]
│
▼
aggregator
│
▼
final compare
```
调用顺序被一个 hash 打乱,但 record pointer 同时按 variant index 放置,所以最终:
```text
records[0] = v0
records[1] = v1
records[2] = v2
records[3] = v3
```
调用顺序只是干扰项。
---
# 14. Aggregator
aggregator:
```text
0x140002940
```
初始 4 个状态:
```c
S0 = seed0 ^ 0x243f6a88;
S1 = seed1 ^ 0x85a308d3;
S2 = ror(seed0, 21)
^ seed1
^ 0x13198a2e;
S3 = ror(seed1, 13)
^ seed0
^ 0x03707344;
```
数值为:
```text
S0 = 9492857b
S1 = 86782501
S2 = 7fbd3a79
S3 = dd4d826e
```
## 14.1 每个 record 生成 q_i
对于第 `i` 个 record:
```c
r9 = (0xb5297a4d - i * 0x4ad685b3) ^ record.err;
q = rol(record.seed, i + 9)
^ rol(r9, i + 13)
^ rol(record.x34, i + 5)
^ record.r13
^ ((record.steps + 1) * 0x045d9f3b)
^ (record.pos << 17)
^ record.pc;
```
然后对 8 个 mixed word 继续:
```c
for (j = 0; j < 8; j++) {
last =
rol(
((j + 1) * 0x7f4a7c15)
^ mixed[j]
^ q,
((3 * i + j) & 7) + 5
);
q = last + 0x6d2b79f5;
}
```
得到最终:
```text
q_i
```
## 14.2 State 更新
aggregator 对四个 q 做类似:
```c
S[i] =
rol(q_i ^ S[i], i + 5)
- 0x61c88647;
S[i+1] ^= B_i(q_i);
```
最后再经过一层 128-bit whitening,得到外层 compare 值。
---
# 15. 逆 Outer 128-bit Whitening
最后一层对四个 dword 只有 XOR/rotate,视作 GF(2) 线性变换。
构造 128×128 bit matrix 后求 rank:
```text
rank = 128
```
所以它可逆。
逆最终 expected aggregate:
```text
892a48af
b45dcd47
006c5ac3
b0b1062a
```
得到 required internal states:
```text
a24ae331
3a0d7edb
c4b33663
de22dab8
```
---
# 16. 求唯一 q-chain
将 aggregator 的四次 state update 展开,可以把整个问题化成:
```text
q0 -> q1 -> q2 -> q3
```
其中只剩一个 32-bit 自由变量。
不需要 SMT。
直接 C 遍历完整:
```text
q0 ∈ [0, 2^32)
```
最终整个空间只有一个根:
```text
q0 = 1f1cc1bc
q1 = 1b7d21a2
q2 = 47d3d751
q3 = 27ff56e7
```
这一步很重要,因为它揭示了题目的真正设计。
---
# 17. 不是四个 Worker 都成功
如果假设四个 variant 都满足各自的 clean-success worker target,可以算出对应 aggregator q。
其中:
```text
variant 2 clean-success q
= 47d3d751
```
恰好等于外层唯一要求:
```text
q2 = 47d3d751
```
而其它 variant 并不满足外层所需 record。
进一步用最终 flag 完整复算后可以看到:
```text
v0 err = ffffffff
v1 err = ffffffff
v2 err = 00000000
v3 err = ffffffff
```
所以这题的真正结构是:
> **只有 variant 2 走 clean success。variant 0/1/3 的失败行为本身就是正确 aggregator fingerprint 的一部分。**
这也是 `AntiAi` / `opaque_trace` 这个设计最核心的误导。
---
# 18. 开始解 Variant 2
Variant 2 的严格目标:
```text
steps = 257
pos = 32
PC = 0x400
seed = 408cf867
r13 = e6a68d04
x34 = 2e8c989c
maskA = ffffffff
maskB = ffffffff
err = 00000000
stack = 32
```
raw VM output:
```text
40e086d0ceff2d270de6e28bd49f57af
beb5316bd8322983820999bccb16ab1d
```
---
# 19. r13 更新可逆
每个 block 的 r13 更新可写成:
```c
r13_next =
rol32(
b * 0x045d9f3b
+ r13_old
+ (pos ^ 0xa7),
(pos % 13) + 5
)
^ 0x9e3779b9;
```
其中:
```text
0x045d9f3b
```
是奇数,因此在 `Z / 2^32 Z` 下可逆。
其逆元:
```text
0x119de1f3
```
所以若知道:
```text
r13_old
r13_next
pos
```
可以直接求 primary byte:
```c
t =
ror32(
r13_next ^ 0x9e3779b9,
(pos % 13) + 5
);
diff =
t
- r13_old
- (pos ^ 0xa7);
b =
diff * 0x119de1f3 mod 2^32;
```
合法时必须:
```text
b < 256
```
这是后续 MITM 的重要剪枝。
---
# 20. x34 更新同样可逆
x34 的更新依赖:
```text
r13_next
x34_old
output[pos]
pos
block helper constants
```
但在反向时:
```text
x34_old
```
可以由:
```text
r13_next
x34_next
output[pos]
pos
```
直接逆出,并且 **不需要先知道 primary byte**。
这使得反向搜索比直接枚举 256 字节快很多。
---
# 21. 前 12 Block Prefix
v2 在 pos2 / pos3 的 `op22` secondary index 是固定:
```text
pos2 -> transformed[3]
pos3 -> transformed[2]
```
因此可以直接枚举:
```text
hash12 ∈ [0,4095]
transformed[2] ∈ [0,255]
transformed[3] ∈ [0,255]
```
然后从 block0 正向跑到 block11。
总搜索空间:
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 1天前
被mxym_编辑
,原因: