-
-
[原创] KCTF 2026 第八题 (AI)
-
发表于: 2026-8-24 02:29 234
-
1. 从入口开始看
程序读入一行字符串,失败时输出 fault,通过校验后输出 correct。IDA 给出的映像基址是 0x140000000,入口 start 位于 0x1400012C0。
初次分析时能看到的内容不多:IDA 只识别出十几个函数,导入表中有 VirtualProtect、GetProcAddress 和 GetThreadContext,字符串主要是 Input:、correct、fault 以及几个 ntdll 函数名。程序还有一个位于 0x14000A000 的 .tgt 段。
start 开头调用 VirtualProtect 修改代码页权限,随后异或一段内存并调用 FlushInstructionCache。静态分析看到的无效字节由这里产生,先处理这段自解密代码。
2. 解开两层代码
第一层
解密位置和长度取自 .tgt:
| 字段 | 值 | 含义 |
|---|---|---|
qword_14000A028 |
0x1800 |
第一层代码 RVA |
qword_14000A020 |
0x510 |
第一层代码长度 |
qword_14000A008 |
0x1D10 |
第二层代码 RVA |
qword_14000A000 |
0x4962 |
第二层代码长度 |
dword_14000A010 |
0x500 |
输入字节和 |
第一层只有单字节异或:
for i in range(0x510):
image[0x1800 + i] ^= 0x5A
解密后,0x1400018D0 处出现正常的 x64 函数序言。把结果写回 IDA 并重新分析,可以看到第二层解密函数。
第二层
第二层解密先对第一层明文计算两组 FNV 状态:
MASK = (1 << 64) - 1
FNV = 0x100000001B3
s1 = 0xCBF29CE484222325
s2 = 0x9E3779B97F4A7C15
for b in layer1:
s1 = ((s1 ^ b) * FNV) & MASK
s2 = ((s2 ^ b) * FNV) & MASK
本题得到:
s1 = 0x73fb4f498aab364f
s2 = 0xb157c7e044b966df
RVA 0x1D10 开始的 0x4962 字节使用下面的密钥流解密:
G = 0x9E3779B97F4A7C15
C = 0xFF51AFD7ED558CCD
for i in range(0x4962):
x = s1 ^ s2 ^ ((G * i) & MASK)
x ^= x >> 33
key = ((C * x) & MASK) >> 56
layer2[i] ^= key
解密结果保存为 layer2.bin,写回 IDA 后,主校验函数可以从 0x140004380 开始反编译。完整伪代码保存在 check.c。
3. 主校验里的输入限制和反调试
入口已经限制输入长度为 16。主校验开头再把 16 个字节相加,与 .tgt 中的 0x500 比较:
len(data) == 16
sum(data) == 0x500
后续分析使用 b'P' * 16 作为探针。字符 P 的值是 80,16 个 P 的字节和正好是 1280,可以进入核心校验。
主函数接着收集调试状态:
- 读取 PEB 的
BeingDebugged和相关字段; - 调用
GetThreadContext,检查Dr0至Dr3和Dr7; - 通过
NtQueryInformationProcess查询进程调试信息; - 用
__rdtsc测量一段代码的执行时间。
这些结果会参与后面的伪随机上下文生成。调试器不仅影响控制流,也会改变 S-box、轮密钥和目标相关数据。用 x64dbg 直接跟踪到这里后,分析改为在 Unicorn 中模拟无调试环境。
4. 用 Unicorn 抓中间状态
emu_check.py 把 PE 映射到原映像基址,写入两层解密结果,再建立栈、输入缓冲区、TEB 和 PEB。GS 基址指向伪造的 TEB,PEB 中的 BeingDebugged 为零。脚本还为程序调用的 Windows API 和内部系统调用入口准备了桩函数。
完整脚本如下。EXE 和 layer2.bin 均从脚本所在目录读取:
from unicorn import *
from unicorn.x86_const import *
import struct
import pefile
from pathlib import Path
BASE = 0x140000000
ROOT = Path(__file__).resolve().parent
pe = pefile.PE(str(ROOT / "kctf2026_CrackMe08.exe"))
img = bytearray(pe.get_memory_mapped_image())
for i in range(1296):
img[0x1800 + i] ^= 0x5A
layer2 = (ROOT / "layer2.bin").read_bytes()
img[0x1D10:0x1D10 + len(layer2)] = layer2
def make_mu(inp: bytes):
assert len(inp) == 16
mu = Uc(UC_ARCH_X86, UC_MODE_64)
img_size = 0x10000
mu.mem_map(BASE, img_size)
mu.mem_write(BASE, bytes(img) + b"\x00" * (img_size - len(img)))
stack, stack_size = 0x70000000, 0x200000
mu.mem_map(stack, stack_size)
inp_addr = 0x60000000
mu.mem_map(inp_addr, 0x1000)
mu.mem_write(inp_addr, inp + b"\x00")
stub = 0x50000000
mu.mem_map(stub, 0x1000)
names = [
"GetStdHandle",
"GetCurrentProcess",
"FlushInstructionCache",
"VirtualProtect",
"GetModuleHandleA",
"GetProcAddress",
"GetCurrentThread",
"GetThreadContext",
]
for i in range(8):
mu.mem_write(stub + i * 16, b"\xc3")
mu.mem_write(
BASE + 0x7000 + i * 8,
struct.pack("<Q", stub + i * 16),
)
teb, peb = 0x80000000, 0x80001000
mu.mem_map(teb, 0x2000)
mu.mem_write(peb, b"\x00" * 0x1000)
try:
mu.reg_write(UC_X86_REG_GS_BASE, teb)
except Exception:
mu.msr_write(0xC0000101, teb)
mu.mem_write(teb + 0x60, struct.pack("<Q", peb))
mu.mem_map(0x7FF00000, 0x1000)
def hook_api(uc, address, size, user_data):
if not stub <= address < stub + 0x1000:
return
idx = (address - stub) // 16
name = names[idx]
rcx = uc.reg_read(UC_X86_REG_RCX)
r9 = uc.reg_read(UC_X86_REG_R9)
if name == "GetModuleHandleA":
if rcx == 0:
uc.reg_write(UC_X86_REG_RAX, BASE)
else:
value = bytes(uc.mem_read(rcx, 20)).split(b"\x00")[0]
result = 0x7FF00000 if value.lower() == b"ntdll.dll" else 0
uc.reg_write(UC_X86_REG_RAX, result)
elif name == "GetCurrentProcess":
uc.reg_write(UC_X86_REG_RAX, 0xFFFFFFFFFFFFFFFF)
elif name == "GetCurrentThread":
uc.reg_write(UC_X86_REG_RAX, 0xFFFFFFFE)
elif name == "VirtualProtect":
uc.mem_write(r9, struct.pack("<I", 0x20))
uc.reg_write(UC_X86_REG_RAX, 1)
elif name == "FlushInstructionCache":
uc.reg_write(UC_X86_REG_RAX, 1)
elif name == "GetProcAddress":
uc.reg_write(UC_X86_REG_RAX, 0)
elif name == "GetThreadContext":
uc.reg_write(UC_X86_REG_RAX, 1)
else:
uc.reg_write(UC_X86_REG_RAX, 0)
mu.hook_add(
UC_HOOK_CODE,
hook_api,
begin=stub,
end=stub + 0x1000,
)
def hook_sys(uc, address, size, user_data):
if address != 0x1400017B0:
return
uc.reg_write(UC_X86_REG_RAX, 0)
rsp = uc.reg_read(UC_X86_REG_RSP)
ret = struct.unpack("<Q", uc.mem_read(rsp, 8))[0]
uc.reg_write(UC_X86_REG_RSP, rsp + 8)
uc.reg_write(UC_X86_REG_RIP, ret)
mu.hook_add(
UC_HOOK_CODE,
hook_sys,
begin=0x1400017B0,
end=0x1400017B1,
)
mu.mem_write(BASE + 0x8020, struct.pack("<I", 10))
return mu, stack, stack_size, inp_addr
def run_check(inp, stop_at=None, mem_offs=None):
mu, stack, stack_size, inp_addr = make_mu(inp)
ret_addr = 0xDEAD0000
mu.mem_map(ret_addr, 0x1000)
mu.mem_write(ret_addr, b"\xcc")
rsp = stack + stack_size - 0x10000
mu.reg_write(UC_X86_REG_RSP, rsp - 8)
mu.mem_write(rsp - 8, struct.pack("<Q", ret_addr))
mu.reg_write(UC_X86_REG_RCX, inp_addr)
state = {}
def hook_stop(uc, address, size, user_data):
if stop_at is None or address != stop_at:
return
rbp = uc.reg_read(UC_X86_REG_RBP)
frame_base = rbp - 0x100
state["frame_base"] = frame_base
state["regs"] = {
name: uc.reg_read(reg)
for name, reg in [
("rax", UC_X86_REG_RAX),
("rbx", UC_X86_REG_RBX),
("rcx", UC_X86_REG_RCX),
("rdx", UC_X86_REG_RDX),
("rsi", UC_X86_REG_RSI),
("rdi", UC_X86_REG_RDI),
("r8", UC_X86_REG_R8),
("r9", UC_X86_REG_R9),
("r10", UC_X86_REG_R10),
("r11", UC_X86_REG_R11),
("r12", UC_X86_REG_R12),
("r13", UC_X86_REG_R13),
("r14", UC_X86_REG_R14),
("r15", UC_X86_REG_R15),
("rbp", UC_X86_REG_RBP),
("rsp", UC_X86_REG_RSP),
]
}
if mem_offs:
state["mem"] = {
name: bytes(uc.mem_read(frame_base + offset, size))
for name, offset, size in mem_offs
}
uc.emu_stop()
if stop_at is not None:
mu.hook_add(UC_HOOK_CODE, hook_stop)
mu.emu_start(
0x140004380,
ret_addr,
timeout=5 * UC_SECOND_SCALE,
count=20_000_000,
)
if stop_at is None:
return mu.reg_read(UC_X86_REG_RAX), None
return state
if __name__ == "__main__":
mem_offs = [
("v416", 0x130, 8),
("v417", 0x138, 16),
("v418", 0x148, 16),
("v419", 0x158, 8),
("v390", 0x70, 8),
("v401", 0x90, 8),
("v403", 0x98, 8),
("v426", 0x8D0, 16),
("fnv_target", 0x60, 8),
("sbox", 0x160, 256),
("v421", 0x260, 19 * 8),
("v422", 0x2F8, 21 * 8),
("ctx_rand", 0x3A0, 86 * 8),
]
probe = bytes([80] * 16)
state = run_check(
probe,
stop_at=0x140006622,
mem_offs=mem_offs,
)
print("regs", {
name: hex(value)
for name, value in state["regs"].items()
})
for name, value in state["mem"].items():
output = value[:64] if len(value) > 64 else value
print(name, output.hex())
result, _ = run_check(probe)
print("final", result)
第一次模拟使用 16 个 P。程序能够进入主校验,但最初设置的断点和局部变量偏移没有对上。检查实际指令中的 [rbp+位移] 后,重新计算栈帧基址,在 0x140006622 处抓到了最终比较所需的数据。
| 数据 | 用途 |
|---|---|
v417 |
第一组 Feistel 的目标 |
v418 |
第二组 Feistel 的目标 |
v419 |
FNV 比较目标 |
v420 |
256 字节 S-box |
v421 |
第一组轮密钥材料 |
v422 |
第二组轮密钥材料 |
Context |
多项式系数、有限域常数和轮密钥来源 |
为了确定轮函数,还跟踪了这些位置:
0x140004EF0 base-94 和多项式处理之后
0x140005900 第一组 Feistel 开始
0x140005B32 第一轮中间值 x
0x140005BF2 第一轮第一次更新
0x140005EA1 第一轮第二次更新
0x140005EBF 第一组 20 轮结束
5. 重建运行时上下文
主校验函数对解密后的 layer2.bin 再计算一次双 FNV:
h1 = 0x553d3c5ef6ee11ff
h2 = 0x4aeee52b5738eb8f
无调试时,参与种子计算的反调试标志为零。两个初始状态为:
seed0 = h1
seed1 = h2 ^ 0xCBF29CE484222325
主函数用一组固定的减法、乘法、异或和右移操作生成 43 对 qword,写入 1232 字节的 CONTEXT 缓冲区。离线脚本按指令顺序复现这段循环后,生成内容与 Unicorn dump 一致。
后续使用的区域为:
| 偏移 | 内容 |
|---|---|
0x00 |
5 个 128 位多项式系数 |
0x50 |
128 位有限域乘法常数 dr |
0x60 |
256 字节 S-box 材料 |
0x160 |
第一组轮密钥 |
0x1F8 |
第二组轮密钥材料 |
0x2A0 |
构造比较目标所需的两个 qword |
代码中的模数是:
P = 2**127 - 39
前 80 字节分成五个 128 位整数,模 P 归约后得到:
A0 = 0x21b3bde1acf9adfa471fb28461a53bb5
A1 = 0x191a3870ad3d941105f15a9c38aae874
A2 = 0x40c190e211322caa2f01797348f02c97
A3 = 0x1df732140b8a3ef758fd09bde0058a37
A4 = 0x3f0cb8512c6f88e47e5f164fb478aab5
S-box 每次读取两个 qword,再按字节交错排列。实际替换时,输入 qword 的八个字节分别查表,最后按原位置组合。
6. 对齐 F 函数
第一组 Feistel 开始时,探针输入对应的状态是:
L = 0x4df1bd4d7d52bcab
R = 0x7db4166d669ff9f0
第一个轮密钥材料为 0x454ab6cf8f0fe386。第 0 轮使用:
key1 = h1 XOR round_key[0]
把汇编中的有限域乘法和旋转操作整理后,F 函数为:
v = key + (side << 64) mod P
m = v * dr mod P
x = m_lo XOR key XOR ROR64(m_hi, 47)
F(side, key) = SBOX64(x) XOR ROR64(x, 51)
第 0 轮的跟踪值是:
x = 0x75feb063f1b61d85
F(R, key1) = 0x52cfaf66516a042a
L XOR F(R, key1) = 0xd38f8df8ca2ae935
0xd38f8df8ca2ae935 与 0x140005BF2 处的寄存器值相同,F 函数由此对齐。
7. 逆向 Feistel
一轮包含两次更新。轮密钥按下面的方式生成:
key1 = alt XOR round_key[i]
key2 = key1 XOR 0x9E3779B97F4A7C15
第一组在偶数轮使用 h1,奇数轮使用 h2;第二组交换两者的奇偶顺序。
正向更新为:
R_new = L XOR F(R, key1)
L_new = R XOR F(R_new, key2)
从输出恢复输入时,顺序反过来:
R_old = L_new XOR F(R_new, key2)
L_old = R_new XOR F(R_old, key1)
第一组目标 v417 由 .tgt 常量与上下文中的两个 qword 异或得到:
T417 = (0xb8fd0694401d3d03, 0x0c14db1c20dd97a8)
逆向 20 轮后的状态为:
L = 0x1489375bba3fb8de
R = 0x4be831b0ad3a2d36
按低 64 位在前合并:
T = L + (R << 64)
= 0x4be831b0ad3a2d361489375bba3fb8de
第一组输出继续进入第二组 Feistel。用正向实现计算出的结果与 v418 相同,轮密钥顺序和两组状态切换均已对齐。
8. 解五次多项式
输入字符限定在 ! 到 ~。程序先减去 33,再按 94 进制累积:
def encode94(data):
e = 0
for b in data:
e = (e * 94 + b - 33) % P
return e
编码结果进入下面的五次多项式:
y = e^5 + A0*e^4 + A1*e^3 + A2*e^2 + A3*e + A4 mod P
前一节恢复出的 T 就是 y。待求多项式为:
f(e) = e^5 + A0*e^4 + A1*e^3 + A2*e^2 + A3*e + (A4 - T)
传给有限域分解代码的系数应为:
poly = [1, A0, A1, A2, A3, (A4 - T) % P]
在 GF(P) 上分解后,结果包含一个一次因子和两个不可约二次因子。一次因子给出的根是:
e = 0x174a5e1d4cf4f146e72f70092ac
分析中曾把系数写成五项,得到 roots 0。原式是首一五次多项式,系数数组应有六项。用 Horner 法复算时也要从首项 1 开始:
s = 1
for coef in [A0, A1, A2, A3, A4]:
s = (s * e + coef) % P
assert s == T
目录中的 solve_final.py 仍保留了这个中间版本。要单独运行该脚本,需要把多项式改成六项,并把验证初值改为 1。
9. 解码并验证
对根连续除以 94,把余数加回 33:
def decode94(e):
out = []
for _ in range(16):
e, r = divmod(e, 94)
out.append(r + 33)
if e:
raise ValueError("超出 16 位 base-94 编码范围")
return bytes(reversed(out))
得到Flag:
kanxue@2o26o8!@#