首页
社区
课程
招聘
[原创] KCTF 2026 第八题《亥子合辰·塔影迷楼》题解
发表于: 2026-8-22 03:29 47

[原创] 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)
  • GetProcAddress stub 返回 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)),大循环裸跑。

排除假象后,黑盒结论收敛得非常干净:

  1. 全部输入依赖收敛到栈上一个 u64:(input[0]×φ) ^ (input[15]<<33) —— 就是 selector,它直接作为密码量参与运算;
  2. 16 字节逐字节 -0x21 后按 base-94 Horner 折叠成大数:N = Σ(bᵢ−0x21)·94^(15−i)(105 位)。数值验证三连中:47×94=0x1142、(0x1142+47)×94=0x6677e、再乘一步 0x25a1186,逐位吻合;
  3. 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₂₀,不经任何哈希。于是:

  1. T1 = m68₂₀、T0 = L₂₀,两个已知目标正好凑齐双 lane 终态;
  2. 每轮是三角结构,精确可逆:m68ᵢ = L_{i+1} ^ D(m68_{i+1}, sᵢ^φ),Lᵢ = m68_{i+1} ^ D(m68ᵢ, sᵢ),逐轮倒退 20 步得 V;
  3. V 定了,解 5 次方程 h(N) = V (mod p) —— p 是素数,Cantor-Zassenhaus 求全部根;过滤 [0, 94¹⁶),根唯一;
  4. 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期)

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