-
-
[原创]亥子合辰·塔影迷楼 Writeup
-
发表于: 2天前 50
-
亥子合辰·塔影迷楼 Writeup
目标:
kctf2026_CrackMe08.exe(x64 PE,27136 字节,无 CRT)答案(Serial):
kanxue@2o26o8!@#真机验证:
Input:correct,退出码 0。
目录
- 结论速览
- 静态结构侦察
- 两层自解密
- 反调试与常量生成链
- 校验算法完整还原
- Unicorn 仿真取常量
- 求逆:双 Feistel 逆推 + Cantor–Zassenhaus 求根
- 常量表(实测运行时值)
- 完整复现脚本
- 踩坑记录
1. 结论速览
程序对输入做四层约束,全部满足才输出 correct:
| # | 约束 | 说明 |
|---|---|---|
| 1 | len(input) == 16 |
去掉尾部\r\n 后长度必须为 16 |
| 2 | sum(bytes) == 0x500 |
字节和恰好 1280;sub_140004380 判 == 0x500,sub_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 = 0x73fb4f498aab364f,h2 = 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
生成物:模数乘子 M、H1/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 & M64、hi = 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 |
字节和门在 0x1400043FC:cmp 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 栈帧。
要点:
- 镜像从磁盘自己解密(
decrypt.py),不依赖手改过的 IDB。 - 伪造 TEB / PEB,
GS基址通过写 MSR0xC0000101设置。 - stub 掉 8 个 KERNEL32 导入:
GetModuleHandleA(0)→ 镜像基址;GetModuleHandleA("ntdll.dll")→ 0(让它走不到直 syscall 分支)GetCurrentThread→-2,GetCurrentProcess→-1GetThreadContext→ 把rdx+0x48..0x78(DR0–DR7)清零并返回 1
- dump 点
0x140004CF7(base-94 Horner 循环里第一次模乘),此时全部常量已就位。 - 探针输入必须自带合法字节和:用
b"P" * 16(16 × 80 = 1280 = 0x500),否则跑不到 dump 点。
磁盘解密结果自检
decrypt.py 输出与 IDB dump 逐字节比对,差异只有 5 段节尾填充(RVA 0x6800..0x7040、0x7800..0x8000、0x8200..0x9000、0x9200..0xA000、0xA200..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:
- 先算
g = gcd(f, x^p - x),只保留一次因子(即 GF(p) 中的根); - 再随机取
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. 设计点评
这题的防护层次很清楚,且每一层都真的挡人:
- 两层依赖式自解密 —— 静态 dump 拿不到代码,且第一层是第二层的密钥;
- 常量全部由代码摘要派生 —— 任何内联补丁(含 INT3 断点)都会让常量全废,等于"打补丁 = 自动上锁";
- 反调试位进种子 —— 调试器附加不是"报错退出",而是静默算出错误常量,最难排查的失败模式;
- 诱饵 —— 诱饵 FNV 函数、8 项全同的分发表、
.tgt+0x38那个从不被引用的常量; - 密码学核心 —— 五次多项式 + 双 20 轮 Feistel,看着像单向,实则因半轮结构(非线性只吃不变的那一半)严格可逆,S-box 故意做成非置换来误导。
对应的破法也就三句话:别打补丁(仿真取常量)、别带调试器(Unicorn + 假 PEB)、别猜结构(读汇编 + 代数求逆)。
最终答案
kanxue@2o26o8!@#
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。