-
-
[原创]第八题:亥子合辰·塔影迷楼 WP
-
发表于: 3天前 35
-
0. 结论
最终输入:
kanxue@2o26o8!@#
原始样本验证结果:
Input:correct
该字符串满足:
len = 16
sum = 0x500
1. 样本信息
附件为 RAR5 压缩包,解压后得到一个 Windows x64 console PE。
| 项目 | 内容 |
|---|---|
| 附件 | kctf2026_CrackMe08.rar |
| RAR SHA256 | 65e4ae3b9702e219711d6e8c630ed1b355174ddc1f9629aa00f8890f81896a3b |
| 样本 | kctf2026_CrackMe08.exe |
| EXE SHA256 | b0dae7a21b059805a35fc5490f84a607d00afb28ad6b5b9f9ebc31574c382ea1 |
| 文件类型 | PE32+ executable (console) x86-64, for MS Windows |
| ImageBase | 0x140000000 |
| EntryPoint | 0x1400012c0 |
节区如下:
.text RVA 0x1000 VSZ 0x5672 RAW 0x0400 RSZ 0x5800
.rdata RVA 0x7000 VSZ 0x06e2 RAW 0x5c00 RSZ 0x0800
.data RVA 0x8000 VSZ 0x0024 RAW 0x6400 RSZ 0x0200
.pdata RVA 0x9000 VSZ 0x01bc RAW 0x6600 RSZ 0x0200
.tgt RVA 0xa000 VSZ 0x0070 RAW 0x6800 RSZ 0x0200
.tgt 是关键数据区:
0xa000: 6249000000000000 101d000000000000
0xa010: 0005000000000000 0000000000000000
0xa020: 1005000000000000 0018000000000000
0xa030: 023eb96d9cef070e 2e9d23692feed162
0xa040: 430220a36458b6a1 f25f12b79967f6f8
0xa050: e647c6b0fa7aeeb5 860afe493335c56e
0xa060: 31ba95eed0220db1 6666666666666666
其中:
.tgt + 0x00: stage2 size = 0x4962
.tgt + 0x08: stage2 RVA = 0x1d10
.tgt + 0x10: input byte sum target = 0x500
.tgt + 0x18: PRNG extra seed = 0
.tgt + 0x20: stage1 size = 0x510
.tgt + 0x28: stage1 RVA = 0x1800
.tgt + 0x30 后面是最终比较常量
2. 第一层自修改:XOR 0x5A
入口 0x1400012c0 会先从 .tgt+0x20 和 .tgt+0x28 取出一段代码的位置和长度:
RVA = 0x1800
size = 0x510
随后对该区域逐字节异或 0x5A,恢复 stage1 代码。
逻辑可以表示为:
for i in range(0x510):
image[rva_to_off(0x1800) + i] ^= 0x5A
恢复后,0x1400018d0 成为有效函数。该函数继续负责第二层代码解密。
3. 第二层自解密:FNV 派生流密钥
stage1 的 0x1400018d0 会先对第一层解密出的 [RVA 0x1800, size 0x510] 做两路 FNV-1a 风格哈希。
常量:
FNV prime = 0x100000001b3
seed1 = 0xcbf29ce484222325
seed2 = 0x9e3779b97f4a7c15
哈希结果:
h1 = 0x73fb4f498aab364f
h2 = 0xb157c7e044b966df
之后根据 .tgt+0x00 和 .tgt+0x08 解密第二段:
RVA = 0x1d10
size = 0x4962
密钥流公式:
MASK = (1 << 64) - 1
PHI = 0x9e3779b97f4a7c15
MIX = 0xff51afd7ed558ccd
for i in range(0x4962):
x = ((i * PHI) & MASK) ^ h2 ^ h1
y = ((x ^ (x >> 33)) * MIX) & MASK
key = (y >> 56) & 0xff
image[rva_to_off(0x1d10) + i] ^= key
解密后得到完整 stage2。stage2 中:
0x140001d10: 包装校验函数
0x140004380: 真正核心校验函数
0x140007100 处存在一个 8 项跳表,但 8 项全部指向 0x140004380,所以表面上的动态选择不会改变最终校验函数。
4. 主流程与输入约束
主流程读取输入后会去掉末尾 \n / \r,要求长度必须为 16。
关键逻辑:
0x140001531: cmp ecx, 0x10
0x1400015d2: call 0x140001070
0x1400015df: call 0x1400018d0
如果长度不是 16,直接输出:
fault
核心函数 0x140004380 入口处还有一个硬约束:
sum(input[0..15]) == 0x500
包装函数 0x140001d10 中也有一层字节和检查,但比较的是 0x500 ^ 1 = 0x501。对真正满足核心条件 0x500 的输入,包装层不会抹掉核心返回值。
最终 flag:
s = b"kanxue@2o26o8!@#"
len(s) == 16
sum(s) == 0x500
5. 核心校验整体结构
0x140004380 的主校验可以拆成以下阶段:
- 检查 16 个输入字节和是否为
0x500。 - 对 stage2 自身
[RVA 0x1d10, size 0x4962]做两路 FNV。 - 采集反调试状态,作为 PRNG 种子扰动。
- 生成 86 个 64-bit qword,用作有限域多项式系数、S-box、Feistel 轮密钥和目标掩码。
- 将输入按 base94 编码为
p = 2^127 - 39上的域元素。 - 对该域元素做五次 Horner 多项式。
- 两路 20 轮可逆 Feistel/S-box 变换。
- 与
.tgt中经过 PRNG mask 处理后的目标值比较。 - 使用 FNV4 / FNV6 做自校验,防止错误路径撞上部分比较。
6. 核心 PRNG 与反调试
0x140004380 会对已解密 stage2 区域重新计算两路 FNV:
h1 = FNV(seed=0xcbf29ce484222325, stage2[0x1d10:0x6672])
= 0x553d3c5ef6ee11ff
h2 = FNV(seed=0x9e3779b97f4a7c15, stage2[0x1d10:0x6672])
= 0x4aeee52b5738eb8f
反调试 flags:
| bit | 来源 |
|---|---|
0x01 |
PEB.BeingDebugged |
0x02 |
NtGlobalFlag 中 0x70 相关位 |
0x04 |
GetThreadContext 检查 DR0/DR1/DR2/DR3/DR7 |
0x08 |
NtQueryInformationProcess(ProcessDebugPort / ProcessDebugObjectHandle) |
0x20 |
RDTSC 检查,核心区域耗时超过 50,000,000 cycles |
正常无调试快速运行时:
flags = 0
PRNG 初始化:
a = h1 ^ flags
b = h2 ^ 0xcbf29ce484222325
PRNG 是 splitmix64 变体:
MASK = (1 << 64) - 1
PHI = 0x9e3779b97f4a7c15
FNV_SEED = 0xcbf29ce484222325
MIX1 = 0xbf58476d1ce4e5b9
MIX2 = 0x94d049bb133111eb
ADD1 = 0x03c6ef372fe94f82a
ADD2 = 0x05d8fc1269c2f61ce
def mix64(x):
x &= MASK
x = ((x ^ (x >> 30)) * MIX1) & MASK
x = ((x ^ (x >> 27)) * MIX2) & MASK
return (x ^ (x >> 31)) & MASK
def gen_qwords(h1, h2, flags=0):
a = h1 ^ flags
b = h2 ^ FNV_SEED
q = []
for _ in range(43):
o1 = mix64((a + ADD1) & MASK)
o2_seed = (b + ADD2) & MASK
b = ((b + MIX1) & MASK) ^ o1
o2 = mix64(o2_seed)
a = ((a + PHI) & MASK) ^ o2
q += [o1, o2]
return q
生成的 q 布局:
| qword 范围 | 用途 |
|---|---|
q[0]..q[9] |
五个 p=2^127-39 上的系数,每两个 qword 组成一个 128-bit 小端数 |
q[10], q[11] |
Feistel 轮函数中的域乘法常量 B |
q[12].. 部分 |
交织生成 256 字节 S-box |
q[44]..q[63] |
第一组 20 轮密钥 |
q[64]..q[83] |
第二组 20 轮密钥 |
q[84], q[85] |
末尾目标值 mask |
7. 输入编码:base94 到有限域
核心将 16 字节输入当成 base94 数字串:
P = (1 << 127) - 39
S = 0
for ch in input_bytes:
S = (S * 94 + (ch - 0x21)) % P
也就是每个字符取值区间为 printable base94:
'!' -> 0
'"' -> 1
...
'~' -> 93
因为:
94^16 < 2^127 - 39
所以 16 字节 base94 编码在该域上不会回绕,可以唯一反解。
8. 五次 Horner 多项式
设 PRNG 生成的五个域元素为 C0..C4,输入编码后的域元素为 S。
核心计算:
Y = S + C0
Y = C1 + S * Y
Y = C2 + S * Y
Y = C3 + S * Y
Y = C4 + S * Y
Y %= P
等价于:
Y = S^5 + C0*S^4 + C1*S^3 + C2*S^2 + C3*S + C4 (mod P)
这里的模数:
P = 2^127 - 39
9. 两路 20 轮 Feistel/S-box
多项式结果 Y 被拆成两个 64-bit 小端 qword,进入两路 20 轮可逆变换。
轮函数结构:
def F(k, x):
# k 是 64-bit round key
# x 是当前 64-bit 半边
# B 是 PRNG 生成的 127-bit 域乘法常量
lo, hi = split((B * (k + (x << 64))) % P)
t = rol64(hi, 17) ^ lo ^ k
return rol64(t, 13) ^ sbox_sub64(t)
Feistel 加密方向:
for i in range(20):
k = keys[i] ^ seed_selector(i)
T = F(k, R) ^ L
L = F(k ^ 0x9e3779b97f4a7c15, T) ^ R
R = T
由于每轮是异或结构,因此可逆。逆一轮:
R_old = F(k ^ PHI, R) ^ L
L_old = F(k, R_old) ^ R
两路输出的目标值来自 .tgt,但会先与 q[84] / q[85] 异或。正常态反推出的关键目标如下:
第一组 Feistel 输出目标:
0xb8fd0694401d3d03
0x0c14db1c20dd97a8
第二组 Feistel 输出目标:
0xaca5240a53fb78a6
0x9a2789b6de31c2dc
FNV4 校验目标:
0xbc1e5ebe1e4ec0d4
FNV6 校验目标:
0x6cd601b3049aa32c
逆第一组和第二组 20 轮,二者必须得到同一个 Y。正常态 flags=0 时唯一满足:
Y = 0x4be831b0ad3a2d361489375bba3fb8de
对应检查:
y_equal = True
hash4_ok = True
其它反调试 flags 会导致两路逆回的 Y 不一致,或者 FNV4 不匹配,因此可以排除。
10. 解五次多项式并还原输入
已知目标:
Y = 0x4be831b0ad3a2d361489375bba3fb8de
要求解:
S^5 + C0*S^4 + C1*S^3 + C2*S^2 + C3*S + C4 - Y = 0 (mod P)
在 GF(P) 上分解该多项式,得到一个一次因子和两个二次因子。唯一一次因子的根为:
S = 0x00000174a5e1d4cf4f146e72f70092ac
将 S 按 16 位 base94 反解:
def decode_base94(n):
digits = []
for _ in range(16):
digits.append(n % 94)
n //= 94
if n != 0:
return None
digits.reverse()
return bytes(d + 0x21 for d in digits)
得到:
kanxue@2o26o8!@#
验证:
len = 16
sum = 0x500
poly_ok = True
c1_ok = True
c2_ok = True
hash6 = 0x6cd601b3049aa32c
hash6_ok = True
11. 精简求解脚本
下面是精简后的核心解法,用已还原常量直接反解。运行后输出最终输入。
#!/usr/bin/env python3
0xb8fd0694401d3d03,
0x0c14db1c20dd97a8,
)
def ror(x, n):
return ((x >> n) | ((x << (64 - n)) & MASK)) & MASK
def rol(x, n):
return (((x << n) & MASK) | (x >> (64 - n))) & MASK
def split(x):
x %= P
return x & MASK, x >> 64
def pair_to_int(pair):
lo, hi = pair
return ((lo & MASK) + ((hi & MASK) << 64)) % P
def mul_pair(a, b):
return split((pair_to_int(a) * pair_to_int(b)) % P)
def subst64(t):
out = 0
for shift in (56, 48, 40, 32, 24, 16, 8, 0):
out = ((out << 8) | SBOX[(t >> shift) & 0xff]) & MASK
return out
def round_f(k, x):
lo, hi = mul_pair(M, split(((k & MASK) + ((x & MASK) << 64)) % P))
t = (rol(hi, 17) ^ lo ^ k) & MASK
return (rol(t, 13) ^ subst64(t)) & MASK
def inv_phase1(out_pair):
L, R = out_pair
seed0, seed1 = CSEL
for i in range(19, -1, -1):
# 第一组变换中,奇偶轮分别选 seed0 / seed1
k = (KEYS1[i] ^ (seed0 if (i & 1) else seed1)) & MASK
R_old = (round_f(k ^ GOLDEN, R) ^ L) & MASK
L_old = (round_f(k, R_old) ^ R) & MASK
L, R = L_old, R_old
return L, R
def decode_base94(n):
digits = []
for _ in range(16):
digits.append(n % 94)
n //= 94
if n != 0:
return None
digits.reverse()
return bytes(d + 33 for d in digits)
def main():
target_y = pair_to_int(inv_phase1(EXPECTED1))
coeff = [pair_to_int(x) for x in C]
x = sp.symbols("x")
poly = sp.Poly(
x**5
+ coeff[0] * x**4
+ coeff[1] * x**3
+ coeff[2] * x**2
+ coeff[3] * x
+ coeff[4]
- target_y,
x,
modulus=P,
)
candidates = []
for factor, _ in sp.factor_list(poly, modulus=P)[1]:
if factor.degree() != 1:
continue
a, b = [int(v) % P for v in factor.all_coeffs()]
root = (-b * pow(a, -1, P)) % P
s = decode_base94(root)
if s is None:
continue
if len(s) == 16 and sum(s) == 0x500:
candidates.append(s)
for s in candidates:
print(s.decode("latin1"))
if __name__ == "__main__":
main()
输出:
kanxue@2o26o8!@#
12. 验证
12.1 静态脚本验证
候选:
kanxue@2o26o8!@#
检查结果:
sum=0x500
x=0x00000174a5e1d4cf4f146e72f70092ac
poly_ok=True
c1_ok=True
c2_ok=True
hash6=0x6cd601b3049aa32c
hash6_ok=True
12.2 Unicorn 核心函数验证
直接对 0x140004380 做 harness,传入候选:
kanxue@2o26o8!@#
返回:
ret 0x1
最终 6 个比较值全部命中:
0xb8fd0694401d3d03
0x0c14db1c20dd97a8
0xaca5240a53fb78a6
0x9a2789b6de31c2dc
0x6cd601b3049aa32c
0xbc1e5ebe1e4ec0d4
12.3 原始样本验证
运行原始样本,输入:
kanxue@2o26o8!@#
输出:
Input:correct
退出码为 0。
13. Flag
kanxue@2o26o8!@#