-
-
KCTF2026: 第八题:亥子合辰·塔影迷楼
-
发表于: 3天前 40
-
kctf2026_CrackMe08 题解
这道题可能我不该干涉太多,干涉多了反而 LLM 还更慢解出 flag。
附件附上2个 agent 的解题 wp
Flag:kanxue@2o26o8!@#
| 文件 | kctf2026_CrackMe08.exe |
| MD5 | 88c2ab23c7b77a560ce5bef6ecc4adc3 |
| 类型 | PE32+ 控制台程序,x86-64,27,136 字节,RELOCS_STRIPPED |
| 导入 | 8 个 KERNEL32 函数 |
| 节 | .text(熵 7.90 原始 / 7.95 按 VirtualSize)、.rdata、.data、.pdata、.tgt |
| 输入 | 恰好 16 个可打印字节,字节和为 1280 |
程序有两层自解密,每一层的密钥都取自它自己的代码;反调试的检测结果不拿去做分支,而是
直接混进密钥调度;而这一切之下,真正的核心是 GF(2^127 − 39) 上的一个五次多项式——用
教科书里的求根算法就能解开。
0. 侦察
代码节是加密的。 .text 按 0x5800 字节的原始大小算熵为 7.90(按 VirtualSize
截断则是 7.95)。只有开头 0x800 字节能正常反汇编,那一段熵为 6.01。
导入表就是一份自白——八个,一个不多:
GetStdHandle GetCurrentProcess FlushInstructionCache VirtualProtect
GetModuleHandleA GetProcAddress GetCurrentThread GetThreadContext
VirtualProtect + FlushInstructionCache = 自修改代码。GetThreadContext = 硬件断点
检测。全表没有任何 I/O 函数——程序靠直接系统调用跟内核打交道。
字符串在撒谎,没加密的那些才说真话
strings 能在 .rdata 里翻出三段用 0x5A 一异或就很漂亮的数据——它们是诱饵。把三个
代码块里所有 RIP 相对操作数扫一遍,对它们的引用是零:
.rdata:0x7050 13 34 2a 2f 2e 60 -> "Input:" <- 无人引用
.rdata:0x7058 39 35 28 28 3f 39 2e 50 -> "correct\n" <- 无人引用
.rdata:0x7060 3c 3b 2f 36 2e 50 -> "fault\n" <- 无人引用
真正打印出来的那几句,是在加密区里用立即数拼到栈上的,全程没有任何异或:
1400013ED mov dword [rbp-9], 0x75706E49 ; "Inpu"
1400013F4 mov word [rbp-5], 0x3A74 ; "t:"
1400015F4 mov dword [rbp-9], 0x72726F63 / 0x0A746365 ; "correct\n"
140001541 mov dword [rbp-9], 0x6C756166 / 0x0A74 ; "fault\n"
真正有用的字符串反倒是没人费心去藏的那批——0x140007068–0x1400070F8 明文躺着:ntdll.dll、NtWriteFile、NtReadFile、NtTerminateProcess、NtQuerySystemTime、NtQueryInformationProcess。最后那个名字,等于在你解密一个字节之前就告诉你里面有调试
端口检查。
.pdata 也没加密:0x1BC 字节 = 37 条 RUNTIME_FUNCTION,等于白送一份完整的函数表——
而这些函数对应的代码,你这会儿还读不了(0x140004380、0x140003580、0x140002C60
都直接摆在里面)。这张表后面还能反过来当作解密正确性的判据:解密对了的话,表里每个函数
起始地址都应该落在一个合法的 prologue 上。
.tgt 节
第五个非标准节:RVA 0xA000 处 0x70 字节,可写。
| RVA | 值 | 含义 |
|---|---|---|
0xA000 |
0x4962 |
.text$smcb 大小 |
0xA008 |
0x1D10 |
.text$smcb 的 RVA |
0xA010 |
0x500 |
输入所需的字节和 |
0xA018 |
0 |
密钥调度的盐 |
0xA020 |
0x510 |
.text$smca 大小 |
0xA028 |
0x1800 |
.text$smca 的 RVA |
0xA030–0xA06F |
8 个 qword | 其中 6 个是加掩码的比对目标(+0x30、+0x40、+0x48、+0x50、+0x58、+0x60);+0x38 和 +0x68 没有任何指令读取——但 +0x38 不是填充,见 §3.3 |
链接器自己的节贡献表也留在文件里,就是 IMAGE_DEBUG_TYPE_POGO 的负载(调试目录 RVA0x7180;负载在 RVA 0x7204 / 文件偏移 0x5E04,0x134 字节;一个 u32 签名,后面跟着{u32 rva, u32 size, 以 NUL 结尾并按 4 字节对齐的名字} 记录)。共 16 条,其中三条把.text 切成:
| 名字 | RVA | 大小 | 状态 |
|---|---|---|---|
.text$mn |
0x1000 |
0x0800 |
明文 — 入口桩 + 系统调用门 |
.text$smca |
0x1800 |
0x0510 |
第一层加密 |
.text$smcb |
0x1D10 |
0x4962 |
第二层加密 |
三块首尾相接,可以自洽验算:0x1800 + 0x510 = 0x1D10,0x1D10 + 0x4962 = 0x6672 = 0x1000 + VirtualSize。
1. 第一层:入口桩
0x1400012C0:
GetModuleHandleA(NULL)-> 映像基址。- 从
.tgt+0x20/.tgt+0x28读.text$smca的大小与 RVA;若size-1 > 0xFFFF
则直接跳到提示符(0x1400012FB)。 VirtualProtect(base+0x1800, 0x510, PAGE_EXECUTE_READWRITE, &old);返回 0 也直接跳到
提示符(0x14000131D),此时.text$smca仍是密文(若随后输入恰好 16 字节,0x1400018D0就会被当成代码执行)。- 整体异或
0x5A:主循环用 SSE 每轮处理 0x40 字节,掩码取自.rdata:0x1400070D0那 16 个0x5A,尾部再逐字节收边。 GetCurrentProcess()->FlushInstructionCache,恢复原页保护。
然后:打印 Input:,最多读 0x3F 字节(0x1400014BB),剥掉所有结尾的 \r / \n
(0x140001510–0x14000152F),要求剩余长度恰好为 16(0x140001531: cmp ecx, 0x10)。
不用导入,直接系统调用
系统调用号是运行时从 ntdll 现挖的,解析器在 0x140001220:
GetModuleHandleA("ntdll.dll")
GetProcAddress(hNtdll, "NtReadFile")
; 在桩函数 0x00..0x3F 偏移范围内正向找 "0F 05"(syscall)这对字节,
; 再往回最多走 24 字节找 "B8"(mov eax, imm32),返回那个 imm32;
; 扫完 0x00..0x3F 仍未凑齐这两者才返回 -1
调用由 0x1400017B0 的私有门发出:rcx 装系统调用号(在 0x1400017F0 搬进 eax),rdx 挪进 r10、成为系统调用的第 1 个参数,栈上第 6–10 个参数整体下移一个槽位——所以这个门
最多支持十个参数,最后才是一条裸 syscall。
走这个解析器的只有四个:NtWriteFile、NtReadFile、NtQuerySystemTime、NtTerminateProcess。第五个 NtQueryInformationProcess 是由同一段循环的内联副本在0x140004566(第二层校验器内部)解析的。此外 0x140004070 还躺着第三份字节完全相同的独立
副本,从没被调用过。
每个 NtTerminateProcess 后面都跟着一条 jmp $ 死循环(0x1400015D0、0x140001680、0x140001713、0x1400017A6):hook 或破坏这个系统调用,进程直接挂死。
两处伪装成检查的死存储
0x1400015D6 调用 0x140001070,它把 16 个输入字节按h ^= b; h = ror64(h * 0x100000001B3, 57) 折叠——初值不是 FNV 的 basis,而是0x243F6A8885A308D3(π 的小数部分)——一共 17 轮(最后一个字节之后还多做一次「乘法 + 轮转」),
再跟 0x1111111111111111 比较。命中就对 0x140008000 处的全局量做读-改-写(g = 3g + 1)。
第二个诱饵 0x1400011AF–0x140001214 解析 NtQuerySystemTime,把时间异或进同一个全局量。
把三个代码块里的 RIP 相对偏移再扫一遍,指向 0x140008000 的引用正好四处——就是这四处——没有任何消费者。
两处都是写了没人读的死存储。
2. 第二层:代码本身就是密钥
0x1400018D0 解开负载,密钥是从一级代码的字节算出来的:
h1 = fnv1a(smca_plain, basis = 0xCBF29CE484222325) # prime 0x100000001B3
h2 = fnv1a(smca_plain, basis = 0x9E3779B97F4A7C15)
for i in range(0x4962):
t = ((i * 0x9E3779B97F4A7C15) & M64) ^ h2 ^ h1 # M64 = 2**64 - 1
t ^= t >> 33
t = (t * 0xFF51AFD7ED558CCD) & M64
smcb[i] ^= (t >> 56) & 0xFF
第一个乘法上的截断是要命的:二进制里那条 imul r8, rax 是 64 位截断乘,而 Python 里不加
掩码的 i * GOLD 会涨到 2^77,紧接着的 >> 33 会把多出来的高位又折回结果里。去掉掩码,
18786 个密钥流字节里会错 18705 个。
程序里还带了一条 AVX vpmullq 路径,算同样的东西、每轮四字节——但它的门是dword [0x140008020] >= 6,而这个标志从未被初始化:文件里它是 00 00 00 00,整个映像对它
只有一处引用(就是这条读,没有写),而且 PE 入口点就是那个桩,根本没有 CRT 去设置类似__isa_available 的值。所以永远走标量循环。
[.text$mn] --异或 0x5A--> [.text$smca] --密钥流--> [.text$smcb] --播种--> [密钥调度]
| |
FNV1a x2 ---^ FNV1a x2 ---^
^-- 不被任何哈希覆盖
每一层解密后的字节都是下一层的密钥。改 .text$smca 一个字节,18 KB 解成噪声;改.text$smcb 一个字节,则是悄悄换掉整套密码算法。注意没被覆盖的地方:.text$mn 自己,
0x1000–0x17FF,不被任何哈希覆盖。
校验器返回后,负载会用同一套密钥流重新加密回去,页保护一并恢复——事后 dump 拿到的还是密文。
分发表
idx = ((input[0] * 0x9E3779B97F4A7C15) ^ (input[15] << 33)) & 7
call qword ptr [table + idx*8] ; 表在 0x140007100
八个表项全是 0x140004380——.rdata 从不加密,所以这一点直接翻到文件偏移 0x5D00 就能看见。
而且这个式子比看上去还空:input[15] << 33 对第 3 位以下毫无贡献,黄金分割常数 0x9E3779B97F4A7C15 ≡ 5 (mod 8),
于是整个表达式塌缩成 (input[0] * 5) & 7。
3. 第三层:校验逻辑
3.1 校验和:在校验器里是真卡点,在外层函数里是陷阱
真正的卡点在 0x140004380:punpcklbw / punpcklwd 把 16 字节扩成 dword,横向相加,
与 0xFFFFFF 相与,再跟 .tgt+0x10 比(1400043FC: cmp eax, [rip+0x5c0e]),不等立刻返回 0:
sum(input) == 0x500 ; 1280。那个 & 0xFFFFFF 只是装饰——
; 16 个字节的和不可能超过 0xFF0。
0x140001D10 根本不是关卡。它先调用校验器,然后才重算同一个和,而且故意跟一个错的值比:
140001d50 call rax ; -> 0x140004380
140001d5f mov r8d, eax ; 校验器的结论
140001d62 mov ecx, [rip+0x82a8] ; *(.tgt+0x10) = 0x500
140001d68 xor eax, eax
140001d6e xor ecx, 1 ; 0x501
140001dc0 and edx, 0xffffff ; edx = 字节和
140001dc6 cmp edx, ecx
140001dc8 cmovne eax, r8d ; 和 != 0x501 -> 放行校验器的结论
140001dcc and eax, 1
外层返回的是 (sum != 0x501) ? checker(input) : 0。这条 cmovne 才是把结果传出去的那条
路——对任何一个真正的候选输入它都会触发,相等的那一支永远走不到。你要是顺手改成 cmove(看到一个
比较就想反过来的本能操作),恰好把本来能通过的输入全部压成 0。
3.2 反调试的结果变成密钥材料
0x140004380 把检测结果累加进 rbx。没有 0x10 这一位——累加点只有 0x1400044CA、0x1400044D4、0x140004529、0x140004671、0x1400046E9:
| 位 | 检测手段 |
|---|---|
0x01 |
gs:[0x60]+2 处的 PEB->BeingDebugged |
0x02 |
PEB->NtGlobalFlag & 0x70 == 0x70(堆调试标志) |
0x04 |
GetThreadContext(CONTEXT_DEBUG_REGISTERS):Dr0–Dr3 按 qword 比,但 Dr7 只按字节比(0x140004520),低字节为 0 的 Dr7 看不见;Dr6 完全不查;调用失败则整块跳过 |
0x08 |
NtQueryInformationProcess 类别 7 / 0x1E——这项检测是废的:两处调用都传 ProcessInformationLength = 4(0x14000460D、0x140004653),而 x64 上这两个值都是指针大小,于是都会返回 STATUS_INFO_LENGTH_MISMATCH,or rbx, 8 根本走不到 |
0x20 |
把 .text$smcb 完整扫一遍,前后两次 rdtsc 的差值超过 0x2FAF080 |
rbx 自始至终没有被拿去做过任何判断——全程没有一条 test / cmp / jcc 碰过它。它直接进了 PRNG 种子:
mov r9, [rip+0x5924] ; .tgt+0x18 = 盐 = 0
imul r10, r14 ; r10 = 盐 * 0x9E3779B97F4A7C15
shl r9, 0x21 ; r9 = 盐 << 33
xor r10, r12 ; r12 = fnv1a(smcb_plain, 0xCBF29CE4...)
xor r9, r13 ; r13 = fnv1a(smcb_plain, 0x9E3779B9...)
xor r10, rbx ; <-- 反调试位在这里污染种子
xor r9, r15 ; r15 = 0xCBF29CE484222325(在 0x140004466 装入)
调试器换不来报错,换来的是一套不同的密码算法。而且这两个 FNV 哈希会被原样复用成轮密钥,
等于顺手把负载自己也校验了一遍。
3.3 密钥调度
两条交错、互相反馈的 splitmix64 流,43 轮吐出 86 个 qword:
def splitmix_final(z):
z ^= z >> 30 ; z = (z * 0xBF58476D1CE4E5B9) & M64
z ^= z >> 27 ; z = (z * 0x94D049BB133111EB) & M64
return z ^ (z >> 31)
for _ in range(43):
s1 += 0x9E3779B97F4A7C15
s2 += 0xBF58476D1CE4E5B9
o1 = splitmix_final(s1 + 0x9E3779B97F4A7C15)
o2 = splitmix_final(s2 + 0x9E3779B97F4A7C15)
s2 ^= o1 ; s1 ^= o2 # 交叉反馈
emit o1, o2
| qword | 用途 |
|---|---|
| 0–9 | 五个 128 位常量 A0..A4,模 p 约简 |
| 10–11 | 128 位乘子 C,同样模 p 约简 |
| 12–43 | 256 字节 S 盒 |
| 44–83 | 40 个轮常量,RC[i] = PRNG[44+i],没有任何置换 |
| 84–85 | 用来揭开比对目标的掩码 |
A0..A4 和 C 的处理是一样的:约简后如果得到 0,就替换成 1(这个二进制里两处修正都不会触发)。S 盒是每对 qword
的逐字节交错:
S[16j + 2k] = (PRNG[12 + 2j] >> 8k) & 0xFF
S[16j + 2k + 1] = (PRNG[13 + 2j] >> 8k) & 0xFF # j = 0..15, k = 0..7
它不是置换:256 个值里只有 166 个互不相同。这没关系,因为轮函数从不需要把它反过来。
文件里存着的那几个比对目标,都被最后两个 qword 异或掩盖过,所以看着像噪声:
| 槽位 | 还原方式 | 值 |
|---|---|---|
| 链 1 · T | .tgt+0x50 ^ PRNG[84] |
0xB8FD0694401D3D03 |
| 链 1 · H | .tgt+0x58 ^ PRNG[85] |
0x0C14DB1C20DD97A8 |
| 链 2 · T | .tgt+0x40 ^ PRNG[84] |
0xACA5240A53FB78A6 |
| 链 2 · H | .tgt+0x48 ^ PRNG[85] |
0x9A2789B6DE31C2DC |
| 6 qword FNV | .tgt+0x30 ^ PRNG[85] |
0x6CD601B3049AA32C |
| 4 qword FNV | .tgt+0x60 ^ PRNG[84] |
0xBC1E5EBE1E4EC0D4 |
目标表里的一处泄漏。 没人读的槽位 .tgt+0x38 里放着 0x62D1EE2F69239D2E——正是PRNG[85],一位不差。它是一个明文恰好为 0 的“加掩码目标”,于是掩码本身就明晃晃地躺在
文件里。六个目标里有三个,不用模拟、不用密钥调度就能直接异或出来:
.tgt+0x58 ^ .tgt+0x38 = 0x0C14DB1C20DD97A8 ; 链 1 . H
.tgt+0x48 ^ .tgt+0x38 = 0x9A2789B6DE31C2DC ; 链 2 . H
.tgt+0x30 ^ .tgt+0x38 = 0x6CD601B3049AA32C ; 6 qword FNV
这同时证明打包时跑的是干净的(rbx = 0)密钥调度。PRNG[84] 没有泄漏,.tgt+0x68
(0x6666666666666666)确实只是填充。
3.4 运算域:GF(2^127 − 39)
凭两个特征,可以直接从汇编里把模数读出来:
- 约简时高位字跟
0x7FFFFFFFFFFFFFFF比(0x1400047B5: movabs rdi, ...),低位字跟-0x27(即0xFFFFFFFFFFFFFFD9)比——模数为0x7FFFFFFFFFFFFFFF_FFFFFFFFFFFFFFD9; - 256→128 位折叠时把溢出部分乘以
0x4E= 78 = 2·39,因为 2^128 ≡ 2·39 (mod p)。
整个二进制里没有任何通用取模。每一处都是固定的 4 次条件减法(mov edx,4 / 比较 / 减 /sub rdx,1; jne)——负载里辨识度最高的惯用法,也是为什么 Python 里直接写 % 就能对上:
对 128 位输入,四次减法永远够用。想把 p 读得最干净,去看 0x140001EF0 那个未内联的辅助函数。
3.5 先 base-94,再一个五次多项式
acc = 0
for c in input:
acc = acc * 0x5E + (c - 0x21) # 0x5E = 94, 0x21 = '!'
正好是可打印 ASCII 区间 !(33)…~(126);这个映射是到 [0, 94^16) 的双射,而
94^16 ≈ 2^104.87 < p,所以 acc 不丢任何信息。
后面还有四步乘加,每步重新装载的操作数是 acc 本身(来自 [rbp+0x7D0] / [rbp+0x7D8]),
不是滚动的中间值:
y1 = acc * (acc + A0)
y2 = acc * (y1 + A1)
y3 = acc * (y2 + A2)
y4 = acc * (y3 + A3)
y = y4 + A4
全局的关键点: 展开就是一个普通的五次多项式——
y = acc^5 + A0·acc^4 + A1·acc^3 + A2·acc^2 + A3·acc + A4 (mod p)
——有限域上的五次多项式可以直接求根。要是这个循环写成把滚动值平方(y <- y·(y + A)),
反过来就得做一串模平方根,每步分叉因子为 2。看错这个操作数,是唯一能让你解不出题的错误。
最后 H0 = y >> 64,T0 = y & M64。
3.6 两条各 20 轮的 Feistel 链
(H0, T0) 被喂进两条独立的 20 轮链,都从同一个起点出发——一条走 RC[0..19],另一条走RC[20..39]——而且两条挑轮密钥用的是相反的奇偶(循环 1 在 0x140005945 用 cmove,
循环 2 在 0x140005EE3 用 cmovne)。其中
K0 = fnv1a(smcb_plain, 0x9E3779B97F4A7C15) = 0x4AEEE52B5738EB8F ; [rbp-0x38]
K1 = fnv1a(smcb_plain, 0xCBF29CE484222325) = 0x553D3C5EF6EE11FF ; [rbp-0x30]
轮密钥是
a = (K1 if i % 2 == 0 else K0) ^ RC[i] # 链 1, i = 0..19
a = (K0 if i % 2 == 0 else K1) ^ RC[20 + i] # 链 2, i = 0..19
注意这里的命名跟 §3.2 那段种子代码给人的直觉正好相反:K0 是以 0x9E3779B9 为初值的那个
哈希。配错了的话,链 1 照样能反推出一个看起来挺像样的 (H0, T0)——只有 §4 的交叉验证能戳穿它。
单轮:
Y = ((((H << 64) | a) % p) * C) % p # 128x128 模 p 乘
w = ror64(Y >> 64, 47) ^ a ^ (Y & M64)
H_ = ror64(w, 51) ^ SBOX64(w) ^ T # SBOX64 = 逐字节过 S 盒
a2 = a ^ 0x9E3779B97F4A7C15
Y2 = ((((H_ << 64) | a2) % p) * C) % p
v = ror64(Y2 >> 64, 47) ^ a2 ^ (Y2 & M64)
T_ = H ^ ror64(v, 51) ^ SBOX64(v)
双分支 Feistel:H_ 是 (H, T) 的函数,T_ 是 (H, H_) 的函数,所以只要有轮密钥,就能
先从 (H_, T_) 恢复 H,再恢复 T。严格可逆。
3.7 最终比对
六个相等条件,由 0x14000665D 处的 and eax, esi 汇总。两个摘要都是按 qword 做的
FNV-1a,初值 0xCBF29CE484222325,乘子 0x100000001B3:
14000660E chain1_T == target 140006626 chain1_H == target
14000662E chain2_T == target 14000663C chain2_H == target
140006644 FNV1a(acc_lo, acc_hi, chain1_T, chain1_H, chain2_T, chain2_H) == target
140006655 FNV1a(chain1_T, chain1_H, chain2_T, chain2_H) == target
真正起约束作用的只有前四条:它们已经把 (H0, T0) 钉死,后两个摘要随之确定;solve.py
会把它们算出来,但从头到尾不需要用。另外,输入本身从来没有跟任何东西比较过,被比的只是
从它派生的一个 384 位摘要。
4. 反向求解
让纯静态求解成立的,不是“校验器是确定性的”,而是它里面除了输入本身,没有任何东西依赖
输入。密钥调度、A0..A4、C、S 盒、轮常量、六个目标,全都是 PE 自身字节的函数。进入
校验器之前唯一依赖输入的控制流是那个八路分发,而八个槽位一模一样。
第一步——把 20 轮倒着走两遍。 两条链必须落到同一个 (H0, T0),这是一次免费的正确性
校验,因为它们的出发点毫不相干:
chain1^-1 -> (0x4BE831B0AD3A2D36, 0x1489375BBA3FB8DE)
chain2^-1 -> (0x4BE831B0AD3A2D36, 0x1489375BBA3FB8DE) 一致
y = 0x4BE831B0AD3A2D36_1489375BBA3FB8DE
第二步——在 GF(p) 上求根。 求 f(x) = x^5 + A0x^4 + A1x^3 + A2x^2 + A3x + (A4 - y)
的根。g = gcd(x^p - x mod f, f) 把一次因子分离出来;这里 deg g == 1,根就直接出来了,roots() 里那套为一般情形准备的 Cantor–Zassenhaus 压根没走到——这也是为什么求解器虽然调用了random.randrange 却是确定性的:它一次随机数都没消耗。算 x^p mod f 只是对一个 4 次多项式
做 127 次平方。
根恰好只有一个:
acc = 0x174A5E1D4CF4F146E72F70092AC
第三步——base-94 解码。 余数为 0,16 位全部落在 [33,126]:
kanxue@2o26o8!@#
字节和 = 1280 = 0x500,这也是那串里写的是 2o26o8、用字母 o 而不是数字 0 的原因
——按直觉那种拼法凑不出作者必须命中的这个和。不过对求解来说这个校验和并不起作用:五次
多项式只有一个根,那道过滤条件从来没筛掉过任何东西。
5. 验证
用 Unicorn 做整程序模拟,从真正的入口点起跑,让程序自己完成两层自解密、完整性哈希、反调试
探测,以及打到一个假的 ntdll 上的直接系统调用。映像本身未被改动;外面这层驱动脚本只是给它一个没有调试器的运行环境,并把四处 rdtsc
钉住,让计时探测看到的差值为 0:
$ cd work && python3 fullemu.py
b'kanxue@2o26o8!@#' => stdout: b'Input:correct\n' exit 0x0
b'AAAAAAAAAAAAAAAA' => stdout: b'Input:fault\n' exit 0x1
另一条路是一个独立的静态求解器:完全不依赖模拟,直接从 PE 里把每一个常量重新推导出来。
它只打开一个文件,耗时 0.075 秒,在任何目录下都能跑:
$ python3 solve.py kctf2026_CrackMe08.exe
acc = 0x174a5e1d4cf4f146e72f70092ac
FLAG = kanxue@2o26o8!@#
闭环也合上了:把求出的 flag 正向喂回模拟器,在 0x140005933 处 dump 出的y = 0x4BE831B0AD3A2D36_1489375BBA3FB8DE,正是两条链反推得到的那个值。
6. 复盘
当作一份设计来读,这个 crackme 对一件事非常克制:绝不告诉攻击者他被抓到了。最漂亮的地方
不是其中任何单一机制,而是它们之间的联锁:
- 软件断点只要落在
0x140001D10–0x140006672之间,改动的字节就会被0x140004490
处的自哈希混进K0/K1。 - 硬件断点不改任何字节,于是由
0x1400044F2的GetThreadContext抓到——并在0x140004720处混进同一个种子。 - 负载在返回前重新加密,事后 dump 拿到的是密文。
- 输入从来不跟任何东西比较,被比的只有一个 384 位摘要。
三道裂缝
没被哈希的那段桩代码。 .text$mn(0x1000–0x17FF)不被任何完整性检查覆盖:第一个
哈希覆盖 smca,第二个覆盖 smcb,没有任何一个覆盖这段桩。在 0x1400015DF 或系统调用门里
下 int3 是免费的。
负载有一半是一份可读的算法说明书。 18.8 KB 里约 9.8 KB——约 25 个函数,跨度0x140001DE0–0x14000405D——是编译器把各原语内联进 0x140004380 之前的未内联参考实现。
它们全是死代码(进入负载的唯一入口是 0x140001D10),却是这套算法最清晰的形态:
| 地址 | 未被调用的参考实现 |
|---|---|
0x140001EF0 |
模 p 的 4 次条件减法 |
0x140002110 |
0x4E 折叠 |
0x140002240 |
完整的 128×128 模 p 乘 |
0x1400024D0 |
双路 FNV-1a |
0x140002550 / 0x1400025A0 |
splitmix64 收尾函数,以及完整的 43×2 PRNG |
0x140002990 / 0x140002C60 |
单个 Feistel 半轮,以及把奇偶作为参数的完整 20 轮链 |
0x140003270 / 0x140003580 |
base-94 Horner,以及五次多项式求值器 |
0x140004110 |
反调试块的一个独立孪生副本 |
数学。 这一层层外壳护住的,其实就是“一个可逆置换 ∘ 一个低次多项式”。Feistel 天生
可逆;127 位素域上的五次多项式是已被解决的问题。平心而论,这笔账得这么算:这些反篡改
措施没让这次攻击付出任何代价,因为求解器从头到尾就没有执行过这个二进制。这层壳惩罚的是
动态调试的人,而校验器本身是完全静态的。
反篡改是有效的——在这里打补丁确实不现实。但正因为把打补丁这条路封得这么彻底,它反而把攻击
推向了它唯一防不住的那条路:搞懂这段代码到底在算什么。
文件
以下路径均相对于 work/ 目录。除 solve.py 外,其余脚本都需在该目录下运行。
| 文件 | 用途 |
|---|---|
solve.py |
独立静态求解器——输入 PE,输出 flag,不依赖其他文件,任意目录可运行 |
fullemu.py |
整程序模拟器(假 ntdll + 系统调用),端到端证明 |
emu.py |
直接调用校验器的 Unicorn 驱动脚本——每个输入请新建一个 Emu() |
model.py |
密码算法的 Python 复现,已与模拟器对拍验证 |
roots.py |
GF(2^127 − 39) 上的多项式求根 |
mem_raw.bin |
按映像基址平铺加载的 PE ——不是调试器 dump |
mem.bin |
同上,.text$smca 已解密 |
mem2.bin / smcb.bin |
同上,.text$smcb 也已解密 |
consts.json |
以 flag 作为输入时,在 0x140005933 处 dump 出的各个常量 |
smcb.asm、smca.asm、mn.asm |
三个代码块的反汇编 |
WRITEUP.en.md |
英文版 |
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。