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

[原创] 第八题:亥子合辰·塔影迷楼 wp by lzq2000

3天前
47

题目文件就一个 kctf2026_CrackMe08.exe,x64 PE,体积不大。跑起来会打 Input:,从 stdin 读一行。长度对了才往下走,对了输出 correct,错了 fault。flag 没有 flag{} 这种壳,16 个可打印字符。
具体解题思路及脚本如下:

1、 入口那两层加密

程序几乎不走正常导入表,入口 start0x1400012C0.text 里一大段是加密的。

第一层好认:RVA 0x1800,长度 0x510,单字节 XOR 0x5A。解开之后是真正的读输入、校验长度、调后面那坨逻辑。

第二层在 0x140001D10,长度 0x4962。keystream 跟已经解开的 stub 的 FNV-1a / golden-FNV 有关:

x = (i * 0x9E3779B97F4A7C15) ^ hgold ^ hfnv
x ^= x >> 33
x *= 0xFF51AFD7ED558CCD
buf[i] ^= (x >> 56) & 0xFF

静态把两层都解开之后,跳表最终进 0x140004380。这才是校验本体。前面 sub_140001070 那个 FNV+ROR64 对输入做了个哈希,对不上会搅后面的时间反调试全局量,主逻辑还是 4380。

输入这边:NtReadFile 读 stdin,去掉 \r\n 之后必须正好 16 字节。另外 16 个字节的算术和要等于 0x500(1280),平均每个字节是 'P'。字符范围实际是 0x210x7E,也就是 '!''~',94 个可打印字符,没有空格。

2、 16 个字符先被当成 94 进制

4380 里先把输入收成一个 128 位数:

n = 0
for c in input:
    n = n * 94 + (c - 0x21)

后面所有运算都吃这个 n'P'*16 对应 0xed048f469eec3fb8af41531605,拿 Unicorn 对着寄存器对过,没跑偏。

3、 反调试掺进密钥里

4380 开头一堆经典货:PEB.BeingDebugged、NtGlobalFlag、DR0–DR3/DR7、NtQueryInformationProcess 的 DebugPort / DebugObjectHandle、rdtsc 超时。检测结果不是简单 abort,而是混进后面 splitmix64 的种子。

所以这题动态调的时候,密钥全都会漂。我这边是 Unicorn 造了个干净 PEB + 假 ntdll/syscall,从 0x140004380 跑到返回,静态 dump S 盒和 round key。

splitmix 吐出来的东西跟输入无关(干净环境下是常量):

  • 256 字节 S 盒(不是置换,大概 160 多个不同取值)
  • 两组各 20 个 qword 的 round key(rk1 / rk2)
  • 若干 128-bit 常数,后面多项式和 Feistel 都用得到

比较目标也是算好放在栈上的:

e0 = 0xB8FD0694401D3D03   # 跟第一组 Feistel 的 r12 比
e1 = 0x0C14DB1C20DD97A8   # 跟第一组 Feistel 的 r9 比
e2 = 0xACA5240A53FB78A6   # 第二组
e3 = 0x9A2789B6DE31C2DC

后面还有两个 FNV 校验,第一组对上的话这两个会跟着对。

4、 预变换:模 2^128-78 的五次多项式

输入 a 不会直接进 Feistel。0x140004EE90x140005910 这段重复了四次「128×128 乘 + 用 0x4E 做 Barrett 式归约」。模数是:

M = 2^128 - 78

0x4E 就是 78。另外还有个循环 4 次的修正,我这边叫它 add39x4:高半部分 >= 0x7FFFFFFFFFFFFFFF 的时候,低半 += 39,高半再加 0x8000000000000001 并处理借位。直观效果是值一旦摸到 2^127 附近,就减掉 P = 2^127-39,把数按回高位为 0 的区间。

所以它并不严格保持 mod M 的剩余类,只保证 mod P 不变(借位走常见那条路径的时候)。这点后面解方程会踩到。

抛开 add39x4 的毛刺,这段代数结构很干净。设

C1 = 0x21B3BDE1ACF9ADFA471FB28461A53BB5
C2 = 0x191A3870AD3D941105F15A9C38AAE874
C3 = 0x40C190E211322CAA2F01797348F02C97
C4 = 0x1DF732140B8A3EF758FD09BDE0058A37
C5 = 0x3F0CB8512C6F88E47E5F164FB478AAB5

(C1/C2 在 rbp-0x20rbp-0x10,C3/C4/C5 在 rbp+0+0x10+0x20,都是 splitmix 吐的。)

