-
-
[原创]KCTF 2026 第八题 "塔影迷楼" WriteUp
-
发表于: 2026-8-21 14:15 26
-
1. 程序入口
IDA 打开后函数很少,入口 start 的 F5 伪代码能直接看出基本交互流程:
qmemcpy(v19, "Input:", 6);
sub_1400017B0(NtWriteFile, ..., v19, 6, ...);
sub_1400017B0(NtReadFile, ..., v23, 63, ...);
// 去掉换行后检查长度
if ( v12 != 16 )
{
qmemcpy(v19, "fault\n", 6);
sub_140001000(1);
}
sub_140001070(v23);
if ( ((int (__fastcall *)(_BYTE *))loc_1400018D0)(v23) == 1 )
puts("correct");
else
puts("fault");
关键点:
- 输入长度必须是 16。
sub_140001070不是最终校验函数,只是额外状态扰动/反分析逻辑。- 真正校验在运行时解密出来的
loc_1400018D0,后面被 IDA 建成sub_140001D10。
2. 第一层代码解密
入口最前面有一段自修改代码:
ModuleHandleA = GetModuleHandleA(0);
v2 = (char *)ModuleHandleA + qword_14000A028;
VirtualProtect(v2, dwSize, 0x40, &flOldProtect);
for (i = 0; i < dwSize; i++)
v2[i] ^= 0x5A;
FlushInstructionCache(...);
VirtualProtect(v2, dwSize, flOldProtect, &flOldProtect);
.tgt 里给出解密范围:
qword_14000A028 = 0x1800
dwSize = 0x510
所以第一层明文代码是:
stage1 = image[0x1800:0x1800+0x510]
stage1 = bytes(b ^ 0x5A for b in stage1)
解完后 0x140001D10 就能正常反汇编/反编译。
3. 第二层 dispatcher
sub_140001D10 的 F5 伪代码很短:
v2 = funcs_140001D50[(21 * *(_BYTE *)a1) & 7](a1);
// 16 字节求和
v4 = sum(input[0..15]);
if ( (v4 & 0xFFFFFF) != (dword_14000A010 ^ 1) )
v3 = v2;
return v3 & 1;
这里 dword_14000A010 ^ 1 == 0x501,所以最终正确时要求:
sum(input[0..15]) != 0x501
真实核心分支由输入首字节决定:
(21 * input[0]) & 7
正确答案首字节是 k,进入的分支是 sub_140004380。
4. 核心函数的入口约束
sub_140004380 开头先做一次 16 字节求和:
v1 = _mm_add_epi32(... input[0..15] ...);
v2 = _mm_add_epi32(v1, _mm_srli_si128(v1, 8));
if ( (_mm_cvtsi128_si32(_mm_add_epi32(v2, _mm_srli_si128(v2, 4))) & 0xFFFFFF) != dword_14000A010 )
return 0;
dword_14000A010 == 0x500,所以核心分支要求:
sum(input[0..15]) == 0x500
最终答案 kanxue@2o26o8!@# 的 ASCII 和正好是 1280 == 0x500。
5. 反调试状态 v11
核心函数中间生成一组运行时参数,种子受反调试状态 v11 影响:
v10 = NtCurrentPeb();
v11 = 0;
if ( v10->BeingDebugged )
v11 |= 1;
if ( ((__int64)v10->ApiSetMap & 0x70) == 0x70 )
v11 |= 2;
if ( GetThreadContext(...) && (Dr0 || Dr1 || Dr2 || Dr3 || Dr7) )
v11 |= 4;
// NtQueryInformationProcess 相关检测
...
v20 = __rdtsc();
for (...) j += image_byte;
v24 = __rdtsc();
if ( v24 - v20 > 0x2FAF080 )
v11 |= 0x20;
这里有一个坑:如果用 Unicorn 单步模拟,rdtsc 差值很容易触发 v11 |= 0x20,会得到错误参数表。
正常运行环境下没有调试标记,也不会触发 timing 位,预期状态是:
v11 = 0
这是后面能否反推出正确注册码的关键。
6. 参数表生成
F5 里这段循环生成 43 对 64 位数:
v26 = v11 ^ v7 ^ (0x9E3779B97F4A7C15 * qword_14000A018);
v27 = v6 ^ (qword_14000A018 << 33) ^ 0xCBF29CE484222325;
for (i = 0; i < 43; i++)
{
v29 = v26 - 0x61C8864680B583EB;
v30 = v27 - 0x40A7B892E31B1A47;
v31 = splitmix64(v29 - 0x61C8864680B583EB);
v35 = splitmix64(v30 - 0x61C8864680B583EB);
table[2*i] = v31;
table[2*i + 1] = v35;
v27 = v31 ^ v30;
v26 = v35 ^ v29;
}
其中 splitmix64 是标准结构:
def splitmix64(x):
x &= 0xffffffffffffffff
x = (0xbf58476d1ce4e5b9 * (x ^ (x >> 30))) & 0xffffffffffffffff
x = (0x94d049bb133111eb * (x ^ (x >> 27))) & 0xffffffffffffffff
return (x ^ (x >> 31)) & 0xffffffffffffffff
参数表用途:
table[0..9] -> 5 个 128-bit 多项式系数
table[10..11] -> 128-bit 模乘常量 mul
table[12..43] -> 256 字节 S-box
table[44..63] -> 第一组 20 轮 key
table[64..83] -> 第二组 20 轮 key
table[84..85] -> 用于 XOR 解目标常量
所有 128-bit 运算都在素数域:
P = 2^127 - 39
F5 里反复出现的:
0x7FFFFFFFFFFFFFFF : 0xFFFFFFFFFFFFFFD9
就是 2^127 - 39 的高低 64 位。
7. 输入编码为 base94 大整数
核心函数把 16 字节输入按 printable 字符集转成一个大整数:
for (i = 0; i < 16; i++)
{
N = N * 94 + input[i] - 33;
N %= P;
}
对应 Python:
P = (1 << 127) - 39
def base94(s):
n = 0
for b in s:
n = (n * 94 + b - 33) % P
return n
8. 多项式阶段
F5 里 v409..v413 是 5 个 128-bit 系数。整理后得到:
x = N
x = (x + c0) % P
x = (x * N + c1) % P
x = (x * N + c2) % P
x = (x * N + c3) % P
x = (x * N + c4) % P
这一步输出的 x 会作为后面两组 20 轮变换的初始状态。
9. 两组 20 轮可逆变换
每组 20 轮本质是 Feistel 结构,因此可以反推。
轮函数抽象如下:
def ror(x, n):
return ((x >> n) | (x << (64 - n))) & 0xffffffffffffffff
def sbox64(sb, x):
out = 0
for sh in (56, 48, 40, 32, 24, 16, 8, 0):
out = (out << 8) | sb[(x >> sh) & 0xff]
return out
def F(xhi, key, mul, sb):
t = (((xhi & MASK) << 64) | (key & MASK)) % P
t = (t * mul) % P
lo = t & MASK
hi = t >> 64
a = lo ^ key ^ ror(hi, 47)
return sbox64(sb, a) ^ ror(a, 51)
逆 20 轮:
def invert_rounds(target_l, target_r, keys):
l, r = target_l, target_r
for i in range(19, -1, -1):
key = keys[i]
old_r = l ^ F(r, key ^ 0x9E3779B97F4A7C15, mul, sb)
old_l = r ^ F(old_r, key, mul, sb)
l, r = old_l & MASK, old_r & MASK
return l, r
v11 = 0 时,两组目标反推到同一个多项式结果:
x = 0x4be831b0ad3a2d361489375bba3fb8de
如果误用 Unicorn 慢速环境里的 v11 = 0x20,两组目标反推结果不一致,这是定位错误的重要依据。
10. 解多项式根
现在只剩:
poly(N) == 0x4be831b0ad3a2d361489375bba3fb8de (mod 2^127 - 39)
用 Sympy 在有限域中分解:
x = sp.symbols("x")
poly = x + c0
for c in [c1, c2, c3, c4]:
poly = poly * x + c
poly = sp.Poly(poly - target, x, modulus=P)
print(sp.factor_list(poly, modulus=P))
分解结果里有一个一次因子:
x - 29524214495202137505193489633964
因此:
N = 29524214495202137505193489633964
= 0x174a5e1d4cf4f146e72f70092ac
把 N 反转成 16 位 base94 字符:
def digits_from_n(n):
ds = []
for _ in range(16):
ds.append(n % 94)
n //= 94
return bytes(d + 33 for d in reversed(ds))
得到:
kanxue@2o26o8!@#
11. 验证
最终用原程序直接验证:
import subprocess
s = b"kanxue@2o26o8!@#\n"
p = subprocess.run(
["kctf2026_CrackMe08.exe"],
input=s,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=3,
)
print(p.stdout)
输出:
Input:correct
12. 总结
这题的主要难点不在长度判断,而在三层结构:
- 入口自解密,先把
0x140001800..0x140001D10还原出来。 sub_140001D10是 dispatcher,会按输入首字节选择真实分支。sub_140004380内部用反调试状态生成参数表,再把输入当成 base94 大整数,经过有限域多项式和两组可逆 Feistel 轮校验。
最容易踩坑的是动态模拟环境。Unicorn 里 rdtsc 会触发 timing 位 v11 |= 0x20,这会生成另一套参数表,导致后续目标无法一致反推。按正常运行环境使用 v11 = 0 后,两组 20 轮校验能反推出同一个多项式目标,随后有限域求根即可得到注册码。