首页
社区
课程
招聘
[原创] KCTF 2026 第八题 (AI)
发表于: 2026-8-24 02:29 234

[原创] KCTF 2026 第八题 (AI)

2026-8-24 02:29
234

1. 从入口开始看

程序读入一行字符串,失败时输出 fault,通过校验后输出 correct。IDA 给出的映像基址是 0x140000000,入口 start 位于 0x1400012C0

初次分析时能看到的内容不多:IDA 只识别出十几个函数,导入表中有 VirtualProtectGetProcAddressGetThreadContext,字符串主要是 Input:correctfault 以及几个 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,检查 Dr0Dr3Dr7
  • 通过 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

0xd38f8df8ca2ae9350x140005BF2 处的寄存器值相同,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!@#

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

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