-
-
[分享]kctf2026_CrackMe08 题解
-
发表于: 3天前 32
-
看雪 2026 CTF · kctf2026_CrackMe08 题解
两层自解密 + 自校验密钥 + GF(2¹²⁷−39) 上的五次多项式与 40 半轮 Feistel
—— 以及为什么这一整条链其实是可逆的。
答案:kanxue@2o26o8!@#
$ printf 'kanxue@2o26o8!@#\n' | ./kctf2026_CrackMe08.exe
Input:correct
本文最后附一份只读原始 exe、不依赖调试器与模拟器的完整求解脚本,运行耗时 0.15 秒,
自动完成两层脱壳、常量重建、Feistel 逆推、GF(p) 求根,并对全部 6 项校验做交叉验证。
0. 一句话路线
这题的壳做得很凶:两层自解密,第二层的密钥是第一层解密结果的哈希(改一个字节就全崩),核心校验里还塞了五路反调试。看上去只能动态调。
但真正的突破口只有一句话:
校验的期望值与输入无关。
确认这一点之后,问题就从"算哈希比对"变成"给定输出求输入"。而整条链没有任何单向压缩:
| 环节 | 形式 | 可逆性 |
|---|---|---|
16 字符 → acc |
base94 Horner,且 94¹⁶ < p 无约减 | 双射 |
acc → t5 |
GF(p) 上的五次多项式 | 求根,本题解唯一 |
t5 → 4 个输出字 |
40 个半轮 Feistel | 结构可逆 |
倒着走一遍就出明文。
1. 目标概况
| 项 | 值 |
|---|---|
| 格式 | PE32+ / x86-64 / Windows CUI |
| 大小 | 27,136 字节 |
| MD5 | 88c2ab23c7b77a560ce5bef6ecc4adc3 |
| SHA1 | 03a1918eff66e07e2a10deae7de9075ccc465edd |
| 链接器 | MSVC 14.44 |
| 入口 | 0x1400012C0(直接就是 main,无 CRT) |
| 重定位 | 已剥离(固定基址 0x140000000) |
导入表只有 8 个 KERNEL32 函数,一个 CRT 都没有:
GetStdHandle GetCurrentProcess FlushInstructionCache
VirtualProtect GetModuleHandleA GetProcAddress
GetCurrentThread GetThreadContext
这份清单本身就是情报:
VirtualProtect+FlushInstructionCache→ 自修改代码GetCurrentThread+GetThreadContext→ 硬件断点检测- 没有任何 I/O 函数 → I/O 走别的路
真正的 I/O 走 ntdll 的直接系统调用,.rdata @ 0x140007078 起躺着五个明文函数名:
NtWriteFile NtReadFile NtTerminateProcess
NtQuerySystemTime NtQueryInformationProcess
附带效果: 所有输入输出都不经过可下断的 Win32 API。
bp kernel32!ReadConsoleA这类常规起手式全部落空。
2. 静态侦察:作者留下的两份"礼物"
在动手脱壳之前,先把静态能拿的信息拿干净。这题有两处直接把结构交代清楚了。
2.1 .rdata$zzzdbg 里的节贡献表
MSVC 的调试元数据把原始节贡献表留在了文件里,明文可读:
| 贡献段 | RVA | 大小 | 状态 |
|---|---|---|---|
.text$mn |
0x1000 |
0x0800 |
明文 |
.text$smca |
0x1800 |
0x0510 |
加密 — SMC 第一层 |
.text$smcb |
0x1D10 |
0x4962 |
加密 — SMC 第二层 |
smc = self-modifying code。作者自己给段起的名字就把层数说清楚了。
.text 全长 0x5672,其中只有最前面 2 KB 能直接反汇编,剩下 20 KB 都是密文:
0x1000 0x1800 0x1D10 0x6672
| .text$mn |smca | .text$smcb (18 KB) |
| 明文 2KB |1.3KB | 密钥流加密 |
2.2 .tgt 节
一个 0x70 字节的自定义节,前 48 字节是解密参数,后面是掩码过的比对目标:
| 偏移 | 值 | 含义 |
|---|---|---|
A000 |
0000000000004962 |
smcb 长度 |
A008 |
0000000000001D10 |
smcb RVA |
A010 |
0000000000000500 |
输入字节和目标 = 1280 |
A018 |
0000000000000000 |
种子槽(实际为 0) |
A020 |
0000000000000510 |
smca 长度 |
A028 |
0000000000001800 |
smca RVA |
A030 |
0E07EF9C6DB93E02 |
目标 · FNV₆ |
A038 |
62D1EE2F69239D2E |
未使用 |
A040 |
A1B65864A3200243 |
目标 · 链 2 输出 B |
A048 |
F8F66799B7125FF2 |
目标 · 链 2 输出 A |
A050 |
B5EE7AFAB0C647E6 |
目标 · 链 1 输出 B |
A058 |
6EC5353349FE0A86 |
目标 · 链 1 输出 A |
A060 |
B10D22D0EE95BA31 |
目标 · FNV₄ |
A068 |
6666666666666666 |
填充 |
一个有意思的细节:
A038的值62D1EE2F69239D2E后来被证实恰好等于掩码本身(PRNG 第 85 个输出)。也就是说这个槽解掩码后是 0 —— 作者用同一套 PRNG 生成目标时留下的空槽。这反过来印证了 PRNG 流是完全确定的。
3. 第一层:XOR 0x5A 与直接系统调用
main(0x1400012C0)开头就做第一层解密,参数全从 .tgt 读:
call cs:GetModuleHandleA ; NULL → 映像基址
mov r8, cs:qword_14000A028 ; 0x1800 smca RVA
mov rdx, cs:qword_14000A020 ; 0x0510 smca 长度
call cs:VirtualProtect ; → PAGE_EXECUTE_READWRITE
; ... SSE 循环,逐 16 字节 xor 0x5A
; (xmm2 取自 .rdata 那 16 个连续的 'Z' = 0x5A)...
xor byte ptr [rax], 5Ah
call cs:FlushInstructionCache
同一个 0x5A 也用在字符串上,.rdata @ 0x140007050:
13 34 2A 2F 2E 60 → "Input:"
39 35 28 28 3F 39 2E 50 → "correct\n"
3C 3B 2F 36 2E 50 → "fault\n"
(用 strings 直接看能看到 Inpuf、faulf 这种半通不通的串,就是因为它们在别处以立即数形式出现。)
3.1 系统调用号是"抠"出来的
0x140001220 这个小函数:
GetModuleHandleA("ntdll.dll")→GetProcAddress(name)- 在导出桩的前
0x40字节里扫0F 05(syscall) - 找到后往回扫
B8(mov eax, imm32),把立即数取出来当调用号
然后 0x1400017B0 的裸桩负责把 Win64 调用约定的参数搬成系统调用约定,再 syscall:
mov r10, rdx
mov rdx, r8
mov r8, r9
mov r9, [rsp+28h]
; ... 把 [rsp+30h..] 依次前移 8 字节 ...
mov eax, ecx ; ecx = 系统调用号
syscall
retn
3.2 主流程
- 解密
.text$smca NtWriteFile打印Input:NtReadFile读最多0x3F字节,剥掉尾部\r/\n- 长度必须恰好 16,否则打印
fault - 调
0x140001070 - 调
0x1400018D0—— 刚解密出来的真正校验入口 - 返回 1 →
correct,否则fault
⚠️ 陷阱:
0x140001070
它把输入做成一个 FNV 变体(初值0x243F6A8885A308D3,乘0x100000001B3,每轮ror 57)去和0x1111111111111111比。
它的返回值从来没被使用过;比中了也只是改一个后面用不到的全局量,顺带用NtQuerySystemTime往.data里搅点噪声。
纯干扰。这是我判断"哪些代码不用管"时省下最多时间的一处。
4. 第二层:自校验密钥流
0x1400018D0 负责解开第二层,而密钥来自第一层解密后的字节:
# 1) 对内存中已解密的 .text$smca(0x510 字节)跑两条 FNV 链
h1 = 0xCBF29CE484222325 # FNV-1a 64 位标准初值
h2 = 0x9E3779B97F4A7C15 # 黄金比例常数
for c in smca_plain:
h1 = ((h1 ^ c) * 0x100000001B3) & M64
h2 = ((h2 ^ c) * 0x100000001B3) & M64
# → h1 = 0x73FB4F498AAB364F h2 = 0xB157C7E044B966DF
# 2) 用 (h1, h2) 生成密钥流解开 .text$smcb
def ks(i):
v = ((i * 0x9E3779B97F4A7C15) & M64) ^ h2 ^ h1
v = (v ^ (v >> 33)) & M64
return ((v * 0xFF51AFD7ED558CCD) & M64) >> 56 # 取最高字节
for i in range(0x4962):
smcb[i] ^= ks(i)
(0xFF51AFD7ED558CCD 是 MurmurHash3 的 finalizer 常数,0x9E3779B97F4A7C15 是 64 位黄金比例常数 —— 认出这两个能省不少时间。)
这就是反补丁机制。
只要改动.text$smca里任意一个字节(比如下一个int3断点),h1/h2就变,18 KB 的第二层解出来全是垃圾,直接崩。动态调试的正确姿势: 断点只能下在
.text$mn(0x1000–0x1800)。那一段不参与任何哈希,且0x1400015DF处正好是call 0x1400018D0,是观察全局状态的理想落脚点。
不过既然这一切都是确定性的,更省事的做法是在静态就把两层都解完,重新落盘成一个可以直接丢进 IDA 的 PE。之后 0x140001D10 往后的反汇编瞬间干净。
4.1 又一个幌子
; 0x140001D10 —— "真正的"校验分发
movzx edx, byte ptr [rcx+0Fh] ; 输入[15]
movzx eax, byte ptr [rcx] ; 输入[0]
imul rax, 9E3779B97F4A7C15h
shl rdx, 21h
xor rdx, rax
and eax, 7
mov rax, [rcx+rax*8] ; 查表 @ 0x140007100
call rax ; ← 看着是输入相关的间接分发
看着像根据输入选不同的处理函数,其实 0x140007100 那 8 个函数指针一模一样,全是 0x140004380:
140007100 80 43 00 40 01 00 00 00 80 43 00 40 01 00 00 00
140007110 80 43 00 40 01 00 00 00 80 43 00 40 01 00 00 00
140007120 80 43 00 40 01 00 00 00 80 43 00 40 01 00 00 00
140007130 80 43 00 40 01 00 00 00 80 43 00 40 01 00 00 00
同一个函数里还有一处 cmp 拿字节和跟 .tgt[0x10] ^ 1 = 1281 比,比中了反而返回 0 —— 这是给"想当然认为和应该等于某个值"的人准备的反向陷阱。真正的约束在 0x140004380 里,是 == 1280。
5. 反调试与常量生成
核心函数 0x140004380 先卡字节和:
and eax, 0FFFFFFh
cmp eax, cs:dword_14000A010 ; 0x500 = 1280
jz loc_140004411
xor eax, eax
retn ; 和不对 → 直接返回 0
然后收集五路反调试信号,攒进一个标志位 flags:
| 位 | 检测 | 说明 |
|---|---|---|
0x01 |
PEB->BeingDebugged |
经典 gs:[60h] + 2 |
0x02 |
[PEB+0x68] & 0x70 == 0x70 |
失效 — 见下 |
0x04 |
Dr0–Dr3 / Dr7 |
GetThreadContext 查硬件断点 |
0x08 |
NtQueryInformationProcess |
直接系统调用查 ProcessDebugPort(7) 与 ProcessDebugObjectHandle(0x1E) |
0x20 |
rdtsc 计时 |
包住一段 18 KB 求和循环,阈值 0x2FAF080(5000 万周期) |
关于 0x02 那一位:
mov rax, gs:60h
cmp [rax+2], bl ; BeingDebugged
mov eax, [rax+68h] ; ← 0x68
setnz bl
and eax, 70h
cmp al, 70h
0x68 是 32 位 PEB 中 NtGlobalFlag 的偏移;64 位 PEB 该处是 ApiSetMap 指针。在真实进程上读出来验证:
PEB+0x68 (x64: ApiSetMap) = 0x2878E940000
低 12 位 = 0x0 -> 页对齐: True
mov eax,[PEB+68h]; and eax,70h -> 0x0 ; == 0x70 ? False
PEB+0xBC NtGlobalFlag(真正) = 0x4400
ApiSetMap 指向由加载器映射的页对齐结构,低 12 位恒为 0,所以 & 0x70 == 0x70 不成立 —— 这一位永远不会被置上。真正的 NtGlobalFlag 在 x64 下位于 PEB+0xBC。这是照搬 32 位模板留下的 bug。
干净环境下 flags == 0。它接着和"对 smcb 自身再跑一遍的双 FNV 哈希"混在一起当种子:
H_a, H_b = fnv2(smcb_plain) # H_a 由 FNV 初值起,H_b 由 GOLD 起
# H_a = 0x553D3C5EF6EE11FF
# H_b = 0x4AEEE52B5738EB8F
s0 = flags ^ H_a ^ (0x9E3779B97F4A7C15 * tgt[0x18])
s1 = H_b ^ (tgt[0x18] << 33) ^ 0xCBF29CE484222325
# tgt[0x18] == 0,所以后两项各自退化
然后是 43 轮双流 SplitMix64,产出 86 个 qword,直接覆盖写在栈上那个 CONTEXT 结构体里当便签本:
ctx, a, b = [], s0, s1
for _ in range(43):
x = (a - 0x61C8864680B583EB) & M64
y = (b - 0x40A7B892E31B1A47) & M64
o1 = splitmix_finalize((x - 0x61C8864680B583EB) & M64); ctx.append(o1); b = o1 ^ y
o2 = splitmix_finalize((y - 0x61C8864680B583EB) & M64); ctx.append(o2); a = o2 ^ x
反编译陷阱: 因为便签本就是 CONTEXT,IDA 后面满屏的
Context.Dr3、Context.Xmm7、Context.Legacy[4]全都是伪随机数,不是真的寄存器。第一次读的时候我在这里愣了很久,以为校验依赖运行时寄存器状态。
另外GetThreadContext传的ContextFlags = 0x100010(CONTEXT_AMD64 | CONTEXT_DEBUG_REGISTERS),只填调试寄存器,其余字段保持栈上原样 —— 但那些位置随后全被 SplitMix 覆盖,所以不影响确定性。
这 86 个 qword 的分工(已逐项验证):
| 索引 | 用途 |
|---|---|
0 – 9 |
K₀…K₄,五个 128 位多项式系数(模 p 约减,0 映射为 1) |
10 – 11 |
RC,Feistel 的域乘轮常数 |
12 – 43 |
256 字节 S 盒(两条 qword 流按字节交错) |
44 – 83 |
40 个轮常数 cₙ(两条链各 20 个) |
84 / 85 |
目标掩码 |
这里就是全题的转折点
上面每一项都只依赖 PRNG 和 flags,没有一项依赖输入。把期望值从 .tgt 里解掩码出来之后,它们就是固定常量:
掩码:ctx[84] = 0D137C6EF0DB7AE5 ctx[85] = 62D1EE2F69239D2E
v414 = tgt[50] ^ ctx[84] = B5EE7AFAB0C647E6 ^ 0D137C6EF0DB7AE5 = B8FD0694401D3D03
v401 = tgt[58] ^ ctx[85] = 6EC5353349FE0A86 ^ 62D1EE2F69239D2E = 0C14DB1C20DD97A8
v388 = tgt[40] ^ ctx[84] = A1B65864A3200243 ^ 0D137C6EF0DB7AE5 = ACA5240A53FB78A6
v399 = tgt[48] ^ ctx[85] = F8F66799B7125FF2 ^ 62D1EE2F69239D2E = 9A2789B6DE31C2DC
fnv6 = tgt[30] ^ ctx[85] = 0E07EF9C6DB93E02 ^ 62D1EE2F69239D2E = 6CD601B3049AA32C
fnv4 = tgt[60] ^ ctx[84] = B10D22D0EE95BA31 ^ 0D137C6EF0DB7AE5 = BC1E5EBE1E4EC0D4
判定这一点的最快方法:拿两个不同输入各跑一次,看比较指令右操作数变不变。变 → 只能爆破;不变 → 想办法求逆。这一步花不到两分钟,却决定了后面所有工作的方向。
反调试的真正杀伤力:不是"检测到就退出"
值得单独指出:flags 并不用来触发任何"发现调试器就报错"的分支,而是直接进了 PRNG 种子。后果是 flags 一旦非 0,K、RC、S 盒、轮常数、目标掩码全部改变。
而 .tgt 里的目标是作者按 flags == 0 生成的固定字节。两边一起变,就意味着:
正确 flag,干净环境 -> ret = 1
正确 flag,BeingDebugged=1 -> ret = 0 ← 同一个正确答案,挂了调试器就不通过
(上面是把模拟器里 PEB->BeingDebugged 置 1 后实测的结果。)
所以"挂个调试器进去 dump 常量"这条路是死的 —— 你 dump 到的是一整套被污染的常量,用它反推出来的输入在干净环境下不成立。要么把反调试全部绕干净(PEB 标志 + 调试端口 + 调试对象句柄 + 硬件断点 + 时序),要么像本文一样干脆静态算。这个设计比常见的"检测到就 ExitProcess"高明得多,因为它不给你任何失败提示,只是安静地算错。
6. 核心算法
反汇编里到处是 imul ..., 4Eh 和对 0x7FFFFFFFFFFFFFFF:0xFFFFFFFFFFFFFFD9 的 128 位条件减法。把这两个常数认出来,整个数学结构就摊开了:
| 常数 | 含义 |
|---|---|
0x7FFF…FFFF : 0xFFFF…FFD9 |
模数 p = 2¹²⁷ − 39(素数) |
0x4E = 78 |
因为 2¹²⁸ ≡ 78 (mod p),用于把 256 位乘积折回 128 位 |
94 |
可见 ASCII 33…126 恰好 94 个 |
整条流水线:
16 字节输入 ──base94──► acc ──五次多项式──► t5 ──Feistel×40──► 4×64bit ──比对
和 = 1280 < 94¹⁶ mod p 两条链
6.1 base94 累加
acc = 0
for c in input: # 16 次
acc = (acc * 94 + (c - 33)) % p
94¹⁶ ≈ 2¹⁰⁴·⁹ < p,所以这一步根本没发生约减,acc 就是那个 94 进制整数本身。
这保证了 acc → 明文 是无歧义的双射 —— 也是最后能直接解码的原因。
6.2 五次 Horner 多项式
t = acc
for i in range(4):
t = ((t + K[i]) % p * acc) % p
t = (t + K[4]) % p # ← 最后一级只有加法,没有乘法
# 展开就是:
# t5 = acc⁵ + K₀·acc⁴ + K₁·acc³ + K₂·acc² + K₃·acc + K₄ (mod p)
K₀ = 21B3BDE1ACF9ADFA471FB28461A53BB5
K₁ = 191A3870AD3D941105F15A9C38AAE874
K₂ = 40C190E211322CAA2F01797348F02C97
K₃ = 1DF732140B8A3EF758FD09BDE0058A37
K₄ = 3F0CB8512C6F88E47E5F164FB478AAB5
RC = 4D8A9E9343D415CB83A0A488EC8523FE
6.3 40 个半轮的 Feistel
状态是两个 64 位字 (A, B),初值 A = t5 高 64 位、B = t5 低 64 位。
一次循环迭代做两个半轮,共 20 次迭代 —— 而且这样的链跑了两条。
def half(A, c): # 128 位域乘
v = ((A << 64) | c) % p
r = (v * RC) % p
return (r & M64) ^ c ^ ror64(r >> 64, 47) # → x
def F(x):
return sbox_bytewise(x) ^ ror64(x, 51)
def iteration(A, B, j, n, even):
c = carr[j] ^ (H_a if ((n & 1) == 0) == even else H_b)
A2 = F(half(A, c )) ^ B # 半轮 1
B2 = F(half(A2, c ^ GOLD )) ^ A # 半轮 2
return A2, B2
两条链的差别只有一条指令:
| 轮常数区间 | 奇偶判定 | 输出 | |
|---|---|---|---|
链 1 0x140005933 |
ctx[44:64] |
cmove → 偶数轮用 H_a |
(A,B) = (v401, v414) |
链 2 0x140005ED0 |
ctx[64:84] |
cmovne → 奇数轮用 H_a |
(A,B) = (v399, v388) |
两条链从同一个 t5 出发,各跑 20 次迭代,一共产出 4 个 64 位字。
6.4 为什么它一定可逆
那个 256 字节 S 盒是 PRNG 直接生成的,不是置换:
256 个字节里只有 166 个不同值,90 个值缺失,最大重数 4
所以 y = P(x) ^ ROR(x,51) 本身没法直接求逆。但这里根本不需要求它的逆。
这是标准 Feistel 结构 (A, B) → (A', B'),其中 A' 只依赖 (A, B),而 B' 依赖 (A', A):
- 已知
(A', B'),A'本身就是第二个半轮half()的输入 →x₂可以正向算出来 →A = B' ^ F(x₂) - 拿到
A后,x₁同样能正向算 →B = A' ^ F(x₁)
轮函数只需要正向求值,一次都不用求逆。 这是整题最关键的一步观察。
6.5 比对
最终 4 个 64 位字,加两个 FNV 校验和,共 6 项全部要对上:
fnv6= FNV(acc.lo, acc.hi, v414, v401, v388, v399) —— 唯一把acc本身也拉进来的约束fnv4= FNV(v414, v401, v388, v399) —— 纯自洽冗余
7. 逆向求解
7.1 倒推 Feistel
def inv_iteration(A2, B2, j, n, even):
c = carr[j] ^ (H_a if ((n & 1) == 0) == even else H_b)
A = F(half(A2, c ^ GOLD)) ^ B2 # 先解第二个半轮
B = F(half(A, c )) ^ A2 # 再解第一个半轮
return A, B
A, B = v399, v388 # 链 2 的目标输出
for n in reversed(range(20)):
A, B = inv_iteration(A, B, 20 + n, n, even=False)
t5 = (A << 64) | B
# t5 = 4BE831B0AD3A2D361489375BBA3FB8DE
单靠链 2 就已经给出 128 位约束,而未知量 acc 只有约 105 位 —— 已经过定。链 1 用作独立校验。
7.2 在 GF(p) 上解五次方程
g(x) = x⁵ + K₀x⁴ + K₁x³ + K₂x² + K₃x + (K₄ − t5)
d(x) = gcd(x^p − x, g(x)) # x^p mod g 用快速幂;d 是全部一次因式之积
# 再用 Cantor–Zassenhaus 拆开 d
结果 deg d = 1,只有一个根,连筛选都省了:
acc = 0x174A5E1D4CF4F146E72F70092AC
随机五次多项式在 GF(p) 上的根数期望值恰好是 1,所以"只有一个根"是常态而非巧合。
万一出现多根也不影响:还有三道独立约束可以筛 —— 16 位 base94 数字必须全部落在0…93(即无进位溢出)、明文必须全部可见、字节和必须等于 1280,再加上fnv6这条把acc本身也拉进去的哈希。
7.3 解码与验证
16 位 base94 数字全部落在 0…93 内,加 33 后都是可见字符:
kanxue@2o26o8!@#
六项校验全部命中,字节和恰好 1280(与 .tgt[0x10] 那个独立约束自洽):
FLAG = 'kanxue@2o26o8!@#'
printable=True sum=1280(need 1280)
chain1=True chain2=True fnv6=True fnv4=True
ALL CHECKS PASS
真机验证:
$ printf 'kanxue@2o26o8!@#\n' | ./kctf2026_CrackMe08.exe
Input:correct
8. 完整求解脚本
只读原始 exe,不需要调试器 / 模拟器 / IDA。与 exe 放同目录直接 python standalone.py 即可,总耗时 0.15 秒。
"""kctf2026_CrackMe08 — 纯静态求解,只读原始 exe,不需要模拟器/调试器。
用法: python standalone.py [path-to-exe]
"""
import struct, random, sys
M = (1<<64)-1
P = (1<<127)-39
GOLD= 0x9E3779B97F4A7C15
FNVB= 0xCBF29CE484222325
FNVP= 0x100000001B3
ror = lambda v,n: ((v>>n)|(v<<(64-n))) & M
EXE = sys.argv[1] if len(sys.argv) > 1 else 'kctf2026_CrackMe08.exe'
raw = bytearray(open(EXE,'rb').read())
TOFF, TRVA = 0x400, 0x1000 # .text raw / rva
off = lambda rva: TOFF + (rva-TRVA)
tgt = raw[0x6800:0x6870] # .tgt @ rva 0xA000
Q = lambda b,i: struct.unpack('<Q', bytes(b[i:i+8]))[0]
SMCB_LEN, SMCB_RVA = Q(tgt,0x00), Q(tgt,0x08) # 0x4962, 0x1D10
SUMTGT = Q(tgt,0x10) # 0x500 = 1280
SEED = Q(tgt,0x18) # 0
SMCA_LEN, SMCA_RVA = Q(tgt,0x20), Q(tgt,0x28) # 0x0510, 0x1800
# ---- 层 1:XOR 0x5A ----------------------------------------------------
for i in range(SMCA_LEN): raw[off(SMCA_RVA)+i] ^= 0x5A
smca = bytes(raw[off(SMCA_RVA):off(SMCA_RVA)+SMCA_LEN])
# ---- 层 2:密钥来自 smca 的双 FNV -------------------------------------
def fnv2(buf, a=FNVB, b=GOLD):
for c in buf:
a = ((a^c)*FNVP) & M; b = ((b^c)*FNVP) & M
return a, b
h1, h2 = fnv2(smca)
for i in range(SMCB_LEN):
v = ((i*GOLD) & M) ^ h2 ^ h1
v = (v ^ (v>>33)) & M
raw[off(SMCB_RVA)+i] ^= ((v*0xFF51AFD7ED558CCD) & M) >> 56
smcb = bytes(raw[off(SMCB_RVA):off(SMCB_RVA)+SMCB_LEN])
# ---- 常量生成:flags=0(干净环境)------------------------------------
Ha, Hb = fnv2(smcb) # H_a 由 FNV basis 起,H_b 由 GOLD 起
s0 = 0 ^ Ha ^ ((GOLD*SEED) & M)
s1 = Hb ^ ((SEED<<33) & M) ^ FNVB
def fin(z):
z = ((z ^ (z>>30))*0xBF58476D1CE4E5B9) & M
z = ((z ^ (z>>27))*0x94D049BB133111EB) & M
return z ^ (z>>31)
ctx, a, b = [], s0, s1
for _ in range(43):
x = (a - 0x61C8864680B583EB) & M
y = (b - 0x40A7B892E31B1A47) & M
o1 = fin((x - 0x61C8864680B583EB) & M); ctx.append(o1); b = o1 ^ y
o2 = fin((y - 0x61C8864680B583EB) & M); ctx.append(o2); a = o2 ^ x
def red(v): # 4 次条件减 + 0→1
for _ in range(4):
if v >= P: v -= P
return v or 1
K = [red(ctx[2*i] | (ctx[2*i+1]<<64)) for i in range(5)]
RC = red(ctx[10] | (ctx[11]<<64))
sbox = bytes(b for j in range(16) for k in range(8)
for b in ((ctx[12+2*j]>>(8*k)) & 0xFF, (ctx[13+2*j]>>(8*k)) & 0xFF))
carr = ctx[44:84] # 40 个轮常数
m84, m85 = ctx[84], ctx[85]
# ---- Feistel -----------------------------------------------------------
SBP = lambda x: sum(sbox[(x>>(8*i)) & 0xFF] << (8*i) for i in range(8))
F = lambda x: SBP(x) ^ ror(x,51)
def half(A,c):
r = (((A<<64 | c) % P) * RC) % P
return (r & M) ^ c ^ ror(r>>64, 47)
def rnd(A,B,j,n,even): # even=True → cnt 偶数用 Ha
c = carr[j] ^ (Ha if ((n & 1)==0)==even else Hb)
A2 = F(half(A,c)) ^ B
B2 = F(half(A2, c^GOLD)) ^ A
return A2, B2
def inv(A2,B2,j,n,even):
c = carr[j] ^ (Ha if ((n & 1)==0)==even else Hb)
A = F(half(A2, c^GOLD)) ^ B2
return A, F(half(A,c)) ^ A2
T = {'v414': Q(tgt,0x50)^m84, 'v401': Q(tgt,0x58)^m85,
'v388': Q(tgt,0x40)^m84, 'v399': Q(tgt,0x48)^m85,
'fnv6': Q(tgt,0x30)^m85, 'fnv4': Q(tgt,0x60)^m84}
# ---- 逆向:链 2 (carr[20:40], cnt 奇数用 Ha) 倒推 t5 -------------------
A, B = T['v399'], T['v388']
for n in reversed(range(20)): A, B = inv(A, B, 20+n, n, False)
t5 = (A<<64) | B
# ---- GF(p) 上解五次方程 ------------------------------------------------
def pn(a):
while len(a)>1 and a[-1]==0: a.pop()
return a
def pmul(a,b):
r=[0]*(len(a)+len(b)-1)
for i,x in enumerate(a):
if x:
for j,y in enumerate(b): r[i+j]=(r[i+j]+x*y)%P
return pn(r)
def pmod(a,b):
a=a[:]; d=len(b)-1; iv=pow(b[-1],-1,P)
while len(a)-1>=d:
c=a[-1]*iv%P; s=len(a)-1-d
if c:
for i,y in enumerate(b): a[s+i]=(a[s+i]-c*y)%P
a.pop()
if not a: a=[0]
return pn(a)
def pdiv(a,b):
a=a[:]; d=len(b)-1; iv=pow(b[-1],-1,P); q=[0]*(len(a)-d)
while len(a)-1>=d:
c=a[-1]*iv%P; s=len(a)-1-d; q[s]=c
if c:
for i,y in enumerate(b): a[s+i]=(a[s+i]-c*y)%P
a.pop()
if not a: break
return pn(q)
def pgcd(a,b):
a,b=pn(a[:]),pn(b[:])
while b!=[0]: a,b=b,pmod(a,b)
iv=pow(a[-1],-1,P); return [x*iv%P for x in a]
def ppow(base,e,mod):
r=[1]; bb=pmod(base,mod)
while e:
if e&1: r=pmod(pmul(r,bb),mod)
bb=pmod(pmul(bb,bb),mod); e>>=1
return r
g = [(K[4]-t5)%P, K[3], K[2], K[1], K[0], 1]
h = ppow([0,1], P, g); h += [0]*(2-len(h)); h[1] = (h[1]-1)%P
d = pgcd(h, g)
roots=[]
def split(f):
if len(f)==2: roots.append((-f[0]*pow(f[1],-1,P))%P); return
while True:
t=ppow([random.randrange(P),1],(P-1)//2,f); t=t[:]; t[0]=(t[0]-1)%P
c=pgcd(t,f)
if 1<=len(c)-1<len(f)-1: split(c); split(pdiv(f,c)); return
if len(d)>1: split(d)
# ---- 验证并解码 --------------------------------------------------------
print('h1=%016X h2=%016X'%(h1,h2))
print('Ha=%016X Hb=%016X'%(Ha,Hb))
print('roots in GF(p):', len(roots))
for acc in roots:
ds=[]; v=acc
for _ in range(16): ds.append(v%94); v//=94
if v: continue
s=bytes(x+33 for x in reversed(ds))
a2=0
for c in s: a2=(a2*94+(c-33))%P
t=a2
for i in range(4): t=((t+K[i])%P*a2)%P
t=(t+K[4])%P
A,B=t>>64,t&M
for n in range(20): A,B=rnd(A,B,20+n,n,False)
c2=(A==T['v399'] and B==T['v388'])
A,B=t>>64,t&M
for n in range(20): A,B=rnd(A,B,n,n,True)
c1=(A==T['v401'] and B==T['v414'])
def fnv(ws):
a=FNVB
for w in ws:
for k in range(8): a=((a^((w>>(8*k))&0xFF))*FNVP)&M
return a
o=[T['v414'],T['v401'],T['v388'],T['v399']]
f6=fnv([a2&M,a2>>64]+o)==T['fnv6']; f4=fnv(o)==T['fnv4']
print('FLAG = %r'%s.decode())
print(' printable=%s sum=%d(need %d) chain1=%s chain2=%s fnv6=%s fnv4=%s'%(
all(33<=c<127 for c in s), sum(s), SUMTGT, c1, c2, f6, f4))
print(' ALL CHECKS PASS' if all((c1,c2,f6,f4,sum(s)==SUMTGT)) else ' *** FAILED ***')
9. 复盘
9.1 真正省时间的一步
不是啃 Hex-Rays,而是用 Unicorn 把 0x140004380 单独跑起来:把 8 个 IAT 项换成桩地址、伪造一个 TEB/PEB、GetThreadContext 返回清零的 CONTEXT,就能对任意输入取任意中间值。
它带来三个决定性的信息:
- 两个不同输入各跑一遍,立刻看出期望值不随输入变化 → 判定"求逆"而不是"爆破";
- Hex-Rays 把 128 位运算铺成两千行
__PAIR128__,几乎没法读;但把候选公式往模拟器 dump 出的中间值上一套,几秒就能证伪或证实; - Python 模型每改一次都能和模拟器逐字段对拍。
最终 t5 由正向链、模拟器、逆向推导三条独立路径给出同一个值,才敢下结论。
9.2 踩过的坑
| # | 坑 | 怎么发现的 |
|---|---|---|
| 1 | 轮常数数组下标算错。 链 2 的指针从 rbp+0x200 起,我按 rbp+0x160 取,偏了 20 个 qword |
单独 dump 一个半轮的 hi/lo 和模型逐字段比对 |
| 2 | 多项式最后一级没有乘法。 前四级是 (t+Kᵢ)·acc,第五级只是 t + K₄ |
按规律外推失败后,穷举候选形式 |
| 3 | 误以为每级都乘 t。 第一级确实是 (acc+K₀)·acc,我顺手假设后面同构 |
同上 —— dump 出每级中间值直接比对 |
| 4 | carr = ctx[45:85] 差一位,正确是 ctx[44:84] |
写独立求解脚本时暴露 |
| 5 | 两条 Feistel 链的奇偶判定相反(cmove vs cmovne) |
见下 |
9.3 关于第 5 个坑:一次不彻底的收尾
第一版分析里我只复现了链 2(v388/v399),链 1(v414/v401)用同样的模型对不上。当时的判断是:链 2 已经把 acc 唯一确定,且真机验证通过,所以不影响结论 —— 于是把它标成"已知未追"就收工了。
这个判断在求解上没错,但在分析上是个漏洞:一个自称完整的模型,不应该有一块跑不通还说不清为什么。
回头把两个循环头逐条对齐,差别只有一条指令:
; 链 1 @ 0x140005945
cmove rax, [rbp-30h] ; (cnt & 1) == 0 → 用 H_a
; 链 2 @ 0x140005EE3
cmovne rax, [rbp-30h] ; (cnt & 1) != 0 → 用 H_a
奇偶判定是反的。加一个 even 参数之后,链 1 的 20 次迭代逐次命中,(A, B) 正好等于 (v401, v414)。
教训:cmove / cmovne 这种一字之差,在几千行反编译里最容易滑过去。逐条对齐循环头,比反复读反编译快得多。
9.4 关于速度
最快的选手 10 分钟拿下,我慢得多。事后看,分水岭只有一句话:先确认期望值是不是常量。
确认之后剩下的都是机械劳动;确认之前,很容易一头扎进 Hex-Rays 那两千行 128 位展开里出不来。这题的壳(两层 SMC + 自校验 + 反调试)看起来是主菜,其实是佐料 —— 它们全都不影响确定性,静态解完就没了。真正的题眼是那句"输出是常量,所以这是个求逆问题"。
9.5 题目暗合
简介里"十六层加密,密钥分散在城市的十六个不同节点""十六层密钥必须按正确顺序叠加"—— 对应的正是 base94 Horner 的 16 位逐位累积:顺序错一位,acc 完全不同。"她尝试七次,前六次都失败"大致对应多项式的次数。
不过这些线索对求解没有实际帮助,答案完全由二进制本身给出并验证。
附 A:关键地址速查表
(地址为解密后的映像内地址,基址 0x140000000)
| 地址 | 作用 |
|---|---|
0x140001000 |
exit(code) 包装 —— 走 NtTerminateProcess |
0x140001070 |
干扰用 FNV 变体,返回值弃用;顺带 NtQuerySystemTime 搅噪声 |
0x140001220 |
ntdll 系统调用号解析(扫 0F 05 → 回扫 B8) |
0x1400012C0 |
入口 / main —— 第一层解密、I/O、长度检查 |
0x1400015DF |
call 校验入口 —— 下断点的最佳位置(在 .text$mn 内,不参与任何哈希) |
0x1400017B0 |
裸 syscall 桩(搬参数 + syscall) |
0x140001810 |
双 FNV 哈希(用于对 smca / smcb 求 h1,h2 / H_a,H_b) |
0x140001890 |
密钥流 fmix 辅助函数 |
0x1400018D0 |
第二层解密 + 调用真正校验 + 用完再加密回去 |
0x140001D10 |
校验分发(8 个相同指针的幌子表 + ^1 反向陷阱) |
0x140004380 |
真正的校验:字节和 → 反调试 → PRNG → 多项式 → Feistel |
0x140005933 |
Feistel 链 1 循环头(cmove,20 次迭代) |
0x140005ED0 |
Feistel 链 2 循环头(cmovne,20 次迭代) |
0x14000660E |
最终 6 项比较 |
0x140007000 |
IAT(8 项) |
0x140007100 |
8 个完全相同的函数指针(幌子表) |
0x14000A000 |
.tgt —— 解密参数 + 掩码目标 |
附 B:复现步骤
最省事的路(推荐)
# 与 exe 同目录,无需任何逆向工具
python standalone.py
# → FLAG = 'kanxue@2o26o8!@#' ALL CHECKS PASS (0.15s)
想自己看反汇编
- 用第 3、4 节的两段解密逻辑把
.text$smca、.text$smcb就地解开,回写成新的 PE; - 用 IDA / Ghidra 打开解密后的 PE,从
0x140004380开始读; - 若要动态验证,断点只下在
0x1400015DF(.text$mn内),不要在0x1800以后下软件断点; - 注意:挂调试器会污染全部常量(见 5 节),动态 dump 出来的 K/RC/S 盒不可用。
工具链:本文用到的是 objdump(节表 / 导入表)、capstone(初始反汇编)、IDA 9.3 无头模式(idat -A -c -S,批量反编译)、Unicorn(把 0x140004380 单独跑起来取中间值)、以及纯 Python 的 GF(p) 多项式求根。
附 C:产物清单
| 文件 | 内容 |
|---|---|
standalone.py |
第 8 节那份,只读 exe 的完整求解器 |
cm_dec.exe |
两层全解密后的 PE,可直接丢进 IDA / Ghidra |
emu.py |
Unicorn 环境:映像加载、IAT 桩、伪 TEB/PEB |
model.py |
Feistel 正向 / 逆向轮函数与常量提取 |
stages.py |
五级多项式的形式判定(穷举候选式) |
看雪 2026 CTF 战队赛 · kctf2026_CrackMe08
答案 kanxue@2o26o8!@# 已在原始二进制上验证输出 correct。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。