首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[分享]kctf2026_CrackMe08 题解
发表于: 2026-8-21 14:30
38
[分享]kctf2026_CrackMe08 题解
correy
4
2026-8-21 14:30
38
# 看雪 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` 读: ```asm 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` 这个小函数: 1. `GetModuleHandleA("ntdll.dll")` → `GetProcAddress(name)` 2. 在导出桩的前 `0x40` 字节里扫 `0F 05`(`syscall`) 3. 找到后往回扫 `B8`(`mov eax, imm32`),把立即数取出来当调用号 然后 `0x1400017B0` 的裸桩负责把 Win64 调用约定的参数搬成系统调用约定,再 `syscall`: ```asm mov r10, rdx mov rdx, r8 mov r8, r9 mov r9, [rsp+28h] ; ... 把 [rsp+30h..] 依次前移 8 字节 ... mov eax, ecx ; ecx = 系统调用号 syscall retn ``` ### 3.2 主流程 1. 解密 `.text$smca` 2. `NtWriteFile` 打印 `Input:` 3. `NtReadFile` 读最多 `0x3F` 字节,剥掉尾部 `\r` / `\n` 4. **长度必须恰好 16**,否则打印 `fault` 5. 调 `0x140001070` 6. 调 `0x1400018D0` —— 刚解密出来的真正校验入口 7. 返回 1 → `correct`,否则 `fault` > ⚠️ **陷阱:`0x140001070`** > 它把输入做成一个 FNV 变体(初值 `0x243F6A8885A308D3`,乘 `0x100000001B3`,每轮 `ror 57`)去和 `0x1111111111111111` 比。 > **它的返回值从来没被使用过**;比中了也只是改一个后面用不到的全局量,顺带用 `NtQuerySystemTime` 往 `.data` 里搅点噪声。 > 纯干扰。这是我判断"哪些代码不用管"时省下最多时间的一处。 --- ## 4. 第二层:自校验密钥流 `0x1400018D0` 负责解开第二层,而**密钥来自第一层解密后的字节**: ```python # 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 又一个幌子 ```asm ; 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` 先卡字节和: ```asm 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` 那一位: ```asm 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 哈希"混在一起当种子: ```python 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 结构体里**当便签本: ```python 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 累加 ```python acc = 0 for c in input: # 16 次 acc = (acc * 94 + (c - 33)) % p ``` 94¹⁶ ≈ 2¹⁰⁴·⁹ **< p**,所以这一步**根本没发生约减**,`acc` 就是那个 94 进制整数本身。 这保证了 `acc → 明文` 是无歧义的双射 —— 也是最后能直接解码的原因。 ### 6.2 五次 Horner 多项式 ```python 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 次迭代 —— 而且这样的链**跑了两条**。 ```python 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 ```python 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) 上解五次方程 ```python 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 秒**。 ```python """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,就能对任意输入取任意中间值。 它带来三个决定性的信息: 1. 两个不同输入各跑一遍,立刻看出**期望值不随输入变化** → 判定"求逆"而不是"爆破"; 2. Hex-Rays 把 128 位运算铺成两千行 `__PAIR128__`,几乎没法读;但把候选公式往模拟器 dump 出的中间值上一套,几秒就能证伪或证实; 3. 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` 唯一确定,且真机验证通过,所以不影响结论** —— 于是把它标成"已知未追"就收工了。 这个判断在**求解**上没错,但在**分析**上是个漏洞:一个自称完整的模型,不应该有一块跑不通还说不清为什么。 回头把两个循环头逐条对齐,差别只有一条指令: ```asm ; 链 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:复现步骤 **最省事的路(推荐)** ```bash # 与 exe 同目录,无需任何逆向工具 python standalone.py # → FLAG = 'kanxue@2o26o8!@#' ALL CHECKS PASS (0.15s) ``` **想自己看反汇编** 1. 用第 3、4 节的两段解密逻辑把 `.text$smca`、`.text$smcb` 就地解开,回写成新的 PE; 2. 用 IDA / Ghidra 打开解密后的 PE,从 `0x140004380` 开始读; 3. 若要动态验证,断点只下在 `0x1400015DF`(`.text$mn` 内),**不要**在 `0x1800` 以后下软件断点; 4. 注意:挂调试器会污染全部常量(见 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`。*
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
correy
4
69
发帖
150
回帖
415
RANK
关注
私信
他的文章
[分享]2026 KCTF 第十题「卯时·曦光初现」(Writeup · Pwn)
1607
[分享]第九题:丑寅同墟·星海抉择
38
[分享]kctf2026_CrackMe08 题解
38
[分享]KCTF 2026 · cm.exe 逆向分析
34
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
21
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部