x1 = a * (a + C1)
x2 = a * (x1 + C2)
x3 = a * (x2 + C3)
x4 = a * (x3 + C4)
T  = x4 + C5

也就是

T = a^5 + C1 a^4 + C2 a^3 + C3 a^2 + C4 a + C5

'P'*16 对过,5910 处的 (r9<<64)|r12 跟这个式子一致。

5、 20 轮 Feistel

5910 之后是 20 轮,结构是:

new_r9  = SB(F(old_r9,  keyxor)) XOR old_r12
new_r12 = SB(F(new_r9,  keyxor^GOLDEN)) XOR old_r9

keyxor 偶数轮用 FNV-1a 常量 0x553D3C5EF6EE11FF 再异或 rk1[i],奇数轮换 golden-FNV 0x4AEEE52B5738EB8F

F 本身是:两个 128 位数先 add39x4,做 128×128 乘,再 % M,再 add39x4。其中一个操作数是常量

A = 0x4D8A9E9343D415CB83A0A488EC8523FE

另一个是 (r9, keyxor)

SBror(x,47) ^ keyxor ^ R_lo,然后 ror(t,51) ^ SboxPerm(t)。S 盒按字节 7,6,5,...,0 的顺序抽。

这是标准 Feistel,反一轮只需要正向再算一遍 F:

old_r9  = SB(F(new_r9, keyxor^GOLDEN)) XOR new_r12
old_r12 = SB(F(old_r9, keyxor))        XOR new_r9

(r9, r12) = (e1, e0) 往回拆 20 轮,得到进入 Feistel 的初态:

r9  = 0x4BE831B0AD3A2D36
r12 = 0x1489375BBA3FB8DE
T   = 0x4BE831B0AD3A2D361489375BBA3FB8DE

5EB8 起还有第二组 20 轮,换 rk2,初态还是同一份 T。第一组对上,第二组的 e2/e3 会一起对上,当交叉验证就行。

6、 解五次方程

现在变成:

a^5 + C1 a^4 + C2 a^3 + C3 a^2 + C4 a + C5 ≡ T  (某种归约下)

M = 2 * PP = 2^127 - 39 是素数。一开始我按 mod M 去解,多项式在 GF(2) 上恒为 1,模 2 无根,模 M 当然也无根。T 是偶数,而这个五次式对任意 a 都是奇数——除非中间 add39x4 翻了奇偶性。

回头看 add39x4:高位到了 2^127 就减 P,剩余类从 mod M 的一个陪集跳到另一个,但 mod P 不变。所以方程应该放在 GF(P) 上解。

Sage / sympy 分解五次多项式,只有一个一次因子:

a ≡ 0x174A5E1D4CF4F146E72F70092AC  (mod P)

a 还得能编成 16 位 94 进制,上界 94^16 远小于 P,所以这个根就是唯一候选。按大端 94 进制展开,字节和正好 0x500:

kanxue@2o26o8!@#

7、 验证

运行:kctf2026_CrackMe08.exe
Input:kanxue@2o26o8!@#
correct

完整脚本如下:

# -*- coding: utf-8 -*-
"""kctf2026 CrackMe08 solver — invert Feistel then solve the quintic mod P=2^127-39."""
from sympy import Poly, symbols

MASK64 = (1 << 64) - 1
M = (1 << 128) - 78
P = (1 << 127) - 39
GOLDEN = 0x9E3779B97F4A7C15

C1 = 0x21B3BDE1ACF9ADFA471FB28461A53BB5
C2 = 0x191A3870AD3D941105F15A9C38AAE874
C3 = 0x40C190E211322CAA2F01797348F02C97
C4 = 0x1DF732140B8A3EF758FD09BDE0058A37
C5 = 0x3F0CB8512C6F88E47E5F164FB478AAB5
E0 = 0xB8FD0694401D3D03  # after 20 rounds, compared with r12
E1 = 0x0C14DB1C20DD97A8  # after 20 rounds, compared with r9
M30 = 0x553D3C5EF6EE11FF
M38 = 0x4AEEE52B5738EB8F
A_HI, A_LO = 0x4D8A9E9343D415CB, 0x83A0A488EC8523FE

