-
-
[原创] 看雪·2026 KCTF —— 第二题:巳时·绿光幽语 Writeup
-
发表于: 2026-8-11 13:14 37
-
题目:题目.exe(出题战队:one team)
类型:Reverse
答案:注册码 yb6tcr
PE32+ console x86-64,编译时间 2026-03-06。7 MB 的体积里,真正的题眼只有几百字节,而且藏在一个几乎不会有人去看的地方。
整题的结构可以概括成一句话:把逻辑塞进被重新编译过的 CPython 解释器里,而不是塞进脚本。
rabin2 -S 显示节区只到 0x54600,后面 7 MB 全是 overlay。overlay 开头是 78 da(zlib),文件末尾是 python313.dll + 一串 0 —— 典型的 PyInstaller cookie 结构:
cookie 位于 0x717f0b,pkgStart = cookiePos + 88 - pkgLen = 0x54600,正好接在最后一个节区之后。TOC 只有 21 项:
入口脚本 main 只有 159 字节,unmarshal 后 co_names = ('sys','exit')、co_consts = (0, None),还原出来就是:
PYZ.pyz 里 112 个模块全是标准库,base_library.zip 154 项也全是标准库。Python 层什么都没干。
到这里第一个岔路口出现了:很容易以为解错方向或者拿错文件。
与其在 190 KB 的 bootloader 里大海捞针,不如直接和上游二进制对比。PyInstaller 的 Windows wheel 里带着预编译的 run.exe,从 PyPI 把 6.11 ~ 6.22 全撸下来逐个比 .text:
命中 PyInstaller 6.20.0。再逐段 diff:
.rsrc 里只有 7 个图标 + 1 个 manifest,其中 256×256 的那张 PNG(37019 字节)与 wheel 里 PyInstaller/bootloader/images/icon-console.ico 内嵌的 PNG 完全一致 —— PyInstaller 默认图标,没夹带。
再验证归档本身有没有藏东西:把 21 个条目的 [entryPos, entryPos+csz) 加上 TOC 区间排序求并集,从 0 到 0x6c390b 无任何空洞。
结论:bootloader、资源、归档布局全部干净。剩下唯一可能被做手脚的,就是归档里那些二进制。
对归档内所有 PE 做体检(节区末尾 vs 文件大小):
那 14168 字节的 overlay 是 Authenticode 数字签名。同一套 Python 发行版里所有 .pyd 都签了名,唯独主 DLL 没有 —— 这是最硬的一个信号。
再和官方比大小:
字符串 diff 直接把作者的构建环境暴露了:
作者从源码自己编了一份 CPython 3.13.1(非 PGO,所以比官方大 1 MB)。题目逻辑在解释器里。
自编 CPython 的改动无法用字节 diff 定位(整个二进制布局都变了),得换思路 —— 从字符串里找不属于 CPython 的东西。过滤掉双方共有的字符串后,几个刺眼的残片浮出来:
co_consts) 这种带右括号的形态,是 marshal 里 )\x01\xda\tco_consts(长度 1 的元组 + interned 短字符串)—— 说明这附近是一段被 marshal 进 DLL 的 code object。
Python 3.13 的 frozen 模块在 python313.dll 里就是一段段裸 marshal 数据,一个挨一个排布。顺序 unmarshal 可以把整张表走出来:
把 <frozen os> 反出来,函数列表最后多了一个 CPython 里根本不存在的 my_function:
选 os 下手很讲究:解释器启动阶段必然 import os,所以模块级代码一定会跑,main.py 干不干活完全无所谓。
反汇编 <frozen os> 模块级字节码的尾部(dis 需要用真正的 CPython 3.13,否则 opcode 表对不上),还原出来是:
对应的模块常量:
先把提示解出来(XOR 85):
题目把要求说得很清楚了:找到 6 字符的 key,让 my_function 被还原成能打印「恭喜成功!」的样子。
重复密钥异或本身不难,难点是"正确的明文长什么样"。这里作者留了一个致命的巧合:
密文 c 和 my_function 原始字节码等长。 而 co_consts 是被单独替换的((None, 解密出的字符串)),也就是说 LOAD_CONST 1 这条指令本身不用变,只是它取到的常量从 666 变成了那句中文 —— 所以还原目标就是原始字节码本身,一个字节都不用改。
my_function 原始字节码:
直接异或:
完美的 6 字节周期,key = yb6tcr,白送。
用这个 key 解另一段密文 succ:
正好就是提示里承诺的那句输出。两条独立约束(字节码必须合法且等于原码、成功串必须是合法 UTF-8 且内容对得上)同时满足,密钥唯一。
solve_q2.py:从原始 exe 一路自动解到 key,全程静态,不需要运行样本、不需要 Windows。
脚本做四件事:解 PyInstaller CArchive → 取出 python313.dll → 用 marshal 里 \xda\x0b<frozen os> 这个 interned 字符串定位、回扫找 code object 头 → 取常量做已知明文异或并自动求周期。
注意必须用 CPython 3.13 跑:3.14 的 marshal 虽然能把 code object 读出来(co_names/co_consts 是对的),但 dis 的 opcode 表已经变了,反汇编结果全是错的。分析过程中如果用 3.14 看 main.pyc,会看到 RETURN_GENERATOR 之类完全不着调的指令。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。