-
-
[原创] KCTF 2026 第八题《亥子合辰·塔影迷楼》题解
-
发表于: 2026-8-22 03:29 47
-
[原创] KCTF 2026 第八题《亥子合辰·塔影迷楼》题解
题目类型:Reverse 出题战队:心学
解题耗时:1 小时 02 分 25 秒
最终序列号:kanxue@2o26o8!@#
0. 题目
附件 kctf2026_CrackMe08.rar 解压出一个 27KB 的 x64 console 程序,判胜条件是输入序列号回车显示 correct。
拿到手先看"地面真相"而不是题面故事:
架构: x64 CUI | 无 ASLR | 导入表只有 8 个函数:
GetStdHandle, GetCurrentProcess, FlushInstructionCache, VirtualProtect,
GetModuleHandleA, GetProcAddress, GetCurrentThread, GetContext
节区: .text(熵 7.90!) .rdata .data .pdata .tgt(非标准名)
strings 残影: 'Inpuf' 'faulf' 'corr' 'ZZZZ...' ntdll.dll/NtReadFile/NtWriteFile...
三个信号叠在一起基本定型:VirtualProtect+FlushInstructionCache = 自修改代码(SMC);.text 熵 7.9 = 大半代码是密文;ntdll 动态解析 + 无 ReadFile/WriteFile 导入 = 内联 syscall(反 hook)。后面还发现出题人在节区表里自己写了名字 .text$smca / .text$smcb —— 节区名直接自曝了 SMC。
题面故事里的"十六层加密、密钥分散在十六个节点、按正确顺序叠加、任何节点失败全盘皆输",读完全文你会承认这不是装饰,是精确的剧透。
1. 第一层:EP 就是一个解密器
入口 0x12c0 的 stub 逻辑干净利落:
GetModuleHandleA(NULL) → base
rbx = 0x510, rdi = base+0x1800 ← 参数来自 .tgt 节 +0x20/+0x28
VirtualProtect(base+0x1800, 0x510, RWX)
XOR 解密: SSE 循环 16 字节异或 key@[0x1400070d0],尾部单字节 xor 0x5a
FlushInstructionCache → 跳入解密后的代码
key 读出来是 16 个 0x5a —— strings 里那堆 'ZZZ' 残影就是它。解密 0x510 字节,得到第一层明文代码。
非标准节 .tgt(target?)是个参数表,交错存放 (len, off) 对和一个 64 字节的目标值区。这个节是全题的"地图"。
2. 第二层:代码的哈希是下一层的密钥
第一层代码里有个函数(0x1400018d0)先对自己动刀:
双 FNV-1a 哈希第一层代码(0x1800..0x2310):
h1 = FNV 基 0xcbf29ce484222325
h2 = FNV 基 0x9e3779b97f4a7c15 ← 黄金比例!题面"Fibonacci"的第一个实体
VirtualProtect 段B (0x1d10, 0x4962)
用 (h1, h2) 派生密钥流解密段B → call 段B 首函数(输入) → 用同一密钥流把段B 加密回去
密钥流是逐字节的:对每个字节偏移 off,
t = off × φ; t ^= h1; t ^= h2; t = (t>>33)^t; t *= 0xff51afd7ed558ccd; key = t >> 56
(murmur3 fmix64 尾混淆取最高字节;SSE 双 lane 的 paddq (0,1) 说明相邻字节各有独立流。)
这就是"密钥分散在节点、按顺序叠加"的真身:每一层代码的哈希是下一层的解密密钥——你 patch 任何一层,后续所有层全部解废。"任何节点失败,全盘皆输"是字面意思。
3. frida 全军覆没与 RPM 一击
想抓"段B 解密态"的内存,frida hook NtReadFile/NtWriteFile —— 全部静默失效,程序照常跑完输出 Input:fault。原因:导入表那 8 个函数 + GetProcAddress 只是为了读 ntdll 函数头提取 syscall number,然后内联 syscall(Hell's Gate),用户态 hook 根本拦不到。
换最朴素的姿势反而一击必中:subprocess 起进程、stdin 用 PIPE 保持打开 —— 程序阻塞在"等输入"上,进程活着;再用 ctypes OpenProcess+ReadProcessMemory 从外部读内存(不是调试器,DebugPort=0,也不碰 DR 寄存器)。等输入时 dump 全映像,diff 磁盘文件:只有第一层(0x510)被解密,段B 还是密文 —— 它要到验证流程里才解密。
4. 验证器:16 字节、sum=1280、五重"毁钥式"反调试
主流程很快读完:读输入 → 剥 \r\n → cmp ecx, 0x10 —— 输入必须恰好 16 字节;然后 sum(16字节) == 1280(.tgt+0x10 的 0x500);首尾字节算 selector = (input[0]×φ) ^ (input[15]<<33) 查一张 8 项函数指针表 —— 8 个指针全指向同一个地址,烟雾弹。真验证器在 0x140004380。
验证器开头是五重反调试:PEB BeingDebugged、NtGlobalFlag、CONTEXT 的 DR0-DR3+DR7、ProcessDebugPort/ObjectHandle(还先扫 ntdll 找 syscall 指令防 hook)、rdtsc 双读差值 < 50M cycles。最阴的是检测命中不返回失败,而是把检测位图 xor 进密钥种子 —— 调试器在场,程序一切正常,只是密钥流悄悄变错,你在错误的解密结果里打转。这直接抬高了 oracle 的卫生要求。
对策(unicorn 全程模拟,15ms/次):
- 假 PEB 全零(BeingDebugged=0,NtGlobalFlag=0)
GetProcAddressstub 返回xor eax,eax; ret—— 里面没有B8 xx 0F 05的 syscall 模式,程序的扫描逻辑失败后直接跳过整个 syscall 直调检查- GetThreadContext stub 把 CONTEXT 清零返回 1
- rdtsc 两处 hook 注入小时间戳
5. 从黑盒差分到代数结构
oracle 在手,开始差分。先说两个自己给自己挖的坑(都是真事):
- 构造差分样本时
b'P'*7+b'QO'+b'P'*6少写了一个字节 —— 15 字节死于长度检查,我却当成"位置 7/8 有特殊检查"分析了一轮。差分样本先len()自检。 - unicorn 逐指令 python hook 太慢导致模拟超时,探针点没执行,
vals.get的默认值让我把"没跑到"误读成"值归零"。hook 必须限定地址范围(hook_add(begin, end)),大循环裸跑。
排除假象后,黑盒结论收敛得非常干净:
- 全部输入依赖收敛到栈上一个 u64:
(input[0]×φ) ^ (input[15]<<33)—— 就是 selector,它直接作为密码量参与运算; - 16 字节逐字节
-0x21后按 base-94 Horner 折叠成大数:N = Σ(bᵢ−0x21)·94^(15−i)(105 位)。数值验证三连中:47×94=0x1142、(0x1142+47)×94=0x6677e、再乘一步0x25a1186,逐位吻合; 94 = 0x5e是可打印 ASCII 的数量,sum=1280恰好等价于 base-94 数位和为 752。
6. 破题三连:5 次多项式 mod 2¹²⁷−39
验证器中段是一大坨软乘法:4 个 64×64 部分积拼 128×128 乘法,再跟"乘 78 / 加 39 / 条件减法"的软除法归约。看到 78 和 39 这种小 magic 数字,第一反射该是 Mersenne 型素数 —— 试一下:
p = 2^127 − 39
2^128 mod p = 2·2^127 mod p = 2·39 mod p = 78 ✓
"除以 78 加 39"就是 mod p 的高位折叠。题面里无处不在的 39/78 至此有了着落。
在 p 的语言下,整条链是:
V = h(N) = ((((N·(N+M1) + C1)·N + C2)·N + C3)·N + C4) mod p ← 5 次 Horner 多项式
m68, L = V>>64, V&M64 ← 双 lane 初值
20 轮 ARX(sᵢ = C[i%2] ^ T[i],T 为 20 qword 轮密钥表):
D(x,s): t = X·((x<<64|s) mod p) mod p
v = ror(t_hi,47) ^ s ^ t_lo
return ror(v,51) ^ S(v) ← S = 256B S-box
m68' = D(m68, sᵢ) ^ L
L' = D(m68', sᵢ^φ) ^ m68
之后 6 条 FNV/S-box 混合管线,与 6 个 64 位目标比对
6 个目标值 = 密钥流水线值 xor .tgt 里那 7 个常量中的 6 个(第 7 个 0x6666666666666666 是 padding;7 个目标对应题面"她尝试七次,前六次都失败")。
7. 反解:不走哈希的路
6 路比对里 5 路经过 FNV 单向压缩,逆不动。但 trace 发现 r10 路和 r12 路从 20 轮结束到比对原封未动 —— 它们就是 m68₂₀ 和 L₂₀,不经任何哈希。于是:
T1 = m68₂₀、T0 = L₂₀,两个已知目标正好凑齐双 lane 终态;- 每轮是三角结构,精确可逆:
m68ᵢ = L_{i+1} ^ D(m68_{i+1}, sᵢ^φ),Lᵢ = m68_{i+1} ^ D(m68ᵢ, sᵢ),逐轮倒退 20 步得 V; - V 定了,解 5 次方程
h(N) = V (mod p)—— p 是素数,Cantor-Zassenhaus 求全部根;过滤[0, 94¹⁶),根唯一; - base-94 反编码 N → 16 字节。
N = 0x174a5e1d4cf4f146e72f70092ac
→ kanxue@2o26o8!@#
sum 校验 1280 ✓,语义自洽(kanxue + @2o26 = 2026 的 leet + o8 = 08 月)。unicorn oracle eax=1,实机:
$ echo 'kanxue@2o26o8!@#' | ./kctf2026_CrackMe08.exe
Input:correct
一次提交,平台锁定。
8. 完整解题脚本(solve_final.py,自包含一键复现)
# -*- coding: utf-8 -*-
# KCTF 2026 第八题《亥子合辰·塔影迷楼》最终求解脚本(自包含,无外部依赖)
# 输入: 两个可逆路目标 T0/T1 -> 输出: kanxue@2o26o8!@#
# 方法: 逆20轮双lane ARX -> V=h(N) -> Cantor-Zassenhaus 解 5 次多项式 mod (2^127-39)
# -> base-94 反编码
import random
M64 = (1 << 64) - 1
P = (1 << 127) - 39 # 素数;程序里 ÷78/mod39 软归约的真身
GR = 0x9E3779B97F4A7C15 # 黄金比例 φ(题面 "Fibonacci")
M1 = 0x21B3BDE1ACF9ADFA471FB28461A53BB5
# ---- 运行时导出的固定常量(unicorn 快照,输入无关已验证)----
SBOX = bytes.fromhex(
'4ed835172d8e916bab6530f28adeda186f5cf09e608981629046fd613daa949f'
'88e22fc430b64f4fde66b80ade4067d76d6551e656e1e540232e61488b476f4c'
'9996650cfff1f87593c03fd152ae048c388dcbf88159a1104c80a10331e6195c'
'd8be465021e378d8f386d0c7fb358fac7974d4426b84f57e49d5266688b3784'
'1182b9230bd9500caedf4df297b70e3d98ac1baa1795b5a8da5927489c7a402b'
'a08db3e554db27324da17ac9c1ba512e1d1151e090e57da9e74ded1365352d4e'
'966536e03cd266e1bcb2d5776460932554c37cc9f00faddcd2e92fe3d0e71ac8'
'9dc183d87d5fa40efa98c65da9d7dc32fc092638af4e673fc81a5ad390563c349')
T = [0x454ab6cf8f0fe386, 0x715664a40e9f5f5, 0x9306305b57fba9a1, 0xe98c2c52e5ebb7db,
0xfb3fd083f20738c3, 0xccb6f8a75c3fde1b, 0x2131dd38af5d4e80, 0xa08b202d0096f0cd,
0xd910717024bb4898, 0x8e30f948a63f886, 0x3f28976422a1b7ea, 0x509fd4dac702028b,
0x4ed3c0879a56c01f, 0x5c804f2b54da01e2, 0xb04119cd47a9c4e0, 0xef0de2ad3c4820ed,
0x9e75101fa5267b0a, 0xa384473bb6e5fdd3, 0x2d2fb4022ad6e75c, 0xc5e6b576cdfde33a]
C = [0x553d3c5ef6ee11ff, 0x4aeee52b5738eb8f]
X = (0x4D8A9E9343D415CB << 64) | 0x83A0A488EC8523FE
CS = [0x191A3870AD3D9411_05F15A9C38AAE874,
0x40C190E211322CAA_2F01797348F02C97,
0x1DF732140B8A3EF7_58FD09BDE0058A37,
0x3F0CB8512C6F88E4_7E5F164FB478AAB5]
# ---- 验证器最终 6 个比对目标中两个可逆路的值 ----
T0_r12 = 0xb8fd0694401d3d03 # r12 路 = L_20
T1_r10 = 0x0c14db1c20dd97a8 # r10 路 = m68_20
def ror(x, n): return ((x >> n) | (x << (64 - n))) & M64
def S64(v): return int.from_bytes(bytes(SBOX[b] for b in v.to_bytes(8, 'little')), 'little')
def D(x, s):
t = (X * ((x << 64 | s) % P)) % P
v = ror(t >> 64, 47) ^ s ^ (t & M64)
return ror(v, 51) ^ S64(v)
def h(N):
w = (N * (N + M1)) % P
for i, c in enumerate(CS):
w = (w + c) % P
if i < 3: w = (w * N) % P
return w
def chain_forward(V):
m68, L = V >> 64, V & M64
for i in range(20):
s = C[i % 2] ^ T[i]
mn = D(m68, s) ^ L
L = D(mn, s ^ GR) ^ m68
m68 = mn
return m68, L
def invert_m68(m68_final, L_final):
m68, L = m68_final, L_final
for i in range(19, -1, -1):
s = C[i % 2] ^ T[i]
m68_prev = L ^ D(m68, s ^ GR)
L_prev = m68 ^ D(m68_prev, s)
m68, L = m68_prev, L_prev
return (m68 << 64) | L
# ---------------- 多项式求根 mod P(Cantor-Zassenhaus)----------------
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
while len(r) > 1 and r[-1] == 0: r.pop()
return r
def pmod(a, b):
a = a[:]; db = len(b) - 1; inv = pow(b[-1], -1, P)
for i in range(len(a) - 1, db - 1, -1):
c = a[i] * inv % P
if c:
for j in range(db + 1): a[i - db + j] = (a[i - db + j] - c * b[j]) % P
while len(a) > 1 and a[-1] == 0: a.pop()
return a
def pgcd(a, b):
while len(b) > 1 or b[0]:
a, b = b, pmod(a, b)
if len(a) > 1:
inv = pow(a[-1], -1, P); a = [c * inv % P for c in a]
return 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 psub(a, b):
n = max(len(a), len(b)); r = [0] * n
for i in range(n):
r[i] = ((a[i] if i < len(a) else 0) - (b[i] if i < len(b) else 0)) % P
while len(r) > 1 and r[-1] == 0: r.pop()
return r
def pdiv(a, b):
A = a[:]; db = len(b) - 1; inv = pow(b[-1], -1, P)
q = [0] * max(len(A) - db, 1)
for i in range(len(A) - 1, db - 1, -1):
c = A[i] * inv % P; q[i - db] = c
if c:
for j in range(db + 1): A[i - db + j] = (A[i - db + j] - c * b[j]) % P
while len(q) > 1 and q[-1] == 0: q.pop()
return q
def poly_roots(f, tries=100):
pder = lambda a: [(i * a[i]) % P for i in range(1, len(a))] or [0]
if len(f) < 2: return []
g = pgcd(f, pder(f))
hq = pdiv(f, g) if len(g) > 1 else f
xp = ppowmod([0, 1], P, hq)
hq = pgcd(hq, pmod(psub(xp, [0, 1]), hq))
roots, stack = [], [hq] if len(hq) > 1 else []
while stack:
g = stack.pop()
if len(g) == 2:
roots.append((-g[0]) * pow(g[1], -1, P) % P); continue
for _ in range(tries):
u = ppowmod([random.randrange(P), 1], (P - 1) // 2, g)
u[0] = (u[0] - 1) % P
w = pgcd(g, u)
if 1 < len(w) < len(g):
stack.append(w); stack.append(pdiv(g, w)); break
else: raise RuntimeError('CZ split failed')
return roots
def _poly_f(Vt):
f = [0, M1 % P, 1]
for i, c in enumerate(CS):
f[0] = (f[0] + c) % P
if i < 3: f = pmul(f, [0, 1])
f[0] = (f[0] - Vt) % P
return f
def serial_of(N):
out = []
for _ in range(16):
N, d = divmod(N, 94); out.append(d)
if N: raise ValueError('N too big')
return bytes(b + 0x21 for b in reversed(out))
def solve():
Vt = invert_m68(T1_r10, T0_r12)
print(f'V = h(N) = {Vt:#034x}')
roots = poly_roots(_poly_f(Vt))
cands = [n for n in roots if 0 <= n < 94 ** 16]
print(f'多项式全部根: {[hex(r) for r in roots]}')
for n in cands:
s = serial_of(n)
ok = chain_forward(h(n)) == (T1_r10, T0_r12) # 正向闭环自验
print(f'N={n:#x} -> {s.decode()} 正向复验: {"PASS" if ok else "FAIL"} sum={sum(s)}')
return [serial_of(n).decode() for n in cands]
if __name__ == '__main__':
print('FLAG:', solve()[0])
运行输出:
V = h(N) = 0x4be831b0ad3a2d361489375bba3fb8de
多项式全部根: ['0x174a5e1d4cf4f146e72f70092ac']
N=0x174a5e1d4cf4f146e72f70092ac -> kanxue@2o26o8!@# 正向复验: PASS sum=1280
FLAG: kanxue@2o26o8!@#
(完整工程产物同目录:unicorn oracle oracle.py、中段指令级解释器 mid_stage.py、逆推器 invert_mid.py、段B 解密器 peel.py、RPM dumper rpm_dump.py、提交客户端 submit.py。)
感谢出题战队心学。这题把 SMC 自校验洋葱、毁钥式反调试、Mersenne 型模算术、ARX 轮函数和 base-94 编码缝成一个整体,每层都对应题面一句话——"十六层加密"是 SMC 分层、"密钥按顺序叠加"是哈希链、"Fibonacci"是 φ 常数、"尝试七次"是 7 个目标值、"十二次失败第十三个团队"是模数 2¹²⁷−39 里的 39 与循环叙事的呼应。出题人在叙事和技术之间做了一比一映射,是攻防赛里少见完成度。快的话 10 分钟能破是因为构件全是反射弧级的标准件——而这恰恰是对题目工程质量的另一种夸奖。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)