SBOX = bytes.fromhex(
    "4ed835172d8e916bab6530f28adeda186f5cf09e608981629046fd613daa949f"
    "88e22fc430b64f4fde66b80ade4067d76d6551e656e1e540232e61488b476f4c"
    "9996650cfff1f87593c03fd152ae048c388dcbf88159a1104c80a10331e6195c"
    "d8be465021e378d8f386d0c7fb358fac7974d4426b84f57e49d5266688b37841"
    "182b9230bd9500caedf4df297b70e3d98ac1baa1795b5a8da5927489c7a402ba"
    "08db3e554db27324da17ac9c1ba512e1d1151e090e57da9e74ded1365352d4e9"
    "66536e03cd266e1bcb2d5776460932554c37cc9f00faddcd2e92fe3d0e71ac89"
    "dc183d87d5fa40efa98c65da9d7dc32fc092638af4e673fc81a5ad390563c349"
)
RK1 = [
    int.from_bytes(bytes.fromhex(
        "86e30f8fcfb64a45f5f5e9404a661507a1a9fb575b300693dbb7ebe5522c8ce9"
        "c33807f283d03ffb1bde3f5ca7f8b6cc804e5daf38dd3121cdf096002d208ba0"
        "9848bb24707110d986f8638a940fe308eab7a1226497283f8b0202c7dad49f50"
        "1fc0569a87c0d34ee201da542b4f805ce0c4a947cd1941b0ed20483cade20def"
        "0a7b26a51f10759ed3fde5b63b4784a35ce7d62a02b42f2d3ae3fdcd76b5e6c5"
    )[i * 8:(i + 1) * 8], "little")
    for i in range(20)
]


def ror64(x, n):
    n &= 63
    return ((x >> n) | (x << (64 - n))) & MASK64


def add39x4(hi, lo, add_hi=0x8000000000000001):
    rdi = 0x7FFFFFFFFFFFFFFF
    special = MASK64 - 0x26
    for _ in range(4):
        do = True
        if not (hi == rdi and lo == special):
            if hi < rdi:
                do = False
        if do:
            borrow = 1 if lo < special else 0
            hi = (hi - borrow + add_hi) & MASK64
            lo = (lo + 0x27) & MASK64
    return hi, lo


def sbox_apply(x):
    r8 = SBOX[(x >> 0x30) & 0xFF]
    rdx = (SBOX[(x >> 0x38) & 0xFF] << 8) & MASK64
    r8 |= rdx
    r8 = (r8 << 8) & MASK64
    for sh in (0x28, 0x20, 0x18, 0x10, 8):
        r8 |= SBOX[(x >> sh) & 0xFF]
        r8 = (r8 << 8) & MASK64
    r8 |= SBOX[x & 0xFF]
    return ror64(x, 0x33) ^ r8


def F_half(hi_state, keyxor):
    B_hi, B_lo = add39x4(hi_state, keyxor)
    A_hi, A_lo = add39x4(A_HI, A_LO)
    R = ((A_hi << 64) | A_lo) * ((B_hi << 64) | B_lo) % M
    return add39x4(R >> 64, R & MASK64)


def SB_from_R(R_hi, R_lo, keyxor, xor_other):
    t = ror64(R_hi, 0x2F) ^ keyxor ^ R_lo
    return sbox_apply(t) ^ xor_other


def round_inv(new_r9, new_r12, rnd):
    keyxor = (M30 if rnd & 1 == 0 else M38) ^ RK1[rnd]
    keyxor2 = keyxor ^ GOLDEN
    R2_hi, R2_lo = F_half(new_r9, keyxor2)
    old_r9 = SB_from_R(R2_hi, R2_lo, keyxor2, new_r12)
    R_hi, R_lo = F_half(old_r9, keyxor)
    old_r12 = SB_from_R(R_hi, R_lo, keyxor, new_r9)
    return old_r9, old_r12


def to_b94(n, length=16):
    out = []
    for _ in range(length):
        out.append(0x21 + (n % 94))
        n //= 94
    if n:
        return None
    return bytes(reversed(out))


def main():
    r9, r12 = E1, E0
    for i in range(19, -1, -1):
        r9, r12 = round_inv(r9, r12, i)
    T = (r9 << 64) | r12

    x = symbols("x")
    f = Poly(
        x ** 5 + C1 * x ** 4 + C2 * x ** 3 + C3 * x ** 2 + C4 * x + (C5 - T),
        x,
        modulus=P,
    )
    roots = [int(r) for r in f.ground_roots()]
    B = 94 ** 16
    for a in roots:
        if 0 <= a < B:
            flag = to_b94(a)
            if flag and sum(flag) == 0x500:
                print(flag.decode("ascii"))
                return
    raise SystemExit("no flag")


if __name__ == "__main__":
    main()

S 盒和 rk1 是干净环境下 dump 出来的常量,跑一下直接出 flag 如下:

kanxue@2o26o8!@#

冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

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