首页
社区
课程
招聘
[原创]KCTF 2026 第八题 "塔影迷楼" WriteUp
发表于: 2026-8-21 14:15 26

[原创]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. 总结

这题的主要难点不在长度判断,而在三层结构:

  1. 入口自解密,先把 0x140001800..0x140001D10 还原出来。
  2. sub_140001D10 是 dispatcher,会按输入首字节选择真实分支。
  3. sub_140004380 内部用反调试状态生成参数表,再把输入当成 base94 大整数,经过有限域多项式和两组可逆 Feistel 轮校验。

最容易踩坑的是动态模拟环境。Unicorn 里 rdtsc 会触发 timing 位 v11 |= 0x20,这会生成另一套参数表,导致后续目标无法一致反推。按正常运行环境使用 v11 = 0 后,两组 20 轮校验能反推出同一个多项式目标,随后有限域求根即可得到注册码。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

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