首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
KCTF2026: 第八题:亥子合辰·塔影迷楼
发表于: 2026-8-21 18:41
56
KCTF2026: 第八题:亥子合辰·塔影迷楼
Magic丶
2026-8-21 18:41
56
# 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` 的负载(调试目录 RVA `0x7180`;负载在 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`: 1. `GetModuleHandleA(NULL)` -> 映像基址。 2. 从 `.tgt+0x20` / `.tgt+0x28` 读 `.text$smca` 的大小与 RVA;若 `size-1 > 0xFFFF` 则直接跳到提示符(`0x1400012FB`)。 3. `VirtualProtect(base+0x1800, 0x510, PAGE_EXECUTE_READWRITE, &old)`;返回 0 也直接跳到 提示符(`0x14000131D`),此时 `.text$smca` 仍是密文(若随后输入恰好 16 字节, `0x1400018D0` 就会被当成代码执行)。 4. 整体异或 `0x5A`:主循环用 SSE 每轮处理 0x40 字节,掩码取自 `.rdata:0x1400070D0` 那 16 个 `0x5A`,尾部再逐字节收边。 5. `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` 解开负载,密钥是从一级代码的字节算出来的: ```python 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 种子: ```asm 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: ```python 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 的交叉验证能戳穿它。 单轮: ```python 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` | 英文版 |
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
上传的附件:
kctf2026_CrackMe08_writeup.zip
(171.88kb,3次下载)
CrackMe08_writeup.zip
(270.07kb,3次下载)
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Magic丶
10
发帖
5
回帖
80
RANK
关注
私信
他的文章
KCTF2026: 第十题:卯时·曦光初现
2677
KCTF2026: 第九题:丑寅同墟·星海抉择
41
KCTF2026: 第八题:亥子合辰·塔影迷楼
56
KCTF2026: 第七题:戌时·暗能潜流
653
KCTF2026: 第六题:酉时·书院迷局
1641
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
谁下载
×
huangyalei
pzhxbz
git_61898amqsy
谁下载
×
huangyalei
pzhxbz
git_61898amqsy
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部