-
-
[原创] 第八题:亥子合辰·塔影迷楼 wp by lzq2000
-
发表于: 3天前 47
-
题目文件就一个 kctf2026_CrackMe08.exe,x64 PE,体积不大。跑起来会打 Input:,从 stdin 读一行。长度对了才往下走,对了输出 correct,错了 fault。flag 没有 flag{} 这种壳,16 个可打印字符。
具体解题思路及脚本如下:
1、 入口那两层加密
程序几乎不走正常导入表,入口 start 在 0x1400012C0。.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'。字符范围实际是 0x21~0x7E,也就是 '!' 到 '~',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。0x140004EE9 到 0x140005910 这段重复了四次「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-0x20、rbp-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)。
SB 是 ror(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 * P,P = 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内核攻防全技术栈,打造具备自动化能力的内核开发高手。