首页
社区
课程
招聘
[原创]亥子合辰·塔影迷楼 Writeup
发表于: 2天前 50

[原创]亥子合辰·塔影迷楼 Writeup

2天前
50

亥子合辰·塔影迷楼 Writeup

目标:kctf2026_CrackMe08.exe(x64 PE,27136 字节,无 CRT)

答案(Serial):kanxue@2o26o8!@#

真机验证:Input:correct,退出码 0。


目录

  1. 结论速览
  2. 静态结构侦察
  3. 两层自解密
  4. 反调试与常量生成链
  5. 校验算法完整还原
  6. Unicorn 仿真取常量
  7. 求逆:双 Feistel 逆推 + Cantor–Zassenhaus 求根
  8. 常量表(实测运行时值)
  9. 完整复现脚本
  10. 踩坑记录

1. 结论速览

程序对输入做四层约束,全部满足才输出 correct

# 约束 说明
1 len(input) == 16 去掉尾部\r\n 后长度必须为 16
2 sum(bytes) == 0x500 字节和恰好 1280;sub_140004380== 0x500sub_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 链:

h1, h2 = 0xCBF29CE484222325, 0x9E3779B97F4A7C15
for b in region_layer1:            # RVA 0x1800..0x1D10
    h1 = (0x100000001B3 * (b ^ h1)) & M64
    h2 = (0x100000001B3 * (b ^ h2)) & M64

实测:h1 = 0x73fb4f498aab364fh2 = 0xb157c7e044b966df

再对 RVA 0x1D10 .. 0x6672(长 0x4962)逐字节异或:

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) 直 syscallNtQueryInformationProcess
ProcessDebugObjectHandle (30) 直 syscall 同上
rdtsc 时序 单步/断点会拉长间隔

只要任一位被置起,标志字变化 → splitmix64 种子变化 → 全部常量变化 → 永远算不出正确答案。
所以本题不能带调试器跑,只能靠仿真(Unicorn)或纯静态推算。

4.3 splitmix64 → CONTEXT → 常量

标志字与代码摘要混合成 splitmix64 的种子,逐步填充一个 CONTEXT 结构(复用系统结构体当常量仓库,混淆意图):

step  = 0x9E3779B97F4A7C15   (即 -0x61C8864680B583EB)
mix1  = 0xBF58476D1CE4E5B9
mix2  = 0x94D049BB133111EB

生成物:模数乘子 MH1/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 累积:

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):

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 & M64hi = u >> 64,分别喂给两条独立的 20 轮 Feistel(密钥组 / 奇偶轮常量不同):

  • 网络 A:keys1,偶轮异或 H2,奇轮异或 H1
  • 网络 B:keys2,偶轮异或 H1,奇轮异或 H2

单个半轮:

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

字节和门在 0x1400043FCcmp 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 / PEBGS 基址通过写 MSR 0xC0000101 设置。
  3. stub 掉 8 个 KERNEL32 导入
    • GetModuleHandleA(0) → 镜像基址;GetModuleHandleA("ntdll.dll") → 0(让它走不到直 syscall 分支)
    • GetCurrentThread-2GetCurrentProcess-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..0x70400x7800..0x80000x8200..0x90000x9200..0xA0000xA200..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 是否双射无关。

逆函数:

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 重建运行时解密镜像

"""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 仿真环境

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 全部常量

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 复刻(与仿真逐位一致)

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 求根

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 — 仿真验证答案

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 — 真机验证

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:在自校验区间里打补丁(最致命)

为了绕过字节和门方便探针,我一开始写了:

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!@#

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

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