-
-
[原创]KCTF 2026 第二题 "绿光幽语" WriteUp
-
发表于: 2天前 94
-
目标文件:题目.exe(PyInstaller onefile 打包,x64,Python 3.13.1)
分析工具:GPT 5.6 + IDA Pro + 官方 CPython 3.13.1 解释器(marshal/dis 还原字节码)
出题人在简介里埋了三条技术线索,破解后可一一对应:
整个解题过程遵循"由外到内、由静到动"的顺序:外壳 → 解包 → 诱饵识别 → 动态运行 → 定位真实逻辑 → 还原算法 → 恢复密钥。
在 IDA 中对入口相关函数做 F5,很快能看到大量 Py_* C-API 被运行时动态解析。例如 sub_140001C80(Python DLL 符号加载器)的 F5 伪代码节选:
解读:这段一次性解析了 50+ 个 Py_* 符号并存入结构体偏移,是 PyInstaller 官方 bootloader 的标准行为(pyi_pylib_load_python_dll / _pyi_pylib_set_runtime_paths 那套)。没有任何自定义算法。
在 IDA 里搜索 flag / KCTF / serial:
结论 1:原生 EXE 层是标准 PyInstaller 壳,flag 逻辑不在原生代码里。 这正是"工牌上的空白照片"——外壳是障眼法。
用脚本解析 PyInstaller 的 CArchive(parse_toc.py / full_toc.py),定位文件尾部的 MEI\x0c\x0b\x0a\x0b\x0e cookie,读出 21 个 TOC 条目:
关键观察:
从 CArchive 直接抽出 main 条目并 marshal.loads 反汇编:
main 只做了一件事:import sys; sys.exit(0)。 这就是"化为灰烬的终端机"——入口脚本是空壳。
同时把 PYZ.pyz 里的 112 个模块全部与官方 3.13.1/3.13.7 源码做字节码级 diff,扣除"点版本差异 + -OO 去 docstring"的噪声后,没有任何一个标准库模块被真正篡改。
结论 2:Python 脚本层(main + PYZ + base_library)全是干净的标准库和诱饵,真实逻辑既不在原生层,也不在常规 pyc 里。
至此静态分析陷入僵局——这是本题设计最"阴"的地方。破局点必须来自动态运行。
既然 main 号称直接退出,那就实际跑一遍看看:
GBK 解码后惊现真实交互逻辑:
重大发现:程序明明"应该"在 main 里 sys.exit(0),却打印出了 keygen 提示!说明真正的代码在 main 之前就执行了。
在 Python 启动流程中,能早于用户入口脚本运行的,只有解释器自带的 frozen module(如 os、importlib._bootstrap 等,它们被冻结进解释器,import 时无需读磁盘)。而这些 frozen module 就编译进了 python313.dll——回想第 2 节里那个体积异常的 DLL。
对提示中出现的函数名 my_function 在所有文件做裸字节搜索:
命中 python313.dll!\xda\x0b 是 marshal 的 SHORT_ASCII_INTERNED(len=11) + my_function,紧跟着一串 s\x10\x00\x00\x00(marshal TYPE_STRING, len=0x10=16 字节)——正是"十六个碎片"。
向前扫描定位到 marshal code object 的起点,用官方 3.13.1 解释器成功 marshal.loads 出一个 co_name='<module>'、内含 my_function 的完整模块——这是一个被出题人魔改过的 frozen os 模块(起始偏移 0x4d00e0)。
对应线索:"工业路由器用的就是这套逻辑"——把校验逻辑冻结进运行时 DLL,是嵌入式/路由器固件里常见的反逆向手法。
递归遍历该 code object 的 co_consts,找到出题人注入的载荷:
对魔改 os 模块的模块级字节码(第 1185–1226 行)反汇编,逐段翻译成等价 Python。核心逻辑如下:
这就是"古典密码 + 时间戳算法"里最简单的单表替换层:XOR 85。
对应 F5 之外的字节码证据(行 1213):
校验原理(精妙之处):程序不做任何 if key == ... 的显式比较。密钥正确时,c 被异或还原成一段合法的 CPython 字节码(正好是 print("恭喜成功!")),my_function() 正常输出;密钥错误时还原出非法字节码,调用即抛异常/崩溃——这正是简介里"三段必须同步解密,否则自毁"。
无需爆破。第 5.4 节告诉我们:解密后的 succ 必然是成功文案。而提示里已经明明白白写着成功文案是 恭喜成功!,其 UTF-8 编码恰好 15 字节,与 succ 长度完全一致:
输出结尾:
✅ 注册码 yb6tcr 经真机验证正确。
综合难度:中上。 对只会"pyinstxtractor + 反编译 pyc"流水线的选手是硬门槛;对有动态调试意识、了解 frozen module 机制的老手是中等。
一句话总结:外壳干净、诱饵到位、逻辑藏得妙,是一道"考动态意识 + frozen module 冷知识"的好题;核心思路值得沉淀成模板。
Flag / 注册码 / 序列号:yb6tcr
| 简介中的隐喻 | 对应的真实技术点 |
|---|---|
| "文件分段加密:古典密码、时间戳算法、硬件密钥" | 重复密钥异或(Vigenère 型古典密码)+ 单字节 XOR + 密文分段 |
| "硬件密钥被拆成十六个碎片,分散在十六个存储扇区" | 提示明文被拆成 8×16=128 字节碎片,藏在重编译的 python313.dll 里 |
| "三年前 KCTF 上那款工业路由器,用的就是这套逻辑" | 自定义/固化 Python 运行时(把逻辑冻结进 DLL 的 frozen module),路由器固件常见套路 |
| "终端机化为灰烬" / "空白照片的工牌" | main.pyc 是空壳诱饵(sys.exit(0)),真正的"人"(逻辑)藏在别处 |
| 常量索引 | 内容 | 用途 |
|---|---|---|
const[145] |
8 个 16 字节 bytes 元组(共 128 字节) | 提示明文密文("16 碎片") |
const[146] |
85 |
单字节 XOR key |
const[147] |
'utf-8' |
解码 |
const[148] |
6 |
密钥长度校验 |
const[149] |
b'\xecbmucryb6tcr*c\x03ucryb6tCr\x1eb' |
my_function 的加密 co_code |
const[151] |
b'\x9f\xe3\x9b\x91\xf5\xee\x9f\xea\xa6\x91\xe9\xed\x96\xde\xb7'(15 字节) |
加密的成功字符串 |
const[143] |
<code my_function> |
待还原的函数(占位版) |
__int64 __fastcall sub_140001C80(__int64 a1)
{
// a1 是一张"函数指针表",逐个用 GetProcAddress 从 python 主 DLL 拿地址
Py_DecRef = GetProcAddress(*(HMODULE *)a1, "Py_DecRef"); // 0x140001c93
*(_QWORD *)(a1 + 16) = Py_DecRef;
if ( !Py_DecRef ) { /* 报错 "Failed to import symbol %hs from Python DLL." */ }
Py_DecodeLocale = GetProcAddress(*(HMODULE*)a1, "Py_DecodeLocale");
Py_Finalize = GetProcAddress(*(HMODULE*)a1, "Py_Finalize");
...
PyMarshal_ReadObjectFromString = GetProcAddress(*(HMODULE*)a1, "PyMarshal_ReadObjectFromString"); // 反序列化 pyc
PyEval_EvalCode = GetProcAddress(*(HMODULE*)a1, "PyEval_EvalCode"); // 执行 code object
PyImport_ExecCodeModule = GetProcAddress(*(HMODULE*)a1, "PyImport_ExecCodeModule");
PyRun_SimpleStringFlags = GetProcAddress(*(HMODULE*)a1, "PyRun_SimpleStringFlags");
...
return 0;
}
typ flag dlen ulen name
m 1 235 289 struct
m 1 2831 4914 pyimod01_archive
m 1 13630 31786 pyimod02_importers
s 1 1027 1881 pyiboot01_bootstrap
s 1 1470 2779 pyi_rth_inspect
s 1 121 159 main ← 入口脚本,仅 159 字节
b 1 ... _decimal.pyd / _hashlib.pyd / _socket.pyd ...
b 1 2676210 7127552 python313.dll ← 注意这个体积
z 0 1372950 1372950 PYZ.pyz ← 112 个标准库模块