首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[原创] KCTF 2026 第八题 (AI)
发表于: 2026-8-24 02:29
368
[原创] KCTF 2026 第八题 (AI)
LingYi0110
2026-8-24 02:29
368
## 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` | 输入字节和 | 第一层只有单字节异或: ```python for i in range(0x510): image[0x1800 + i] ^= 0x5A ``` 解密后,`0x1400018D0` 处出现正常的 x64 函数序言。把结果写回 IDA 并重新分析,可以看到第二层解密函数。 ### 第二层 第二层解密先对第一层明文计算两组 FNV 状态: ```python MASK = (1 << 64) - 1 FNV = 0x100000001B3 s1 = 0xCBF29CE484222325 s2 = 0x9E3779B97F4A7C15 for b in layer1: s1 = ((s1 ^ b) * FNV) & MASK s2 = ((s2 ^ b) * FNV) & MASK ``` 本题得到: ```text s1 = 0x73fb4f498aab364f s2 = 0xb157c7e044b966df ``` RVA `0x1D10` 开始的 `0x4962` 字节使用下面的密钥流解密: ```python 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` 比较: ```text 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` 均从脚本所在目录读取: ```python 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` | 多项式系数、有限域常数和轮密钥来源 | 为了确定轮函数,还跟踪了这些位置: ```text 0x140004EF0 base-94 和多项式处理之后 0x140005900 第一组 Feistel 开始 0x140005B32 第一轮中间值 x 0x140005BF2 第一轮第一次更新 0x140005EA1 第一轮第二次更新 0x140005EBF 第一组 20 轮结束 ``` ## 5. 重建运行时上下文 主校验函数对解密后的 `layer2.bin` 再计算一次双 FNV: ```text h1 = 0x553d3c5ef6ee11ff h2 = 0x4aeee52b5738eb8f ``` 无调试时,参与种子计算的反调试标志为零。两个初始状态为: ```python 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 | 代码中的模数是: ```python P = 2**127 - 39 ``` 前 80 字节分成五个 128 位整数,模 `P` 归约后得到: ```text A0 = 0x21b3bde1acf9adfa471fb28461a53bb5 A1 = 0x191a3870ad3d941105f15a9c38aae874 A2 = 0x40c190e211322caa2f01797348f02c97 A3 = 0x1df732140b8a3ef758fd09bde0058a37 A4 = 0x3f0cb8512c6f88e47e5f164fb478aab5 ``` S-box 每次读取两个 qword,再按字节交错排列。实际替换时,输入 qword 的八个字节分别查表,最后按原位置组合。 ## 6. 对齐 F 函数 第一组 Feistel 开始时,探针输入对应的状态是: ```text L = 0x4df1bd4d7d52bcab R = 0x7db4166d669ff9f0 ``` 第一个轮密钥材料为 `0x454ab6cf8f0fe386`。第 0 轮使用: ```text key1 = h1 XOR round_key[0] ``` 把汇编中的有限域乘法和旋转操作整理后,F 函数为: ```text 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 轮的跟踪值是: ```text x = 0x75feb063f1b61d85 F(R, key1) = 0x52cfaf66516a042a L XOR F(R, key1) = 0xd38f8df8ca2ae935 ``` `0xd38f8df8ca2ae935` 与 `0x140005BF2` 处的寄存器值相同,F 函数由此对齐。 ## 7. 逆向 Feistel 一轮包含两次更新。轮密钥按下面的方式生成: ```text key1 = alt XOR round_key[i] key2 = key1 XOR 0x9E3779B97F4A7C15 ``` 第一组在偶数轮使用 `h1`,奇数轮使用 `h2`;第二组交换两者的奇偶顺序。 正向更新为: ```text R_new = L XOR F(R, key1) L_new = R XOR F(R_new, key2) ``` 从输出恢复输入时,顺序反过来: ```text R_old = L_new XOR F(R_new, key2) L_old = R_new XOR F(R_old, key1) ``` 第一组目标 `v417` 由 `.tgt` 常量与上下文中的两个 qword 异或得到: ```text T417 = (0xb8fd0694401d3d03, 0x0c14db1c20dd97a8) ``` 逆向 20 轮后的状态为: ```text L = 0x1489375bba3fb8de R = 0x4be831b0ad3a2d36 ``` 按低 64 位在前合并: ```text T = L + (R << 64) = 0x4be831b0ad3a2d361489375bba3fb8de ``` 第一组输出继续进入第二组 Feistel。用正向实现计算出的结果与 `v418` 相同,轮密钥顺序和两组状态切换均已对齐。 ## 8. 解五次多项式 输入字符限定在 `!` 到 `~`。程序先减去 33,再按 94 进制累积: ```python def encode94(data): e = 0 for b in data: e = (e * 94 + b - 33) % P return e ``` 编码结果进入下面的五次多项式: ```text y = e^5 + A0*e^4 + A1*e^3 + A2*e^2 + A3*e + A4 mod P ``` 前一节恢复出的 `T` 就是 `y`。待求多项式为: ```text f(e) = e^5 + A0*e^4 + A1*e^3 + A2*e^2 + A3*e + (A4 - T) ``` 传给有限域分解代码的系数应为: ```python poly = [1, A0, A1, A2, A3, (A4 - T) % P] ``` 在 `GF(P)` 上分解后,结果包含一个一次因子和两个不可约二次因子。一次因子给出的根是: ```text e = 0x174a5e1d4cf4f146e72f70092ac ``` 分析中曾把系数写成五项,得到 `roots 0`。原式是首一五次多项式,系数数组应有六项。用 Horner 法复算时也要从首项 `1` 开始: ```python 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: ```python 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: ```text kanxue@2o26o8!@# ```
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
LingYi0110
6
发帖
1
回帖
10
RANK
关注
私信
他的文章
[原创] KCTF 2026 第八题 (AI)
368
[原创] KCTF 2026 第七题 (AI)
188
[原创] KCTF 2026 第五题
256
[原创] KCTF 2026 第九题
39
[原创] KCTF 2026 第二题
160
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部