首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
0
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
发表于: 2026-8-11 13:00
88
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
correy
4
2026-8-11 13:00
88
# KCTF 2026 · 巳时「绿光幽语」 Writeup > **题目**:第二题 · 巳时 · 绿光幽语(Reverse,难度 220 / 精巧 50) > **附件**:`题目.rar` → `题目.exe`(7,438,179 字节) > **SHA256**:`F6B192F9EE87C4AD2BC8681291CA428CF8E98FEC26FA5D7B82C6E791A7A90EAD` **答案:** ``` yb6tcr ``` --- ## 0. TL;DR 一层套一层的藏匿题,真正的算法只有几行: 1. 外壳是 **PyInstaller onefile**(Python 3.13)。解包后发现打包的用户脚本 `main.py` 只有 159 字节,内容是 `import sys; sys.exit(0)`——**纯诱饵**。 2. 真正的载荷藏在被打包进去的 **`python313.dll`** 里:作者篡改了 CPython 的**冻结模块 `<frozen os>`**,在模块末尾注入了一段代码。冻结模块在解释器启动阶段就执行,**远早于 `main.py`**,所以主脚本立刻退出毫不影响。 3. 注入的逻辑是:读 6 字符 key,用它对一段 26 字节密文做**重复异或**,结果直接当作 `my_function.__code__.co_code` 装回去;另一段 15 字节密文同样处理后成为 `co_consts`。 4. 而这 26 字节的明文是**被结构完全约束死的**——函数体只可能是 `print("…")`,在 3.13 下编译结果唯一。已知明文一异或,密钥流呈严格 6 周期:`yb6tcr`。 --- ## 1. 外壳侦察 ``` $ file 题目.exe PE32+ executable for MS Windows 6.00 (console), x86-64, 7 sections ``` 7 个节区加起来到文件偏移 `0x54600` 就结束了,而文件有 7.4 MB——**7,092,579 字节的 overlay**。overlay 开头是 `78 DA`(zlib best-compression)。 文件尾 88 字节是 PyInstaller 的 cookie: ``` struct: !8sIIii64s magic = 'MEI\x0c\x0b\n\x0b\x0e' pkglen = 7092579 # 正好等于 overlay 长度 tocpos = 0x6c35ab toclen = 864 pyvers = 313 pylib = 'python313.dll' ``` 按 CArchive 格式(每项 18 字节头 + 变长名)解出 21 项: ``` [m] struct [s] pyiboot01_bootstrap [b] VCRUNTIME140.dll [m] pyimod01_archive [s] pyi_rth_inspect [b] _bz2.pyd [m] pyimod02_importers [s] main <--- [b] _decimal.pyd [m] pyimod03_ctypes [b] _hashlib.pyd [m] pyimod04_pywin32 [b] _lzma.pyd [o] pyi-contents-directory _internal [b] _socket.pyd [z] PYZ.pyz (112 modules) [b] base_library.zip [b] libcrypto-3.dll [b] python313.dll <--- [b] select.pyd [b] unicodedata.pyd ``` ## 2. 第一个诱饵 `[s]` 是 PYSOURCE,三项里 `main` 就是打包的用户脚本,**159 字节**。它是裸的 marshal code object(无 pyc 头),直接 `marshal.loads` 就能读: ```python >>> c = marshal.loads(open('main','rb').read()) >>> c.co_consts, c.co_names ((0, None), ('sys', 'exit')) ``` 即: ```python import sys sys.exit(0) ``` 另两项 `pyiboot01_bootstrap` / `pyi_rth_inspect` 对比常量表确认是**原版 PyInstaller 运行时钩子**,没动过。PYZ 里 112 个模块全是标准库,`base_library.zip` 154 个成员也干净。 到这里如果只顺着"PyInstaller → 解包 → 反编译主脚本"的常规路子走,就断了。 ## 3. 直接跑一遍 ``` $ printf 'abcdef\n' | ./题目.exe 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) ...(循环) ``` 输出是 **GBK**。程序显然是活的,那提示串一定在某处。 ## 4. 定位真正的载荷 把提示串按 GBK 编码当作探针(`提示` = `CC E1 CA BE`)在 exe 本体和全部解包产物里搜: * `题目.exe` 本体:**0 命中**(`.rdata` / `.data` 里没有任何有意义的中文串) * 解包产物:`my_function` 命中 **`python313.dll` 偏移 `0x4dace9`** 这就是题眼。命中位置在 DLL 的 `.rdata`(VA `0x3a9000`,文件 `0x3a7600`)——**CPython 的冻结模块区**。 ### 为什么是冻结模块 CPython 3.11+ 会把一批启动必需的模块 deep-freeze 进解释器:`importlib._bootstrap`、`importlib._bootstrap_external`、`zipimport`、`abc`、`codecs`、`io`、`_collections_abc`、`os`、`site`……它们以 marshal 好的 code object 形式躺在 `.rdata` 里。 作者篡改的是 **`<frozen os>`**。关键在于执行顺序: ``` PyInstaller bootloader └─ Py_Initialize └─ import os ← 冻结模块,此处执行注入代码(题目在这里跑完) └─ 运行 pyiboot01_bootstrap └─ 运行 pyi_rth_inspect └─ 运行 main.py ← sys.exit(0),此时题目早已结束 ``` 所以主脚本是空的完全说得通。 ### 取出模块 在 DLL 里从命中点往回扫,找能被 `marshal.loads` 成功解析、且子树里含 `my_function` 的最靠后的起点: ``` <frozen os> code object @ file 0x4d00e0 co_filename = '<frozen os>' ``` 对比真实 `os.py`,多出来一个函数: ``` line 1173 process_cpu_count ← 原版就有 line 1188 my_function ← 注入 ``` ## 5. 工具问题:3.14 的机器怎么读 3.13 的字节码 宿主机只有 Python 3.14 和 3.10,目标是 3.13。3.14 的 `marshal` 能加载 3.13 的 code object,但 `dis` 的操作码编号对不上,直接反汇编全是错的。 不联网也能解决——**题目自己带着 3.13 的操作码表**。PYZ 里有 3.13 的 `_opcode_metadata.pyc`,它的 `opmap` 是个字面量字典,编译后键值在 `co_consts` 里成对相邻: ```python c = marshal.loads(open('_opcode_metadata.pyc','rb').read()[16:]) ks = c.co_consts opmap = {} for i in range(len(ks)-1): if isinstance(ks[i], str) and type(ks[i+1]) is int: opmap.setdefault(ks[i], ks[i+1]) ``` 校验通过(每个名字唯一): ``` RESUME 149 LOAD_CONST 83 LOAD_GLOBAL 91 LOAD_ATTR 82 CALL 53 STORE_NAME 114 BINARY_OP 45 COMPARE_OP 58 EXTENDED_ARG 71 RETURN_CONST 103 ``` 3.13 的指令一律 2 字节(opcode, arg),inline cache 以 `CACHE`(opcode 0)实体存在于字节流中。所以线性扫描 + 跳过 0 号 opcode 就能得到正确的**指令序列与操作数**,写个几十行的最小反汇编器即可。 > 这个办法对**控制流**不可靠,见第 9 节。但读算法够用了。 ## 6. 还原注入的代码 `<frozen os>` 模块级字节码尾部(偏移 2770 起): ``` 2770 LOAD_CONST <code my_function, line 1188> 2772 MAKE_FUNCTION 2774 STORE_NAME my_function 2788 LOAD_CONST b'' 2790 LOAD_ATTR join +self 2810 BUILD_LIST 0 2812 LOAD_CONST (8 个 16 字节的 bytes) 2814 LIST_EXTEND 1 2816 CALL 1 2824 STORE_NAME buffer ... 2884 BINARY_OP 13 (+=) 2888 STORE_NAME w 2890 JUMP_BACKWARD 2924 LOAD_CONST 'utf-8' 2942 POP_TOP # print(w.decode('utf-8')) 2944 LOAD_NAME input 2956 STORE_NAME key 2958 LOAD_NAME len 2972 LOAD_CONST 6 2974 COMPARE_OP (!=) 2982 JUMP_BACKWARD # len != 6 -> 重来 3008 CALL 0 3016 STORE_NAME b_key # key.encode() 3018 LOAD_CONST b'\xecbmucryb6tcr*c\x03ucryb6tCr\x1eb' 3020 STORE_NAME c 3026 LOAD_NAME range 3054 FOR_ITER 3058 STORE_NAME i 3080 CALL 1 # len(b_key) 3088 BINARY_OP 6 (%) 3092 BINARY_SUBSCR # b_key[i % len(b_key)] 3096 BINARY_OP 12 (^) # c[i] ^ ... 3100 LOAD_ATTR to_bytes +self 3122 CALL 1 3130 BINARY_OP 13 (+=) 3134 STORE_NAME buf 3144 LOAD_NAME my_function 3168 STORE_NAME my 3194 LOAD_NAME buf ``` (`BINARY_OP` 的 `nb_op` 编号:`0 +`、`6 %`、`12 ^`、`13 +=`。) 等价源码: ```python buffer = b''.join([ <8 段 × 16 字节> ]) # 128 字节 w = b'' for i in buffer: w += (i ^ 0x55).to_bytes(1) print(w.decode('utf-8')) # 打印提示与待还原源码 key = input() if len(key) != 6: <重来> 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 # buf -> my.__code__.co_code # 另一段 15 字节密文同样处理 -> co_consts my() ``` ### 提示文本 那 8 段 × 16 = **128 字节**(故事里的"十六个存储扇区")异或 `0x55`: ``` 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) ``` 常量表里躺着的 `0x55` 得到印证。这一步顺手确认了整段逻辑的还原是对的。 ## 7. 已知明文攻击 `c` 是 26 字节、重复异或、密钥 6 字节。**明文是被约束死的。** `my_function` 的函数体只可能是 `print("…")`,在 CPython 3.13 下编译结果唯一: ``` 95 00 RESUME 0 5b 01 LOAD_GLOBAL 1 # (print, +NULL) 00 00 ×4 CACHE ×4 # LOAD_GLOBAL 有 4 个 inline cache 53 01 LOAD_CONST 1 # 那个字符串 35 01 CALL 1 00 00 ×3 CACHE ×3 # CALL 有 3 个 20 00 POP_TOP 67 00 RETURN_CONST 0 # None ``` **正好 26 字节。** 这不是猜——同一个 DLL 的冻结模块里还有 4 处 co_code 与它逐字节相同(任何 `def f(): print(x)` 都编译成这个),可以当交叉验证。 再看 DLL 里**实际装着的** `my_function.co_code`: ``` 34 00 00 00 00 00 00 00 00 00 00 00 53 01 35 01 00 00 00 00 00 00 20 00 67 00 ``` 与明文只差字节 **0、2、3**——`RESUME` 和 `LOAD_GLOBAL print` 被抹掉了。也就是说随包发布的 `my_function` 是个残废壳子,必须靠 key 还原。 异或: ```python c = b'\xecbmucryb6tcr*c\x03ucryb6tCr\x1eb' plain = bytes.fromhex('9500 5b01 '+'00'*8+' 5301 3501 '+'00'*6+' 2000 6700') key = bytes(a ^ b for a, b in zip(c, plain)) # -> b'yb6tcryb6tcryb6tcryb6tcryb' ``` **26 字节全部呈严格 6 周期**,且是可打印 ASCII: ``` KEY = yb6tcr ``` ## 8. 三重验证 **① 周期自洽**:26/26 字节密钥流严格 6 周期,随机巧合概率可忽略。 **② 第二段密文独立印证**:15 字节的 `co_consts` 密文用同一密钥解: ``` 9f e3 9b 91 f5 ee 9f ea a6 91 e9 ed 96 de b7 ^ yb6tcr (repeating) e6 81 ad e5 96 9c e6 88 90 e5 8a 9f ef bc 81 恭 喜 成 功 ! ``` 合法 UTF-8,且正是提示里显示的那句 `print("恭喜成功!")`。这段密文的推导完全独立于第 7 节的 crib。 **③ 实机**: ```bash printf 'yb6tcr\n' | ./题目.exe ``` ``` 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) 恭喜成功! ``` `ExitProcess(0)`,单次循环即退出(错误 key 会一直重问)。 ## 9. 没有解干净的部分 如实说明。 注入块前面有一处门禁,反汇编读到的是 `LOAD_NAME cpu_count` / `LOAD_CONST 20` / `COMPARE_OP` / `POP_JUMP_IF_FALSE`(跳过整块)。**这段我没有静态解出确定语义。** 原因是我这个自制反汇编器对**控制流**不可靠:3.13 里 inline cache 数量按指令类型固定,而我用的是"把后续连续的 0 号 opcode 一律当作本指令的 cache"这一启发式。对 `LOAD_NAME` 实测到的 0 值游程长度在 4–12 之间浮动,说明边界判定有误,跳转目标随之算偏。 我做了对照实验来确认这是工具问题而不是题目做了手工汇编——拿**未被篡改的**标准库函数跑同样的校验(跳转目标是否落在合法指令边界上): ``` os._exists instrs= 4 bad targets=0 os.fsencode instrs= 18 bad targets=0 importlib._bootstrap MOD instrs=214 bad targets=0 os.makedirs instrs= 81 bad targets=6 ← 原版代码也报错 os.walk instrs=233 bad targets=10 ← 原版代码也报错 ib._verbose_message instrs= 26 bad targets=2 ← 原版代码也报错 ``` 原版函数同样出错,**所以是我的解码器的问题,不能据此推断题目字节码被手工拼装过**。 这一处不影响结论:门禁在普通机器上是通过的(实机跑通即证),而且密钥的推导(第 7 节)完全不依赖它。要把这段读准,最省事的办法是装一个真正的 Python 3.13,用它自带的 `dis` 跑: ```bash uv python install 3.13 ``` ## 10. 小结 考点分三层,一层比一层"藏": 1. **打包层的错误引导** —— PyInstaller 是标准套路,解包→反编译主脚本也是标准套路。作者把主脚本做成 `sys.exit(0)`,专治肌肉记忆。 2. **运行时层的藏匿** —— 把代码塞进随包分发的 `python313.dll` 的**冻结模块**里。这一步的巧妙之处在于利用了 CPython 的启动顺序:冻结的 `os` 在 `main.py` 之前执行,且没有任何 `.py` / `.pyc` 落地,常规的 pyc 扫描工具全都看不见。定位它的办法很朴素——**拿运行时可见的字符串当探针,去搜所有解包产物**。 3. **密码层** —— 反而是最弱的一环:6 字节重复异或,而明文是 CPython 字节码,结构完全确定。只要意识到 `print("…")` 在 3.13 下的编译结果唯一且恰好 26 字节,key 就是白给。 顺带一提,工程上有个小启发:**逆向 frozen module 时,目标解释器版本的操作码表可以从目标自己身上拿**——`_opcode_metadata.pyc` 就在它的 PYZ 里躺着,不用联网、不用装对应版本,就能起步。 --- ### 附:关键偏移速查 | 位置 | 内容 | |---|---| | `题目.exe` `0x54600` | CArchive 起点(overlay,7,092,579 B) | | `题目.exe` `0x717f0b` | PyInstaller cookie(`MEI\x0c\x0b\n\x0b\x0e`) | | 归档项 `main` | 诱饵脚本,159 B,`import sys; sys.exit(0)` | | `python313.dll` `0x4d00e0` | `<frozen os>` 模块 code object 起点 | | `python313.dll` `0x4dace9` | 字符串 `my_function` | | `<frozen os>` line 1188 | 注入的 `my_function` | | 模块字节码 2770– | 注入的模块级逻辑 | | — | `buffer` 8×16 B,XOR `0x55` → 提示文本 | | — | `c` 26 B,XOR `yb6tcr` → `my_function.co_code` | | — | 15 B 密文,XOR `yb6tcr` → `恭喜成功!` |
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
mb_fabrnyzx
雪 币:
200
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
13
粉丝
0
关注
私信
mb_fabrnyzx
2
楼
666
5天前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
correy
4
69
发帖
150
回帖
415
RANK
关注
私信
他的文章
[分享]2026 KCTF 第十题「卯时·曦光初现」(Writeup · Pwn)
1624
[分享]第九题:丑寅同墟·星海抉择
39
[分享]kctf2026_CrackMe08 题解
45
[分享]KCTF 2026 · cm.exe 逆向分析
37
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
87
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部