首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[原创]亥子合辰·塔影迷楼 Writeup
发表于: 2026-8-21 23:29
52
[原创]亥子合辰·塔影迷楼 Writeup
阿捏利
1
2026-8-21 23:29
52
# 亥子合辰·塔影迷楼 Writeup > 目标:`kctf2026_CrackMe08.exe`(x64 PE,27136 字节,无 CRT) > > **答案(Serial):`kanxue@2o26o8!@#`** > > 真机验证:`Input:correct`,退出码 0。 --- ## 目录 1. [结论速览](#1-结论速览) 2. [静态结构侦察](#2-静态结构侦察) 3. [两层自解密](#3-两层自解密) 4. [反调试与常量生成链](#4-反调试与常量生成链) 5. [校验算法完整还原](#5-校验算法完整还原) 6. [Unicorn 仿真取常量](#6-unicorn-仿真取常量) 7. [求逆:双 Feistel 逆推 + Cantor–Zassenhaus 求根](#7-求逆双-feistel-逆推--cantorzassenhaus-求根) 8. [常量表(实测运行时值)](#8-常量表实测运行时值) 9. [完整复现脚本](#9-完整复现脚本) 10. [踩坑记录](#10-踩坑记录) --- ## 1. 结论速览 程序对输入做四层约束,全部满足才输出 `correct`: | # | 约束 | 说明 | | - | ----------------------- | ------------------------------------------------------------------------------------------------- | | 1 | `len(input) == 16` | 去掉尾部`\r\n` 后长度必须为 16 | | 2 | `sum(bytes) == 0x500` | 字节和恰好 1280;`sub_140004380` 判 `== 0x500`,`sub_140001D10` 另判 `!= 0x501`,两处夹住 | | 3 | 128 位目标 A、B | 输入 → base-94 → 五次多项式 → 两条 20 轮 Feistel,输出两个 128 位值需等于`.tgt` 中的目标 | | 4 | 两个 64 位 FNV 摘要 | 对上述结果再做两条 FNV-1a,需等于目标 H1/H2 | `kanxue@2o26o8!@#` 的字节和 = 1280,长度 = 16,四项全中。 整个链条**唯一解**:五次多项式在 GF(2^127−39) 上只有 1 个根落在 16 位 base-94 可表示范围内。 --- ## 2. 静态结构侦察 ### 2.1 段表 | 段 | VA | VSize | RawSize@Offset | | ------------------ | ---------------- | ---------------- | ------------------------ | | `.text` | 0x1000 | 0x5672 | 0x5800 @ 0x400 | | `.idata` | 0x7000 | — | (并入 .rdata 视图) | | `.rdata` | 0x7000 | 0x06E2 | 0x800 @ 0x5C00 | | `.data` | 0x8000 | 0x0024 | 0x200 @ 0x6400 | | `.pdata` | 0x9000 | 0x01BC | 0x200 @ 0x6600 | | **`.tgt`** | **0xA000** | **0x0070** | **0x200 @ 0x6800** | 自定义段 `.tgt` 是全部配置与目标值的存放地,是逆向的第一个抓手。 ### 2.2 关键函数 | 地址 | 作用 | | -------------------------------------------- | ------------------------------------------------ | | `0x1400012C0` | `start`(自定义入口,无 CRT) | | `0x140001000` | 退出 stub(走`NtTerminateProcess` 直系统调用) | | `0x140001070` | 诱饵 FNV(看着像核心,实际不参与判定) | | `0x140001220` | 按名字从 ntdll 导出表解析系统调用号 | | `0x1400017B0` | 直接`syscall` 桩 | | `0x1400018D0` | 第二层解密器 | | `0x140001D10` | 分发器(长度检查 +`sum != 0x501` 检查) | | **`0x140004380` .. `0x140006672`** | **核心校验函数** | 分发表符号 `funcs_140001D50` 实际落在 `0x140007100`,8 个表项**全部指向 `0x140004380`** —— 典型的伪多路分发。 ### 2.3 导入表 只导入 8 个 KERNEL32 函数,其余全部走动态解析的 ntdll 直系统调用: ``` 0x140007000 GetStdHandle 0x140007008 GetCurrentProcess 0x140007010 FlushInstructionCache 0x140007018 VirtualProtect 0x140007020 GetModuleHandleA 0x140007028 GetProcAddress 0x140007030 GetCurrentThread 0x140007038 GetThreadContext ``` 按名字动态解析的 syscall:`NtWriteFile` / `NtReadFile` / `NtTerminateProcess` / `NtQuerySystemTime` / `NtQueryInformationProcess`。 ### 2.4 `.tgt` 配置布局 | 偏移 | 值 | 含义 | | ----- | ---------------------- | -------------------- | | +0x00 | `0x4962` | 第二层长度 | | +0x08 | `0x1D10` | 第二层 RVA | | +0x10 | `0x500` | **字节和目标** | | +0x18 | `0` | 种子微调 | | +0x20 | `0x510` | 第一层长度 | | +0x28 | `0x1800` | 第一层 RVA | | +0x30 | `0x0E07EF9C6DB93E02` | hash1 掩码 | | +0x38 | `0x62D1EE2F69239D2E` | 未被引用(诱饵) | | +0x40 | xmmword | 目标 B 掩码 | | +0x50 | xmmword | 目标 A 掩码 | | +0x60 | qword | hash2 掩码 | 注意:`.tgt` 里存的是**掩码**,不是目标本身。真正的目标是掩码与运行时常量异或/派生后的结果,所以静态读 `.tgt` 拿不到目标值 —— 必须动态取。 --- ## 3. 两层自解密 ### 第一层(由 `start` 完成) 对 RVA `0x1800 .. 0x1D10`(长 0x510)逐字节 `XOR 0x5A`。 ### 第二层(由 `sub_1400018D0` 完成) 密钥流由**刚解密好的第一层区域**推出两条 FNV-1a 链: ```python h1, h2 = 0xCBF29CE484222325, 0x9E3779B97F4A7C15 for b in region_layer1: # RVA 0x1800..0x1D10 h1 = (0x100000001B3 * (b ^ h1)) & M64 h2 = (0x100000001B3 * (b ^ h2)) & M64 ``` 实测:`h1 = 0x73fb4f498aab364f`,`h2 = 0xb157c7e044b966df`。 再对 RVA `0x1D10 .. 0x6672`(长 0x4962)逐字节异或: ```python for j in range(0x4962): z = h1 ^ h2 ^ ((0x9E3779B97F4A7C15 * j) & M64) z ^= z >> 33 img[0x1D10 + j] ^= ((0xFF51AFD7ED558CCD * z) & M64) >> 56 ``` 用后**立即重新加密**,配合 `VirtualProtect` + `FlushInstructionCache`。所以静态 dump 拿不到明文代码,必须自己算(见 `decrypt.py`)或从运行态取。 > 依赖链:第一层是第二层的密钥。任何对第一层的改动都会让第二层解出垃圾。 --- ## 4. 反调试与常量生成链 ### 4.1 自校验 **所有算法常量都由代码区 RVA `0x1D10 .. 0x6672` 的 FNV-1a 摘要派生。** 这意味着:**在该区间内改任何一个字节,所有常量全废**。这是整题最狠的一处设计,也是我踩的最大的坑(见 §10)。 ### 4.2 反调试标志字 程序收集一组反调试位,合成一个"标志字",喂给常量生成器: | 检测 | 手段 | | --------------------------------- | --------------------------------------- | | `PEB.BeingDebugged` | `gs:[0x60] + 0x02` | | `PEB.ApiSetMap & 0x70` | 调试器下常有差异 | | DR0–DR7 | `GetThreadContext` 读硬件断点寄存器 | | `ProcessDebugPort` (7) | 直 syscall`NtQueryInformationProcess` | | `ProcessDebugObjectHandle` (30) | 直 syscall 同上 | | `rdtsc` 时序 | 单步/断点会拉长间隔 | **只要任一位被置起,标志字变化 → splitmix64 种子变化 → 全部常量变化 → 永远算不出正确答案。** 所以本题**不能带调试器跑**,只能靠仿真(Unicorn)或纯静态推算。 ### 4.3 splitmix64 → CONTEXT → 常量 标志字与代码摘要混合成 splitmix64 的种子,逐步填充一个 `CONTEXT` 结构(复用系统结构体当常量仓库,混淆意图): ``` step = 0x9E3779B97F4A7C15 (即 -0x61C8864680B583EB) mix1 = 0xBF58476D1CE4E5B9 mix2 = 0x94D049BB133111EB ``` 生成物:模数乘子 `M`、`H1`/`H2`、多项式系数 `K1..K5`、256 字节 S-box、两组 20 个轮密钥 `keys1`/`keys2`,以及四个目标值。 --- ## 5. 校验算法完整还原 ### 5.1 数域 ``` p = 2^127 - 39 = 0x7FFFFFFFFFFFFFFF_FFFFFFFFFFFFFFD9 ``` 代码用 128 位手工模运算实现,折叠时用 `2^128 ≡ 78 (0x4E) mod p`。 ### 5.2 base-94 Horner 编码 可打印输入按 `byte - 33` 转成 base-94 数字,Horner 累积: ```python acc = 0 for ch in input16: acc = acc * 94 + (ch - 33) ``` 16 位 base-94 ≈ 2^105,远小于 p,故编码无损、无溢出歧义。 ### 5.3 五次多项式 ``` u = poly(acc) = acc^5 + K1·acc^4 + K2·acc^3 + K3·acc^2 + K4·acc + K5 (mod p) ``` Horner 实现(`model.py:poly`): ```python u = acc for i in range(4): u = ((u + K[i]) % P) * acc % P return (u + K[4]) % P ``` ### 5.4 双 20 轮 Feistel 把 128 位的 `u` 拆成 `lo = u & M64`、`hi = u >> 64`,分别喂给两条独立的 20 轮 Feistel(密钥组 / 奇偶轮常量不同): - 网络 A:`keys1`,偶轮异或 `H2`,奇轮异或 `H1` - 网络 B:`keys2`,偶轮异或 `H1`,奇轮异或 `H2` 单个半轮: ```python def ror(x, n): return ((x >> n) | (x << (64 - n))) & M64 def sub8(x): # 逐字节 S-box,位置不变 r = 0 for i in range(8): r |= SB[(x >> (8*i)) & 0xFF] << (8*i) return r def G(x): return sub8(x) ^ ror(x, 51) def half(hi, k): # 128 位模乘后压回 64 位 v = ((hi << 64) | k) % P pr = (v * MUL) % P return (pr & M64) ^ k ^ ror(pr >> 64, 47) def feistel(lo, hi, keys, even_key): Pv, Qv = lo, hi for j in range(20): k = keys[j] ^ (even_key if j % 2 == 0 else other_key) out1 = Pv ^ G(half(Qv, k)) Pv, Qv = Qv ^ G(half(out1, k ^ GOLDEN)), out1 return Pv, Qv ``` 每条网络输出 128 位 `(Qv << 64) | Pv`。 ### 5.5 四处比较 汇编中的四组终态比较(六条 `cmp`,因为 128 位分高低两半): | 地址 | 比较 | | --------------- | ----------------------------------------- | | `0x14000660E` | `r12` vs `[rbp+0x38]` — 目标 A 低 64 | | `0x140006626` | `r10` vs `[rbp+0x40]` — 目标 A 高 64 | | `0x14000662E` | `rbx` vs `[rbp+0x48]` — 目标 B 低 64 | | `0x14000663C` | `r14` vs `[rbp+0x50]` — 目标 B 高 64 | | `0x140006644` | `rdi` vs `[rbp+0x58]` — hash1 | | `0x140006655` | `r9` vs `[rsp+0x60]` — hash2 | 字节和门在 `0x1400043FC`:`cmp eax, cs:dword_14000A010` / `0x140004402 jz loc_140004411`。 ### 5.6 关键栈帧偏移(rbp 相对) | 偏移 | 内容 | | ----------------------------------- | -------------------- | | −0x90 / −0x70 | B 的低 / 高 64 位 | | −0x88 | 轮常量 | | −0x68 | A 高 64 位 | | −0x60 / −0x50 | 模数乘子 M 低 / 高 | | −0x38 / −0x30 | H1 / H2 | | −0x20, −0x10, +0x00, +0x10, +0x20 | K1..K5(各 128 位) | | +0x30 | A 低 64 位 | | +0x38 / +0x48 | 目标 A / 目标 B | | +0x58 / +0x60(rsp) | 目标 hash1 / hash2 | | +0x60 | S-box(256 字节) | | +0x160 | keys1(20 × qword) | | +0x200 | keys2(20 × qword) | | +0x2A0 | CONTEXT 结构 | --- ## 6. Unicorn 仿真取常量 因为常量由代码自摘要 + 反调试标志派生,最省事的路子是**仿真到常量生成完毕、在第一次模乘处 dump 栈帧**。 要点: 1. **镜像从磁盘自己解密**(`decrypt.py`),不依赖手改过的 IDB。 2. **伪造 TEB / PEB**,`GS` 基址通过写 MSR `0xC0000101` 设置。 3. **stub 掉 8 个 KERNEL32 导入**: - `GetModuleHandleA(0)` → 镜像基址;`GetModuleHandleA("ntdll.dll")` → 0(让它走不到直 syscall 分支) - `GetCurrentThread` → `-2`,`GetCurrentProcess` → `-1` - `GetThreadContext` → 把 `rdx+0x48..0x78`(DR0–DR7)清零并返回 1 4. **dump 点 `0x140004CF7`**(base-94 Horner 循环里第一次模乘),此时全部常量已就位。 5. **探针输入必须自带合法字节和**:用 `b"P" * 16`(16 × 80 = 1280 = 0x500),否则跑不到 dump 点。 ### 磁盘解密结果自检 `decrypt.py` 输出与 IDB dump 逐字节比对,差异只有 5 段**节尾填充**(RVA `0x6800..0x7040`、`0x7800..0x8000`、`0x8200..0x9000`、`0x9200..0xA000`、`0xA200..0xB000`),IDA 读 `0xFF` 而磁盘映射为 `0x00`;`.tgt`/`.data`/`.rdata`/`.pdata` 前 0x80 字节完全一致。**代码区零差异**。 --- ## 7. 求逆:双 Feistel 逆推 + Cantor–Zassenhaus 求根 ### 7.1 Feistel 可逆性论证 S-box **不是置换**(实测只有 161 个不同输出,某字节最多 5 个原像),乍看不可逆。但看半轮结构: ``` out1 = Pv ^ G(half(Qv, k)) # 非线性输入只用 Qv Pnew = Qv ^ G(half(out1, k ^ GOLDEN)) # 非线性输入只用 out1 Qnew = out1 ``` **每个半轮的非线性函数只吃"这一步不变的那一半"**,所以逆向时那一半是已知的,`G` 只需正向求值、不需求逆。整个 20 轮网络因此严格可逆,与 S-box 是否双射无关。 逆函数: ```python def feistel_inv(Pf, Qf, keys, even_key): Pv, Qv = Pf, Qf for j in reversed(range(20)): k = keys[j] ^ (even_key if j % 2 == 0 else other_key) out1 = Qv Qprev = Pv ^ G(half(out1, k ^ GOLDEN)) Pprev = out1 ^ G(half(Qprev, k)) Pv, Qv = Pprev, Qprev return Pv, Qv ``` ### 7.2 交叉验证(正确性的强证据) 目标 A 经 `keys1` 逆推、目标 B 经 `keys2` 逆推,**两者必须给出同一个 `u`**: ``` required poly value: 0x4be831b0ad3a2d361489375bba3fb8de ``` 两条完全独立的 20 轮网络逆推到同一个 128 位值,偶然吻合概率约 2^-127 —— 这直接证明模型和常量全对。 ### 7.3 多项式求根 问题化为:在 GF(p) 上解 ``` acc^5 + K1·acc^4 + K2·acc^3 + K3·acc^2 + K4·acc + (K5 - u) = 0 ``` 用 **Cantor–Zassenhaus**: 1. 先算 `g = gcd(f, x^p - x)`,只保留一次因子(即 GF(p) 中的根); 2. 再随机取 `a`,用 `gcd(g, (x+a)^((p-1)/2) - 1)` 递归分裂。 结果: ``` degree 5 roots found: 1 root 0x174a5e1d4cf4f146e72f70092ac verify=True fits16digits=True -> b'kanxue@2o26o8!@#' bytesum=1280 poly==UP: True ``` **唯一根**,恰好能用 16 位 base-94 表示,且字节和恰好 1280 —— 与 §5 的字节和门自洽。答案唯一。 --- ## 8. 常量表(实测运行时值) ``` M = 0x4d8a9e9343d415cb83a0a488ec8523fe H1 = 0x4aeee52b5738eb8f H2 = 0x553d3c5ef6ee11ff K1 = 0x21b3bde1acf9adfa471fb28461a53bb5 K2 = 0x191a3870ad3d941105f15a9c38aae874 K3 = 0x40c190e211322caa2f01797348f02c97 K4 = 0x1df732140b8a3ef758fd09bde0058a37 K5 = 0x3f0cb8512c6f88e47e5f164fb478aab5 目标 A = 0x0c14db1c20dd97a8b8fd0694401d3d03 目标 B = 0x9a2789b6de31c2dcaca5240a53fb78a6 目标 H1= 0x6cd601b3049aa32c 目标 H2= 0xbc1e5ebe1e4ec0d4 keys1[:4] = 0x454ab6cf8f0fe386, 0x0715664a40e9f5f5, 0x9306305b57fba9a1, 0xe98c2c52e5ebb7db keys2[:4] = 0x2ed2623056d887ca, 0xe92dca64a141d5cd, 0x1d02ff225704d8b1, 0x0b0d6805938ef7ae sbox[:16] = [78,216,53,23,45,142,145,107,171,101,48,242,138,222,218,24] sbox 是置换: False (161 个不同输出) 必需多项式值 u = 0x4be831b0ad3a2d361489375bba3fb8de 求得 acc = 0x174a5e1d4cf4f146e72f70092ac ``` ### 第二层解密链值 ``` FNV chains: h1 = 0x73fb4f498aab364f h2 = 0xb157c7e044b966df layer1: RVA 0x1800 len 0x510 layer2: RVA 0x1d10 len 0x4962 ``` --- ## 9. 完整复现脚本 全部脚本在 `F:\tmp\kctf8\`,执行顺序: ``` decrypt.py → emu.py → dump.py → model.py → solve2.py → verify.py → run_real.py ``` IDA 只用于人读分析,不参与复现链。 > Windows 下执行方式(避开 cygwin 与内联引号问题): > `cmd.exe /c "cd /d F:\tmp\kctf8 && python decrypt.py"` 依赖:`pip install unicorn`(Python 3.9+,`pow(x,-1,P)` 需要 3.8+)。 --- ### 9.1 `decrypt.py` — 从磁盘 PE 重建运行时解密镜像 ```python """Rebuild the runtime-decrypted image straight from the on-disk PE. Layer 1 (done by `start`): XOR 0x5A over RVA 0x1800 .. 0x1D10 (len 0x510) Layer 2 (done by sub_1400018D0): keystream XOR over RVA 0x1D10 .. 0x6672 (len 0x4962), keystream derived from two FNV-1a chains over the *already decrypted* layer-1 region. """ import struct EXE = r"F:\2026\KCTF\8_2\kctf2026_CrackMe08.exe" OUT = r"F:\tmp\kctf8\image_from_disk.bin" REF = r"F:\tmp\kctf8\image_1000_b000.bin" M64 = (1 << 64) - 1 FNV_PRIME = 0x100000001B3 FNV_OFF = 0xCBF29CE484222325 GOLDEN = 0x9E3779B97F4A7C15 def map_image(path, size=0x20000): d = open(path, "rb").read() pe = struct.unpack_from("<I", d, 0x3C)[0] nsec = struct.unpack_from("<H", d, pe + 6)[0] optsz = struct.unpack_from("<H", d, pe + 20)[0] sect = pe + 24 + optsz img = bytearray(size) hdr = struct.unpack_from("<I", d, pe + 24 + 60)[0] # SizeOfHeaders img[0:hdr] = d[0:hdr] secs = [] for i in range(nsec): o = sect + 40 * i name = d[o:o + 8].rstrip(b"\x00").decode() vsz, va, rsz, ptr = struct.unpack_from("<IIII", d, o + 8) img[va:va + rsz] = d[ptr:ptr + rsz] secs.append((name, va, vsz, rsz, ptr)) return img, secs img, secs = map_image(EXE) print("sections:") for n, va, vsz, rsz, ptr in secs: print(" %-8s VA %#07x vsz %#06x raw %#06x@%#06x" % (n, va, vsz, rsz, ptr)) # .tgt config drives both layers cfg = lambda off: struct.unpack_from("<Q", img, 0xA000 + off)[0] L2_LEN, L2_RVA = cfg(0x00), cfg(0x08) L1_LEN, L1_RVA = cfg(0x20), cfg(0x28) print("layer1: RVA %#x len %#x layer2: RVA %#x len %#x" % (L1_RVA, L1_LEN, L2_RVA, L2_LEN)) # ---- layer 1: plain XOR 0x5A ---- for i in range(L1_RVA, L1_RVA + L1_LEN): img[i] ^= 0x5A # ---- layer 2: keystream from two FNV-1a chains over the layer-1 region ---- h1, h2 = FNV_OFF, GOLDEN for i in range(L1_RVA, L1_RVA + L1_LEN): b = img[i] h1 = (FNV_PRIME * (b ^ h1)) & M64 h2 = (FNV_PRIME * (b ^ h2)) & M64 print("FNV chains: h1=%#018x h2=%#018x" % (h1, h2)) for j in range(L2_LEN): z = h1 ^ h2 ^ ((GOLDEN * j) & M64) z ^= z >> 33 img[L2_RVA + j] ^= ((0xFF51AFD7ED558CCD * z) & M64) >> 56 open(OUT, "wb").write(bytes(img[0x1000:0xB000])) open(r"F:\tmp\kctf8\image_full.bin", "wb").write(bytes(img)) ``` --- ### 9.2 `emu.py` — Unicorn 仿真环境 ```python import struct, sys from unicorn import * from unicorn.x86_const import * EXE = r"F:\2026\KCTF\8_2\kctf2026_CrackMe08.exe" IMAGE = r"F:\tmp\kctf8\image_full.bin" # produced by decrypt.py (both layers applied) BASE = 0x140000000 IMGSZ = 0x20000 STUB = 0x01000000 STACK = 0x00200000 STKSZ = 0x40000 TEB = 0x00300000 PEB = 0x00301000 INBUF = 0x00400000 IAT = { 0x140007000: "GetStdHandle", 0x140007008: "GetCurrentProcess", 0x140007010: "FlushInstructionCache", 0x140007018: "VirtualProtect", 0x140007020: "GetModuleHandleA", 0x140007028: "GetProcAddress", 0x140007030: "GetCurrentThread", 0x140007038: "GetThreadContext", } def build_image(): img = bytearray(open(IMAGE, "rb").read()) assert len(img) == IMGSZ, len(img) return img class Emu: def __init__(self, verbose=False): self.verbose = verbose self.uc = uc = Uc(UC_ARCH_X86, UC_MODE_64) img = build_image() uc.mem_map(BASE, IMGSZ, UC_PROT_ALL) uc.mem_write(BASE, bytes(img)) # NOTE: never patch image bytes -- the code hashes itself (RVA 0x1d10..0x6672) # and derives every constant from that hash. Probe only with inputs whose # byte sum == 0x500, e.g. b"P" * 16. uc.mem_map(STUB, 0x1000, UC_PROT_ALL) uc.mem_write(STUB, b"\xC3" * 0x1000) self.stub_of = {} for i, (slot, name) in enumerate(sorted(IAT.items())): addr = STUB + i * 0x10 uc.mem_write(slot, struct.pack("<Q", addr)) self.stub_of[addr] = name uc.mem_map(STACK, STKSZ, UC_PROT_ALL) uc.mem_map(TEB, 0x1000, UC_PROT_ALL) uc.mem_map(PEB, 0x1000, UC_PROT_ALL) uc.mem_write(TEB + 0x60, struct.pack("<Q", PEB)) # TEB->ProcessEnvironmentBlock uc.mem_write(PEB, b"\x00" * 0x400) # BeingDebugged=0, ApiSetMap=0 uc.reg_write(UC_X86_REG_MSR, (0xC0000101, TEB)) # GS base uc.mem_map(INBUF, 0x1000, UC_PROT_ALL) uc.hook_add(UC_HOOK_CODE, self.hk_stub, begin=STUB, end=STUB + 0x1000) self.snap = {} def hk_stub(self, uc, addr, size, ud): name = self.stub_of.get(addr) if name is None: return rcx = uc.reg_read(UC_X86_REG_RCX) rdx = uc.reg_read(UC_X86_REG_RDX) if name == "GetModuleHandleA": if rcx == 0: uc.reg_write(UC_X86_REG_RAX, BASE) else: uc.reg_write(UC_X86_REG_RAX, 0) # "ntdll.dll" -> not found, skip probe path elif name == "GetCurrentThread": uc.reg_write(UC_X86_REG_RAX, 0xFFFFFFFFFFFFFFFE) elif name == "GetCurrentProcess": uc.reg_write(UC_X86_REG_RAX, 0xFFFFFFFFFFFFFFFF) elif name == "GetThreadContext": uc.mem_write(rdx + 0x48, b"\x00" * (0x78 - 0x48)) # Dr0..Dr7 = 0 uc.reg_write(UC_X86_REG_RAX, 1) else: uc.reg_write(UC_X86_REG_RAX, 1) if self.verbose: print(" [api] %s(%#x)" % (name, rcx)) def hk_final(self, uc, addr, size, ud): if addr != 0x14000660E: return rbp = uc.reg_read(UC_X86_REG_RBP) rsp = uc.reg_read(UC_X86_REG_RSP) rd = lambda a, n=8: int.from_bytes(uc.mem_read(a, n), "little") self.snap = dict( gotA_lo=uc.reg_read(UC_X86_REG_R12), gotA_hi=uc.reg_read(UC_X86_REG_R10), tgtA_lo=rd(rbp + 0x38), tgtA_hi=rd(rbp + 0x40), gotB_lo=uc.reg_read(UC_X86_REG_RBX), gotB_hi=uc.reg_read(UC_X86_REG_R14), tgtB_lo=rd(rbp + 0x48), tgtB_hi=rd(rbp + 0x50), gotH1=uc.reg_read(UC_X86_REG_RDI), tgtH1=rd(rbp + 0x58), gotH2=uc.reg_read(UC_X86_REG_R9), tgtH2=rd(rsp + 0x60), ) def run(self, inp16): uc = self.uc assert len(inp16) == 16 uc.mem_write(INBUF, bytes(inp16)) rsp = STACK + STKSZ - 0x2000 uc.mem_write(rsp, struct.pack("<Q", 0xdeadbeef)) # fake return address uc.reg_write(UC_X86_REG_RSP, rsp) uc.reg_write(UC_X86_REG_RCX, INBUF) self.snap = {} h = uc.hook_add(UC_HOOK_CODE, self.hk_final, begin=0x14000660E, end=0x14000660E) try: uc.emu_start(0x140004380, 0xdeadbeef, count=100_000_000) finally: uc.hook_del(h) return uc.reg_read(UC_X86_REG_RAX) & 0xffffffff if __name__ == "__main__": e = Emu(verbose=True) r = e.run(b"P" * 16) # byte sum 1280 == 0x500, passes the gate print("ret =", r) for k, v in e.snap.items(): print(" %-8s %#034x" % (k, v)) ``` --- ### 9.3 `dump.py` — 在第一次模乘处 dump 全部常量 ```python import struct, sys, json sys.path.insert(0, r"F:\tmp\kctf8") from emu import Emu, INBUF from unicorn import UC_HOOK_CODE from unicorn.x86_const import UC_X86_REG_RBP HOOK_AT = 0x140004CF7 # first mulmod inside the base-94 Horner loop class Dumper(Emu): def grab(self, inp16): got = {} def hk(uc, addr, size, ud): if addr != HOOK_AT or got: return rbp = uc.reg_read(UC_X86_REG_RBP) rd = lambda off, n=8: int.from_bytes(uc.mem_read(rbp + off, n), "little") got["rbp"] = rbp got["M"] = (rd(-0x50) << 64) | rd(-0x60) # v405:v404 got["H1"] = rd(-0x38) # v409 got["H2"] = rd(-0x30) # v410 for i, off in enumerate((-0x20, -0x10, 0x00, 0x10, 0x20)): got["K%d" % (i + 1)] = (rd(off + 8) << 64) | rd(off) got["TA"] = (rd(0x40) << 64) | rd(0x38) got["TB"] = (rd(0x50) << 64) | rd(0x48) got["TH1"] = rd(0x58) got["sbox"] = list(uc.mem_read(rbp + 0x60, 256)) got["keys1"] = [rd(0x160 + 8 * i) for i in range(20)] got["keys2"] = [rd(0x200 + 8 * i) for i in range(20)] got["ctx"] = [rd(0x2A0 + 8 * i) for i in range(0x4D0 // 8)] h = self.uc.hook_add(UC_HOOK_CODE, hk, begin=HOOK_AT, end=HOOK_AT) try: ret = self.run(inp16) finally: self.uc.hook_del(h) return ret, got if __name__ == "__main__": d = Dumper() probe = bytes([80] * 16) # byte sum = 16*80 = 1280 = 0x500 -> passes the gate assert sum(probe) == 0x500 ret, g = d.grab(probe) print("ret", ret, "probe sum", sum(probe)) for k in ("rbp", "M", "H1", "H2", "K1", "K2", "K3", "K4", "K5", "TA", "TB", "TH1"): print("%-5s %#x" % (k, g[k])) print("sbox[:16]", g["sbox"][:16]) print("sbox is permutation:", len(set(g["sbox"])) == 256) print("keys1", [hex(x) for x in g["keys1"][:4]]) print("keys2", [hex(x) for x in g["keys2"][:4]]) snap = dict(d.snap) out = {k: (v if not isinstance(v, list) else v) for k, v in g.items()} out["snapA"] = (snap["gotA_hi"] << 64) | snap["gotA_lo"] out["snapB"] = (snap["gotB_hi"] << 64) | snap["gotB_lo"] json.dump(out, open(r"F:\tmp\kctf8\consts.json", "w")) print("gotA %#x" % out["snapA"]) print("gotB %#x" % out["snapB"]) ``` --- ### 9.4 `model.py` — 纯 Python 复刻(与仿真逐位一致) ```python import json P = (1 << 127) - 39 M64 = (1 << 64) - 1 GOLDEN = 0x9E3779B97F4A7C15 c = json.load(open(r"F:\tmp\kctf8\consts.json")) MUL = c["M"] H1, H2 = c["H1"], c["H2"] K = [c["K%d" % i] for i in range(1, 6)] SB = c["sbox"] KEYS1, KEYS2 = c["keys1"], c["keys2"] TA, TB = c["TA"], c["TB"] def ror(x, n): return ((x >> n) | (x << (64 - n))) & M64 def sub8(x): r = 0 for i in range(8): r |= SB[(x >> (8 * i)) & 0xFF] << (8 * i) return r def G(x): return sub8(x) ^ ror(x, 51) def half(hi, k): """one half-round: multiply (hi:k) by MUL mod p, then compress to 64 bits""" v = ((hi << 64) | k) % P pr = (v * MUL) % P return (pr & M64) ^ k ^ ror(pr >> 64, 47) def feistel(lo, hi, keys, even_key): """even_key = key xored on even rounds; the other one on odd rounds""" Pv, Qv = lo, hi for j in range(20): k = keys[j] ^ (even_key if j % 2 == 0 else (H1 if even_key == H2 else H2)) out1 = Pv ^ G(half(Qv, k)) Pv, Qv = Qv ^ G(half(out1, k ^ GOLDEN)), out1 return Pv, Qv def feistel_inv(Pf, Qf, keys, even_key): Pv, Qv = Pf, Qf for j in reversed(range(20)): k = keys[j] ^ (even_key if j % 2 == 0 else (H1 if even_key == H2 else H2)) out1 = Qv Qprev = Pv ^ G(half(out1, k ^ GOLDEN)) Pprev = out1 ^ G(half(Qprev, k)) Pv, Qv = Pprev, Qprev return Pv, Qv def poly(acc): u = acc for i in range(4): u = ((u + K[i]) % P) * acc % P return (u + K[4]) % P def forward(acc): up = poly(acc) lo, hi = up & M64, up >> 64 pa, qa = feistel(lo, hi, KEYS1, H2) pb, qb = feistel(lo, hi, KEYS2, H1) return (qa << 64) | pa, (qb << 64) | pb def base94(digits): a = 0 for d in digits: a = a * 94 + d return a def to_digits(acc): ds = [0] * 16 for i in range(15, -1, -1): ds[i] = acc % 94 acc //= 94 return ds, acc == 0 if __name__ == "__main__": acc = base94([ord("P") - 33] * 16) # same probe input dump.py used A, B = forward(acc) print("model A %#034x" % A) print("emu A %#034x" % c["snapA"]) print("model B %#034x" % B) print("emu B %#034x" % c["snapB"]) print("A match:", A == c["snapA"], " B match:", B == c["snapB"]) ``` 实际输出: ``` model A 0x8c727c518166e905ff477a4d5cddf1b4 emu A 0x8c727c518166e905ff477a4d5cddf1b4 model B 0x0b9fc797e8b363ada72fd97fc693a7df emu B 0x0b9fc797e8b363ada72fd97fc693a7df A match: True B match: True ``` --- ### 9.5 `solve2.py` — 逆推 + Cantor–Zassenhaus 求根 ```python import model as M P = M.P M64 = M.M64 pa, qa = M.feistel_inv(M.TA & M64, M.TA >> 64, M.KEYS1, M.H2) pb, qb = M.feistel_inv(M.TB & M64, M.TB >> 64, M.KEYS2, M.H1) UPA = (qa << 64) | pa UPB = (qb << 64) | pb assert UPA == UPB and UPA < P, "targets inconsistent" UP = UPA print("required poly value: %#034x" % UP) # poly(x) = x^5 + K1 x^4 + K2 x^3 + K3 x^2 + K4 x + K5 K = M.K co = [0, 1] for i in range(4): t = co[:] t[0] = (t[0] + K[i]) % P co = [0] + t co[0] = (co[0] + K[4]) % P assert (sum(c * pow(999, i, P) for i, c in enumerate(co)) % P) == M.poly(999) co[0] = (co[0] - UP) % P print("degree", len(co) - 1) # ---- root finding over GF(p) : Cantor-Zassenhaus ---- def pnorm(a): while a and a[-1] == 0: a.pop() return a def pmul(a, b): if not a or not b: return [] 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 pnorm(r) def pmod(a, m): a = a[:] inv = pow(m[-1], -1, P) while len(a) >= len(m): if a[-1]: f = a[-1] * inv % P off = len(a) - len(m) for i, c in enumerate(m): a[off + i] = (a[off + i] - f * c) % P a.pop() return pnorm(a) def ppowmod(base, e, m): r = [1] base = pmod(base, m) while e: if e & 1: r = pmod(pmul(r, base), m) base = pmod(pmul(base, base), m) e >>= 1 return r def pgcd(a, b): a, b = pnorm(a[:]), pnorm(b[:]) while b: a, b = b, pmod(a, b) return a def pmonic(a): inv = pow(a[-1], -1, P) return [c * inv % P for c in a] def roots(f): """all roots in GF(p) of squarefree-ish f""" f = pmonic(pnorm(f[:])) # g = gcd(f, x^p - x) keeps exactly the linear factors xp = ppowmod([0, 1], P, f) g = pgcd(f, pnorm([(xp[0] if xp else 0), ((xp[1] if len(xp) > 1 else 0) - 1) % P] + [c % P for c in xp[2:]])) g = pmonic(g) if g else [] out = [] def split(h): if len(h) <= 1: return if len(h) == 2: # x + c out.append((-h[0]) % P) return import random rnd = random.Random(len(out) + len(h) + 12345) while True: a = rnd.randrange(P) t = ppowmod([a % P, 1], (P - 1) // 2, h) t = pnorm([(t[0] - 1) % P] + t[1:]) if t else [] d = pgcd(h, t) if d and 0 < len(d) - 1 < len(h) - 1: d = pmonic(d) split(d) split(pmonic(pdiv(h, d))) return def pdiv(a, b): a = a[:] q = [0] * (len(a) - len(b) + 1) inv = pow(b[-1], -1, P) while len(a) >= len(b): if a[-1]: f2 = a[-1] * inv % P q[len(a) - len(b)] = f2 off = len(a) - len(b) for i, cc in enumerate(b): a[off + i] = (a[off + i] - f2 * cc) % P a.pop() return pnorm(q) split(g) return sorted(set(out)) rs = roots(co) print("roots found:", len(rs)) for r in rs: ok = (sum(c * pow(r, i, P) for i, c in enumerate(co)) % P) == 0 ds, fits = M.to_digits(r) print(" root %#x verify=%s fits16digits=%s" % (r, ok, fits)) if fits: s = bytes(d + 33 for d in ds) print(" -> %r bytesum=%d poly==UP: %s" % (s, sum(s), M.poly(r) == UP)) ``` --- ### 9.6 `verify.py` — 仿真验证答案 ```python import emu FLAG = b"kanxue@2o26o8!@#" e = emu.Emu() print("input :", FLAG.decode()) print("length :", len(FLAG), " bytesum:", sum(FLAG)) ret = e.run(FLAG) s = e.snap print("emu return value (1 == correct):", ret) print(" gotA %#034x tgtA %#034x match=%s" % ( (s["gotA_hi"] << 64) | s["gotA_lo"], (s["tgtA_hi"] << 64) | s["tgtA_lo"], (s["gotA_hi"], s["gotA_lo"]) == (s["tgtA_hi"], s["tgtA_lo"]))) print(" gotB %#034x tgtB %#034x match=%s" % ( (s["gotB_hi"] << 64) | s["gotB_lo"], (s["tgtB_hi"] << 64) | s["tgtB_lo"], (s["gotB_hi"], s["gotB_lo"]) == (s["tgtB_hi"], s["tgtB_lo"]))) print(" hash1 %#018x vs %#018x match=%s" % (s["gotH1"], s["tgtH1"], s["gotH1"] == s["tgtH1"])) print(" hash2 %#018x vs %#018x match=%s" % (s["gotH2"], s["tgtH2"], s["gotH2"] == s["tgtH2"])) ``` --- ### 9.7 `run_real.py` — 真机验证 ```python import subprocess, os EXE = r"F:\2026\KCTF\8_2\kctf2026_CrackMe08.exe" FLAG = b"kanxue@2o26o8!@#" for suffix, label in ((b"", "no newline"), (b"\n", "LF"), (b"\r\n", "CRLF")): path = r"F:\tmp\kctf8\stdin.bin" open(path, "wb").write(FLAG + suffix) with open(path, "rb") as f: p = subprocess.run([EXE], stdin=f, capture_output=True) print("%-11s -> stdout=%r exit=%d" % (label, p.stdout, p.returncode)) # negative control open(r"F:\tmp\kctf8\stdin.bin", "wb").write(b"PPPPPPPPPPPPPPPP\n") with open(r"F:\tmp\kctf8\stdin.bin", "rb") as f: p = subprocess.run([EXE], stdin=f, capture_output=True) print("control -> stdout=%r exit=%d" % (p.stdout, p.returncode)) ``` --- ### 9.8 全链路实测输出 ``` required poly value: 0x4be831b0ad3a2d361489375bba3fb8de roots found: 1 root 0x174a5e1d4cf4f146e72f70092ac verify=True fits16digits=True -> b'kanxue@2o26o8!@#' bytesum=1280 poly==UP: True ==== emu return value (1 == correct): 1 gotA 0x0c14db1c20dd97a8b8fd0694401d3d03 tgtA 0x0c14db1c20dd97a8b8fd0694401d3d03 match=True gotB 0x9a2789b6de31c2dcaca5240a53fb78a6 tgtB 0x9a2789b6de31c2dcaca5240a53fb78a6 match=True hash1 0x6cd601b3049aa32c vs 0x6cd601b3049aa32c match=True hash2 0xbc1e5ebe1e4ec0d4 vs 0xbc1e5ebe1e4ec0d4 match=True ==== no newline -> stdout=b'Input:correct\n' exit=0 LF -> stdout=b'Input:correct\n' exit=0 CRLF -> stdout=b'Input:correct\n' exit=0 control -> stdout=b'Input:fault\n' exit=1 ``` --- ## 10. 踩坑记录 ### 坑 1:在自校验区间里打补丁(最致命) 为了绕过字节和门方便探针,我一开始写了: ```python uc.mem_write(0x140004402, b"\xEB") # jz -> jmp ``` `0x140004402` 落在 RVA `0x1D10..0x6672` 之内 —— 正是被 FNV 摘要的自校验区。 **症状**:两个 128 位目标逆推出**两个不同**的多项式值(`assert UPA == UPB` 失败)。这个症状很容易被误判成"Feistel 结构理解错了",实际是常量已经全歪。 **修法**:彻底删掉补丁,改用**天然满足字节和的探针** `b"P" * 16`(16 × 80 = 1280 = 0x500)。改完立刻两边逆推到同一个值。 > 教训:面对自校验样本,**"改一个字节图方便"是禁区**。要么绕过检查点的整条路径(改控制流之外的东西,如构造合法输入),要么别改。 ### 坑 2:`echo` 的尾随空格 ``` echo kanxue@2o26o8!@# | kctf2026_CrackMe08.exe → Input:fault ``` cmd 会把 `#` 与 `|` 之间的空格一起送进管道,剥掉换行后长度是 17 而非 16,直接被长度门毙掉。 **修法**:把输入写进文件,用 `subprocess.run([EXE], stdin=f)` 喂 stdin。三种行尾(无换行 / LF / CRLF)都能 `correct`。 ### 坑 3:以为是仿射映射 最初想偷懒:假设 `acc → (A, B)` 是仿射的,探针 `acc = 0..1234567` 拟合。结果完全不成立 —— 因为中间有五次多项式 + 两条 20 轮 Feistel。这次失败反而逼出了正确路线:老老实实读汇编、还原结构、按代数求逆。 ### 坑 4:Windows 下脚本执行环境 - bash 工具下 heredoc 写含中文的文件会**静默失败**(0 字节或残留旧文件); - `python -c "...含中文..."` 在 cmd 下中文参数变 `?`; - `>nul` 重定向报"系统找不到指定的路径"。 **修法**:一律 Write 工具落 `.py` 文件(ASCII 路径),再 `cmd.exe /c "cd /d F:\tmp\kctf8 && python x.py"` 执行。 ### 坑 5:IDA MCP 输出被截断 `sub_140004380` 的 Hex-Rays 伪代码 56365 字符 / 2033 行,MCP 传输在 76KB 处截断。**修法**:用 `ida_hexrays.decompile` 在 IDA 里直接落盘到 `F:\tmp\kctf8\f4380.c`,再本地读。反汇编(2384 条指令)同理落到 `f4380.asm`,用来定位 `mul`/`imul` 块和最终比较。 --- ## 11. 设计点评 这题的防护层次很清楚,且每一层都真的挡人: 1. **两层依赖式自解密** —— 静态 dump 拿不到代码,且第一层是第二层的密钥; 2. **常量全部由代码摘要派生** —— 任何内联补丁(含 INT3 断点)都会让常量全废,等于"打补丁 = 自动上锁"; 3. **反调试位进种子** —— 调试器附加不是"报错退出",而是**静默算出错误常量**,最难排查的失败模式; 4. **诱饵** —— 诱饵 FNV 函数、8 项全同的分发表、`.tgt+0x38` 那个从不被引用的常量; 5. **密码学核心** —— 五次多项式 + 双 20 轮 Feistel,看着像单向,实则因半轮结构(非线性只吃不变的那一半)严格可逆,S-box 故意做成非置换来误导。 对应的破法也就三句话:**别打补丁(仿真取常量)、别带调试器(Unicorn + 假 PEB)、别猜结构(读汇编 + 代数求逆)**。 --- **最终答案** ``` kanxue@2o26o8!@# ```
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
阿捏利
1
9
发帖
10
回帖
124
RANK
关注
私信
他的文章
[原创]丑寅同墟·星海抉择WriteUp
21
[原创]亥子合辰·塔影迷楼 Writeup
52
[原创]申时·忆海倒带— WriteUp
77
[原创]巳时·绿光幽语 题解(Writeup)
3
[原创]# KCTF 2026 – Rosetta Calibration 题解(Writeup)
6
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部