首页
社区
课程
招聘
KCTF2026: 第八题:亥子合辰·塔影迷楼
发表于: 3天前 40

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"

真正有用的字符串反倒是没人费心去藏的那批——0x1400070680x1400070F8 明文躺着:
ntdll.dllNtWriteFileNtReadFileNtTerminateProcessNtQuerySystemTime
NtQueryInformationProcess。最后那个名字,等于在你解密一个字节之前就告诉你里面有调试
端口检查。

.pdata 也没加密:0x1BC 字节 = 37 条 RUNTIME_FUNCTION,等于白送一份完整的函数表——
而这些函数对应的代码,你这会儿还读不了(0x1400043800x1400035800x140002C60
都直接摆在里面)。这张表后面还能反过来当作解密正确性的判据:解密对了的话,表里每个函数
起始地址都应该落在一个合法的 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
0x1400015100x14000152F),要求剩余长度恰好为 160x140001531: 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

走这个解析器的只有四个:NtWriteFileNtReadFileNtQuerySystemTime
NtTerminateProcess。第五个 NtQueryInformationProcess 是由同一段循环的内联副本
0x140004566(第二层校验器内部)解析的。此外 0x140004070 还躺着第三份字节完全相同的独立
副本,从没被调用过。

每个 NtTerminateProcess 后面都跟着一条 jmp $ 死循环(0x1400015D00x140001680
0x1400017130x1400017A6):hook 或破坏这个系统调用,进程直接挂死。

两处伪装成检查的死存储

0x1400015D6 调用 0x140001070,它把 16 个输入字节按
h ^= b; h = ror64(h * 0x100000001B3, 57) 折叠——初值不是 FNV 的 basis,而是
0x243F6A8885A308D3(π 的小数部分)——一共 17 轮(最后一个字节之后还多做一次「乘法 + 轮转」),
再跟 0x1111111111111111 比较。命中就对 0x140008000 处的全局量做读-改-写(g = 3g + 1)。
第二个诱饵 0x1400011AF0x140001214 解析 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 校验和:在校验器里是真卡点,在外层函数里是陷阱

真正的卡点在 0x140004380punpcklbw / 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
0x1400044D40x1400045290x1400046710x1400046E9

检测手段
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 = 40x14000460D0x140004653),而 x64 上这两个值都是指针大小,于是都会返回 STATUS_INFO_LENGTH_MISMATCHor 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..A4C 的处理是一样的:约简后如果得到 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 >> 64T0 = y & M64

3.6 两条各 20 轮的 Feistel 链

(H0, T0) 被喂进两条独立的 20 轮链,都从同一个起点出发——一条走 RC[0..19],另一条走
RC[20..39]——而且两条挑轮密钥用的是相反的奇偶(循环 1 在 0x140005945cmove
循环 2 在 0x140005EE3cmovne)。其中

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..A4C、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 对一件事非常克制:绝不告诉攻击者他被抓到了。最漂亮的地方
不是其中任何单一机制,而是它们之间的联锁:

  • 软件断点只要落在 0x140001D100x140006672 之间,改动的字节就会被 0x140004490
    处的自哈希混进 K0 / K1
  • 硬件断点不改任何字节,于是由 0x1400044F2GetThreadContext 抓到——并在 0x140004720 处混进同一个种子。
  • 负载在返回前重新加密,事后 dump 拿到的是密文。
  • 输入从来不跟任何东西比较,被比的只有一个 384 位摘要。

三道裂缝

没被哈希的那段桩代码。 .text$mn0x10000x17FF)不被任何完整性检查覆盖:第一个
哈希覆盖 smca,第二个覆盖 smcb,没有任何一个覆盖这段桩。在 0x1400015DF 或系统调用门里
int3 是免费的。

负载有一半是一份可读的算法说明书。 18.8 KB 里约 9.8 KB——约 25 个函数,跨度
0x140001DE00x14000405D——是编译器把各原语内联进 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.asmsmca.asmmn.asm 三个代码块的反汇编
WRITEUP.en.md 英文版

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

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