首页
社区
课程
招聘
[分享]kctf2026_CrackMe08 题解
发表于: 3天前 32

[分享]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 无约减 双射
acct5 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 与直接系统调用

main0x1400012C0)开头就做第一层解密,参数全从 .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 直接看能看到 Inpuffaulf 这种半通不通的串,就是因为它们在别处以立即数形式出现。)

3.1 系统调用号是"抠"出来的

0x140001220 这个小函数:

  1. GetModuleHandleA("ntdll.dll")GetProcAddress(name)
  2. 在导出桩的前 0x40 字节里扫 0F 05syscall
  3. 找到后往回扫 B8mov 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 主流程

  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 负责解开第二层,而密钥来自第一层解密后的字节

# 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$mn0x10000x1800)。那一段不参与任何哈希,且 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

0x6832 位 PEB 中 NtGlobalFlag 的偏移;64 位 PEB 该处是 ApiSetMap 指针。在真实进程上读出来验证:

PEB+0x68 (x64: ApiSetMap)   = 0x2878E94000012 位 = 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.Dr3Context.Xmm7Context.Legacy[4] 全都是伪随机数,不是真的寄存器。第一次读的时候我在这里愣了很久,以为校验依赖运行时寄存器状态。
另外 GetThreadContext 传的 ContextFlags = 0x100010CONTEXT_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,就能对任意输入取任意中间值。

它带来三个决定性的信息:

  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 唯一确定,且真机验证通过,所以不影响结论 —— 于是把它标成"已知未追"就收工了。

这个判断在求解上没错,但在分析上是个漏洞:一个自称完整的模型,不应该有一块跑不通还说不清为什么。

回头把两个循环头逐条对齐,差别只有一条指令:

; 链 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)

想自己看反汇编

  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


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回