首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[原创] 看雪·2026 KCTF —— 第二题:巳时·绿光幽语 Writeup
发表于: 2026-8-11 13:14
58
[原创] 看雪·2026 KCTF —— 第二题:巳时·绿光幽语 Writeup
yuzhouheike
2026-8-11 13:14
58
# 看雪·2026 KCTF —— 第二题:巳时·绿光幽语 Writeup > 题目:`题目.exe`(出题战队:one team) > 类型:Reverse > 答案:注册码 `yb6tcr` --- ## 0. 样本信息 ``` 题目.rar 7,255,261 bytes sha256 ce817ebd7499be0ba081bbe1572dced64ac1b6a9391fc2955237c189860fb531 题目.exe 7,438,179 bytes sha256 f6b192f9ee87c4ad2bc8681291ca428cf8e98fec26fa5d7b82c6e791a7a90ead ``` `PE32+ console x86-64`,编译时间 2026-03-06。7 MB 的体积里,真正的题眼只有几百字节,而且藏在一个几乎不会有人去看的地方。 整题的结构可以概括成一句话:**把逻辑塞进被重新编译过的 CPython 解释器里,而不是塞进脚本。** --- ## 1. 表层:PyInstaller onefile `rabin2 -S` 显示节区只到 `0x54600`,后面 7 MB 全是 overlay。overlay 开头是 `78 da`(zlib),文件末尾是 `python313.dll` + 一串 0 —— 典型的 PyInstaller cookie 结构: ```c struct COOKIE { char magic[8]; // MEI\014\013\012\013\016 uint32 pkgLen; // 7092579 uint32 toc; // 7091627 uint32 tocLen; // 864 uint32 pyvers; // 313 char pylibname[64]; // "python313.dll" }; ``` cookie 位于 `0x717f0b`,`pkgStart = cookiePos + 88 - pkgLen = 0x54600`,正好接在最后一个节区之后。TOC 只有 21 项: ``` m struct s pyiboot01_bootstrap b _decimal.pyd b python313.dll m pyimod01_archive s pyi_rth_inspect b _hashlib.pyd b select.pyd m pyimod02_importers s main b _lzma.pyd b unicodedata.pyd m pyimod03_ctypes b VCRUNTIME140.dll b _socket.pyd o pyi-contents-directory _internal m pyimod04_pywin32 b _bz2.pyd b base_library.zip z PYZ.pyz b libcrypto-3.dll ``` **入口脚本 `main` 只有 159 字节**,unmarshal 后 `co_names = ('sys','exit')`、`co_consts = (0, None)`,还原出来就是: ```python import sys sys.exit(0) ``` `PYZ.pyz` 里 112 个模块全是标准库,`base_library.zip` 154 项也全是标准库。**Python 层什么都没干。** 到这里第一个岔路口出现了:很容易以为解错方向或者拿错文件。 --- ## 2. 排除法:证明原生代码没被动过 与其在 190 KB 的 bootloader 里大海捞针,不如直接和上游二进制对比。PyInstaller 的 Windows wheel 里带着预编译的 `run.exe`,从 PyPI 把 6.11 ~ 6.22 全撸下来逐个比 `.text`: ``` pyi-6.18.0 .text=186880 match=35561 exact=False pyi-6.19.0 .text=186880 match=35561 exact=False pyi-6.20.0 .text=186880 match=186880 exact=True ← 逐字节相同 pyi-6.21.0 .text=187392 match=6873 exact=False ``` 命中 **PyInstaller 6.20.0**。再逐段 diff: | 节区 | 结果 | |---|---| | `.text` | **完全相同**(186880 字节) | | `.data` / `.pdata` / `.fptable` / `.reloc` | 完全相同 | | `.rdata` | 仅 3 字节不同 @ `0xfcc4`:`8793aa69` vs `2632e969` —— PE 时间戳副本 | | `.rsrc` | 新增段(stock 的 `run.exe` 没有资源) | | PE 头 0..0x400 | 40 字节差异,全部是"多了一个节区"的连带后果(节区数 6→7、校验和、资源目录项、时间戳) | `.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、资源、归档布局全部干净**。剩下唯一可能被做手脚的,就是归档里那些二进制。 --- ## 3. 定位:被换掉的 `python313.dll` 对归档内所有 PE 做体检(节区末尾 vs 文件大小): ``` _bz2.pyd overlay=14168 ts=2026-04-07 _decimal.pyd overlay=14168 ts=2026-04-07 _hashlib.pyd overlay=14168 ts=2026-04-07 _socket.pyd overlay=14168 ts=2026-04-07 select.pyd overlay=14168 ts=2026-04-07 unicodedata.pyd overlay=14168 ts=2026-04-07 libcrypto-3.dll overlay=14192 ts=2026-02-13 python313.dll overlay= 0 ts=2026-05-19 ← 唯一没有 Authenticode 签名的 ``` 那 14168 字节的 overlay 是 Authenticode 数字签名。**同一套 Python 发行版里所有 `.pyd` 都签了名,唯独主 DLL 没有** —— 这是最硬的一个信号。 再和官方比大小: ``` 官方 python-3.13.1-embed-amd64 里的 python313.dll : 6,093,816 题目里的 python313.dll : 7,127,552 (+1 MB) ``` 字符串 diff 直接把作者的构建环境暴露了: ``` H:\样本分析\789456\Python-3.13.1\PCbuild\amd64\python313.pdb H:\样本分析\789456\Python-3.13.1\Objects\codeobject.c H:\样本分析\789456\Python-3.13.1\Python\import.c ... [MSC v.1941 64 bit (AMD64)] ``` 作者从源码自己编了一份 CPython 3.13.1(非 PGO,所以比官方大 1 MB)。**题目逻辑在解释器里。** --- ## 4. 题眼:植入 frozen `os` 模块 自编 CPython 的改动无法用字节 diff 定位(整个二进制布局都变了),得换思路 —— 从字符串里找不属于 CPython 的东西。过滤掉双方共有的字符串后,几个刺眼的残片浮出来: ``` my_function bmucryb6tcr*c co_consts) ``` `co_consts)` 这种带右括号的形态,是 marshal 里 `)\x01\xda\tco_consts`(长度 1 的元组 + interned 短字符串)—— 说明这附近是**一段被 marshal 进 DLL 的 code object**。 Python 3.13 的 frozen 模块在 `python313.dll` 里就是一段段裸 marshal 数据,一个挨一个排布。顺序 unmarshal 可以把整张表走出来: ``` 0x4bc520 <frozen importlib._bootstrap> 47420 → 56860 0x4ca340 <frozen importlib.machinery> 1069 0x4ca770 <frozen posixpath> 17899 0x4ced60 <frozen __phello__.ham> 104 0x4cedd0 <frozen _sitebuiltins> 4872 0x4d00e0 <frozen os> 47420 ← 就是它 0x4dba20 <frozen importlib.util> 11840 0x4de860 <frozen __phello__> 332 ``` 把 `<frozen os>` 反出来,函数列表最后多了一个 CPython 里根本不存在的 **`my_function`**: ```python [..., 'add_dll_directory', 'process_cpu_count', 'my_function'] ``` 选 `os` 下手很讲究:**解释器启动阶段必然 import `os`**,所以模块级代码一定会跑,`main.py` 干不干活完全无所谓。 ### 4.1 被注入的模块级代码 反汇编 `<frozen os>` 模块级字节码的尾部(`dis` 需要用真正的 CPython 3.13,否则 opcode 表对不上),还原出来是: ```python x = 0 while x < 20: # 最多 20 次机会 def my_function(): print(666) x += 1 buffer = b''.join([<8 个 16 字节的密文块>]) w = b'' for i in buffer: w += (i ^ 85).to_bytes(1) print(w.decode('utf-8')) # 打印提示 key = input() if len(key) != 6: # 长度必须是 6 continue b_key = key.encode() c = b'\xecbmucryb6tcr*c\x03ucryb6tCr\x1eb' # 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) # ← 用解密结果当新字节码 succ = b'\x9f\xe3\x9b\x91\xf5\xee\x9f\xea\xa6\x91\xe9\xed\x96\xde\xb7' # 15 字节 buf = b'' 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'))) my_function.__code__ = my # 把函数整个换掉 my_function() ``` 对应的模块常量: | 索引 | 内容 | |---|---| | 142 | `20`(重试次数上限) | | 143 | `<code object my_function>` | | 145 | 8 个 16 字节密文块(提示文本,XOR 85) | | 146 | `85` | | 148 | `6`(key 长度) | | 149 | `c`,26 字节 | | 151 | `succ`,15 字节 | 先把提示解出来(`XOR 85`): ``` 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) ``` 题目把要求说得很清楚了:**找到 6 字符的 key,让 `my_function` 被还原成能打印「恭喜成功!」的样子。** --- ## 5. 已知明文攻击 重复密钥异或本身不难,难点是"正确的明文长什么样"。这里作者留了一个致命的巧合: ```python >>> len(my_function.__code__.co_code) 26 >>> len(c) 26 ``` **密文 `c` 和 `my_function` 原始字节码等长。** 而 `co_consts` 是被单独替换的(`(None, 解密出的字符串)`),也就是说 `LOAD_CONST 1` 这条指令本身不用变,只是它取到的常量从 `666` 变成了那句中文 —— 所以**还原目标就是原始字节码本身,一个字节都不用改**。 `my_function` 原始字节码: ``` 95 00 RESUME 0 5b 01 00 00 00 00 00 00 00 00 LOAD_GLOBAL 1 (print + NULL) 53 01 LOAD_CONST 1 (666) 35 01 00 00 00 00 00 00 CALL 1 20 00 POP_TOP 67 00 RETURN_CONST 0 (None) ``` 直接异或: ``` c ec 62 6d 75 63 72 79 62 36 74 63 72 2a 63 03 75 63 72 79 62 36 74 43 72 1e 62 co_code 95 00 5b 01 00 00 00 00 00 00 00 00 53 01 35 01 00 00 00 00 00 00 20 00 67 00 ------------ XOR ---------------------------------------------------------------------- 79 62 36 74 63 72 79 62 36 74 63 72 79 62 36 74 63 72 79 62 36 74 63 72 79 62 y b 6 t c r y b 6 t c r y b 6 t c r y b 6 t c r y b ``` 完美的 6 字节周期,**key = `yb6tcr`**,白送。 ### 双向验证 用这个 key 解另一段密文 `succ`: ``` succ ^ yb6tcr = '恭喜成功!' ``` 正好就是提示里承诺的那句输出。两条独立约束(字节码必须合法且等于原码、成功串必须是合法 UTF-8 且内容对得上)同时满足,密钥唯一。 --- ## 6. 一键 solver [`solve_q2.py`](solve_q2.py):从原始 exe 一路自动解到 key,**全程静态,不需要运行样本、不需要 Windows**。 ```bash python3.13 solve_q2.py 题目.exe ``` ``` [*] PyInstaller archive: python=313 lib=python313.dll entries@0x6c35ab [*] python313.dll: 7127552 bytes [*] <frozen os> @ 0x4d00e0 === 程序提示(8×16 字节 XOR 85)=== 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) === 恢复的 key(周期 6)=== yb6tcr === 校验 === new co_code == 原始 co_code : True succ ^ key : '恭喜成功!' [+] 注册码 = yb6tcr ``` 脚本做四件事:解 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` 之类完全不着调的指令。 --- ## 7. 答案 ``` yb6tcr ``` 提交返回: ```json { "code": "0", "message": "哟, 不错哦,答对了!!" } ``` --- ## 8. 小结 这题的难度不在密码学(重复密钥异或 + 已知明文,两行代码的事),而在**信息藏得够深,且前面铺了一条完整的死路**: 1. **PyInstaller 是幌子。** 入口脚本 `import sys; sys.exit(0)`,PYZ 和 base_library 全是标准库。老老实实用 pyinstxtractor + 反编译走到底,会得到"这文件什么都没有"的结论。 2. **bootloader 也是幌子。** 与 PyInstaller 6.20.0 官方 `run.exe` 的 `.text` 逐字节相同。这一步反而是最有价值的——**用上游二进制做对照,能把 190 KB 的搜索空间一次性清零**,比在 IDA 里瞎翻高效得多。 3. **真正的载体是被重编的解释器。** 判定依据有三条,任意一条都够:主 DLL 缺 Authenticode 签名而同套 `.pyd` 都有签名、体积比官方大 1 MB、字符串里带着 `H:\样本分析\789456\Python-3.13.1\` 的构建路径。 4. **落点是 frozen `os`。** 选 `os` 是因为解释器启动必定 import 它,天然拿到执行权,完全绕开了脚本层。 5. **自修改字节码。** 用 `code.replace(co_code=...)` + `func.__code__ = ...` 在运行期换掉函数体,动态调试时看到的和静态反编译看到的不是一个东西。 而作者留下的破绽也很干净:**替换后的字节码和原始字节码等长且完全相同**(因为常量是单独换的)。这等于把明文直接放在了旁边,重复密钥异或在已知明文面前一击即溃。 题面里那句"文件分段加密:古典密码、时间戳算法、硬件密钥"和"十六个碎片分散在十六个存储扇区"纯属剧情包装 —— 实际只有一层重复密钥异或,和 8 个 16 字节的提示文本块。
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
yuzhouheike
9
发帖
38
回帖
60
RANK
关注
私信
他的文章
KCTF 第十题:卯时·曦光初现 - PWN Writeup
3201
[原创] 第九题:丑寅同墟·星海抉择
55
[原创] 看雪 KCTF 2026 第五题《申时·忆海倒带》Writeup
22
[原创] 看雪·2026 KCTF —— 第二题:巳时·绿光幽语 Writeup
58
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部