-
-
KCTF 2026第二题「巳时·绿光幽语」Writeup
-
发表于: 2026-8-25 18:16 223
-
一、题目信息
- 题目文件:
题目.exe(7,438,179 字节) - 题目简介:故事讲述了金融分析师发现一段被拆成 16 片的硬件密钥,需要还原旧终端机上的分段加密文件。程序提示"请输入 key 还原代码(请输入 6 个字符)"。
- 目标:求出序列号(6 字符的 key)
- 最终答案:
yb6tcr
二、初步分析
用 file / PE 头检查可知 题目.exe 是 PyInstaller 打包的 Python 3.13 程序。PyInstaller 程序通常把 Python 解释器(python313.dll)和打包的 .pyc 一起内置。使用 pyinstxtractor.py 解包:
题目.exe_extracted/
├── py313_mod.dll # 注意:被修改的 python313.dll(重命名)
├── base_library.zip
└── ...
关键点:py313_mod.dll 是被作者修改过的解释器。正常 PyInstaller 用的是原版 python313.dll,这里重命名为 py313_mod.dll,说明作者对解释器动了手脚。
三、修改后的解释器分析
3.1 字符串全部被加密
用 IDA 打开 py313_mod.dll(ImageBase 0x180000000),发现程序要打印的提示文本在文件中不是明文,而是被 XOR 加密的数据块:
- 8 个加密提示字符串块位于
0x4dad10 - 0x4dadde,均 XOR0x55(85) - 后面紧跟着一系列小数据:
INT 85、'utf-8'、INT 6、块 9、('co_code',)、INT 15、块 10、('co_consts',)
这说明作者在解释器层面(DLL 内部)把字符串 marshal 数据加密了,运行时由解释器自行解密,标准反编译工具直接看是乱码。
3.2 marshal 格式被修改
对比本机标准 Python 3.13.14 与 py313_mod.dll 的 marshal tag 编码:
| 对象 | 标准 3.13.14 | 作者 DLL |
|---|---|---|
| INT | 0xe9 |
0x69 |
| SMALL_TUPLE(1 字节 count) | 0xa9 |
0x29 |
| bytes(4 字节 len) | 0xf3 |
0x73 |
| SMALL_STRING(1 字节 len) | 0xfa |
0x7a |
| CODE | 0xe3 |
0x63 |
规律:标准 tag = 作者 tag + 0x80(FLAG_REF 位)。作者 DLL 的 marshal 变体大多不带 FLAG_REF 位。而 0x63 CODE tag 和 0x81/0xa4 qualname tag 则是作者完全自定义的。
好消息:标准 3.13.14 的 marshal.loads 可以解析所有作者 DLL 的无 FLAG 变体(0x29、0x73、0x69、0x7a 均可正常 loads),只有 code/qualname tag 是自定义的。
四、动态分析:加载作者解释器
IDA 静态分析该 DLL 流程繁琐且不稳定,改为动态加载:用 ctypes 加载 py313_mod.dll,调用它自己的 Py_Initialize 和 PyMarshal_ReadObjectFromString,让作者自己的解析器去解析 marshal 数据——它支持自定义 tag,并能自动 XOR 解密字符串。
load_marshal.py / probe.py / scan.py
一个坑:Py_Initialize 会立刻把解密后的提示文本打印 20 次(因为作者代码注入在 os 模块里,import os 时自动执行)。解决:加载 DLL 前把 fd 1/2 重定向到 devnull,初始化后再恢复。
4.1 作者代码注入位置
Py_Initialize 后 sys.modules 里找到自定义函数 my_function,它位于 <frozen os> 模块中(origin='frozen')。说明作者把自定义代码追加到了标准 os 模块的字节码末尾,import os 时即执行。
反汇编 os 模块主代码尾部(约 1185-1235 行)得到完整作者逻辑:
x = 0
while x < 20:
def my_function(): print(666) # 占位函数,co_consts = (None, 666)
x += 1
buffer = b''.join([8 个加密块]) # 拼接 8 段 XOR 0x55 的密文
w = b''
for i in buffer:
b = i ^ 85
w += b.to_bytes(1)
print(w.decode('utf-8')) # 打印解密后的提示
key = input()
if len(key) != 6:
continue # 长度不对就重来
b_key = key.encode()
c = b'\xecbmucryb6tcr*c\x03ucryb6tCr\x1eb' # 块 9(26 字节)
buf = b''
for i in range(len(c)):
buf += (c[i] ^ b_key[i % len(b_key)]).to_bytes(1)
my = my_function.__code__
my = my.replace(co_code=buf) # 块 9 解密结果 -> 新字节码
buf = b''
succ = b'\x9f\xe3\x9b\x91\xf5\xee\x9f\xea\xa6\x91\xe9\xed\x96\xde\xb7' # 块 10(15 字节)
for i in range(len(succ)):
buf += (succ[i] ^ b_key[i % len(b_key)]).to_bytes(1)
my = my.replace(co_consts=(None, buf.decode('utf-8'))) # 块 10 解密结果 -> consts[1]
my_function.__code__ = my
my_function() # 调用还原后的函数
4.2 提示文本
8 个加密块 XOR 0x55 拼接后是:
提示,需要还原的代码
def my_function():
print("恭喜成功!")
请输入key还原代码(请输入6个字符)
即提示文本本身就明示了还原目标:让 my_function 打印 恭喜成功!。
五、破解密钥:已知明文攻击
整体算法非常清晰:
- 块 9(26 字节)XOR key(UTF-8,循环重复)→ 还原成
my_function的新字节码(co_code) - 块 10(15 字节)XOR 同一 key → 还原成字符串,替换
co_consts[1]
而 co_consts[1] 最终要被 my_function() 打印出来。结合提示文本,"恭喜成功!" 就是要还原的目标明文!
"恭喜成功!" 的 UTF-8 编码(15 字节):
e6 81 ad e5 96 9c e6 88 90 e5 8a 9f ef bc 81
块 10 密文(15 字节):
9f e3 9b 91 f5 ee 9f ea a6 91 e9 ed 96 de b7
因为密钥是循环重复的(key[i % 6]),取前 6 字节做 XOR:
k[0] = 9f ^ e6 = 79 = 'y'
k[1] = e3 ^ 81 = 62 = 'b'
k[2] = 9b ^ ad = 36 = '6'
k[3] = 91 ^ e5 = 74 = 't'
k[4] = f5 ^ 96 = 63 = 'c'
k[5] = ee ^ 9c = 72 = 'r'
key = yb6tcr。其余 9 字节用同一 6 字节 key 循环验证,完全吻合。
六、验证
6.1 块 9 解密成合法字节码
块9 ^ 'yb6tcr' =
95 00 5b 01 00 00 00 00 00 00 00 00 53 01 35 01 00 00 00 00 00 00 3a 10 28 16
用 Python dis 反汇编(与原始 my_function 占位代码结构一致):
RESUME 0
LOAD_GLOBAL 1 (print)
LOAD_CONST 1
CALL 1
POP_TOP
RETURN_CONST 0
即"打印 consts[1]"的合法代码。
6.2 块 10 解密成目标字符串
块10 ^ 'yb6tcr' = e6 81 ad e5 96 9c e6 88 90 e5 8a 9f ef bc 81 = "恭喜成功!"
6.3 真实运行验证
a)加载作者 DLL,手动还原并调用 my_function(),实际打印 恭喜成功!。
b)向原 题目.exe 管道输入 yb6tcr,程序输出:
提示,需要还原的代码
def my_function():
print("恭喜成功!")
请输入key还原代码(请输入6个字符)
恭喜成功!
验证通过 ✅