首页
社区
课程
招聘
[原创]第八题:亥子合辰·塔影迷楼 WP
发表于: 3天前 35

[原创]第八题:亥子合辰·塔影迷楼 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 的主校验可以拆成以下阶段:

  1. 检查 16 个输入字节和是否为 0x500
  2. 对 stage2 自身 [RVA 0x1d10, size 0x4962] 做两路 FNV。
  3. 采集反调试状态,作为 PRNG 种子扰动。
  4. 生成 86 个 64-bit qword,用作有限域多项式系数、S-box、Feistel 轮密钥和目标掩码。
  5. 将输入按 base94 编码为 p = 2^127 - 39 上的域元素。
  6. 对该域元素做五次 Horner 多项式。
  7. 两路 20 轮可逆 Feistel/S-box 变换。
  8. .tgt 中经过 PRNG mask 处理后的目标值比较。
  9. 使用 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!@#

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

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