文章总结: 2026KCTF第二题《巳时·绿光幽语》是一道逆向分析题,核心设计是将校验逻辑隐藏在重新编译的python313.dll中,并植入frozenos模块,使代码在main.py执行前触发。解题需通过对比二进制文件定位被修改的DLL,分析字符串找到植入的frozen模块,并利用异或算法还原字节码。题目考验选手对程序运行链路和Python字节码的敏感度,而非单纯依赖工具。 综合评分: 88 文章分类: 逆向分析,二进制安全,CTF,安全工具,实战经验
2026 KCTF | 第二题《巳时·绿光幽语》设计思路及解析
2026 KCTF 2026 KCTF
看雪学苑
2026年8月12日 18:06 上海
在小说阅读器读本章
去阅读
8月10日12:00,2026 KCTF攻防对抗赛正式开赛!本届赛事沿用多维度积分体系,难度、火力、精致度三重积分同步核算,攻防双向计分机制兼顾趣味性与竞技性。
*签到题《辰时·钟鸣破晓》赛事周期内持续开放,比赛全程都可以提交 flag 获取对应攻击积分。
值得关注:
本题的解题数据也折射出 AI 对 CTF 竞赛正在产生的结构性影响。
截止今天中午12点,第二题《巳时·绿光幽语》关卡已关闭。本题共有119支战队成功提交flag,『RST』战队“一血”仅用时9分58秒。这样的破解速度和覆盖面,在过去同等难度的 Reverse 题中并不常见。
去年我们更多看到的是少数选手开始尝试借助大模型辅助分析,可以说还是“由点到面”的扩散阶段;而到了今年,变化已经明显不只是“更多人开始使用 AI”,而是AI 正在实质性改变逆向题的解题效率、技术门槛和竞赛节奏。过去需要选手花较长时间完成的 PyInstaller 解包、Python 字节码分析、异常代码定位、算法还原等工作,如今在合适的工具链和提示引导下,AI 已经能够在很短时间内协助完成其中相当一部分。
对 CTF 出题者而言,这也意味着一个新的命题正在出现:当 AI 可以快速完成传统意义上的静态分析和模式识别后,未来的赛题究竟应该考什么,才能继续有效区分选手真正的分析、判断与创新能力?
我们带着这些问题和好奇
一起来看看本题的设计思路&解题思路
KCTF评委团队点评:
本题的亮点不在于复杂的密码算法,而在于对Python 打包与运行机制的巧妙利用。
题目以 PyInstaller 作为表层入口,却将真正的校验逻辑隐藏在重新编译的 python313.dll 中,并进一步植入 frozen os 模块,使代码在 main.py 执行之前即被触发,有效避开了常规“解包—反编译主脚本”的分析路径。
在最终校验阶段,题目没有采用直接比较字符串的方式,而是将用户输入作为循环 XOR 密钥,动态还原 my_function 的字节码与常量;而题目给出的目标输出又恰好构成已知明文,为选手留下了清晰但不直白的突破口。
整体来看,这是一道藏得深、算法简、思路巧的 Reverse 题,真正考验的是选手对程序运行链路、Python 字节码以及异常现象的敏感度,而非单纯依赖工具完成机械式反编译。
一起来看看本题的设计思路&解题思路
一、
设计思路
出题战队:one team
战队成员ID:穿甲葡萄籽
题目需要6个字符串,字符范围在小写大写数字中组成。成功后控制台会输出“恭喜成功!”,如果输出其他乱码字符或程序崩溃不算成功。
文件pyinstaller打包。将代码编写到os.py中,通过python源码编译把os.py编译到python.dll中。
用户可从python.dll中还原出os.py的代码。
根据提示的代码可获取到“print(“恭喜成功!”)”编译后的python字节马。
根据os.py中的分组异或代码可分析出异或的key。
x``=``0
while``x<``20``:
def``my_function():
print``(``666``)
try``:
x``+``=``1
buffer``=``b''.join([
b``'\xB3\xDA\xC5\xB2\xF1\xEF\xBA\xE9\xD9\xBC\xC9\xD5\xBD\xF3\xD4\xBD'``,
b``'\xEA\xCD\xB0\xDB\xCA\xB2\xCF\xD1\xB1\xEE\xF6\xB2\xF5\xD4\x58\x5F'``,
b``'\x31\x30\x33\x75\x38\x2C\x0A\x33\x20\x3B\x36\x21\x3C\x3A\x3B\x7D'``,
b``'\x7C\x6F\x58\x5F\x5C\x25\x27\x3C\x3B\x21\x7D\x77\xB3\xD4\xF8\xB0'``,
b``'\xC3\xC9\xB3\xDD\xC5\xB0\xDF\xCA\xBA\xE9\xD4\x77\x7C\x58\x5F\xBD'``,
b``'\xFA\xE2\xBD\xEB\xC6\xB0\xD0\xF0\x3E\x30\x2C\xBD\xEA\xCD\xB0\xDB'``,
b``'\xCA\xB1\xEE\xF6\xB2\xF5\xD4\xBA\xE9\xDD\xBD\xFA\xE2\xBD\xEB\xC6'``,
b``'\xB0\xD0\xF0\x63\xB1\xED\xFF\xB0\xF8\xC2\xB2\xF9\xF3\xBA\xE9\xDC'``])
w``=``b""
for``i``in``buffer``:
b``=``i^``0x55
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'
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)
buf``=``b""
succ``=``b``'\x9f\xe3\x9b\x91\xf5\xee\x9f\xea\xa6\x91\xe9\xed\x96\xde\xb7'
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()
break
except``Exception as e:
continue
二、
赛题解析
该解题思路由论坛会员【yuzhouheike】提供
样本信息
题目.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 结构:
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.pydb python313.dll
m pyimod01_archive s pyi_rth_inspect b _hashlib.pydb select.pyd
m pyimod02_importers s mainb _lzma.pydb unicodedata.pyd
m pyimod03_ctypes b VCRUNTIME140.dllb _socket.pyd o pyi-contents-directory _internal
m pyimod04_pywin32 b _bz2.pydb base_library.zip z PYZ.pyz
b libcrypto-3.dll
入口脚本 main 只有 159 字节,unmarshal 后 co_names = ('sys','exit')、co_consts = (0, None),还原出来就是:
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:
.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 <frozenimportlib._bootstrap> 47420 → 56860
0x4ca340 <frozenimportlib.machinery> 1069
0x4ca770 <frozenposixpath> 17899
0x4ced60 <frozen__phello__.ham> 104
0x4cedd0 <frozen_sitebuiltins> 4872
0x4d00e0 <frozenos> 47420 ← 就是它
0x4dba20 <frozenimportlib.util> 11840
0x4de860 <frozen__phello__> 332
把 <frozen os> 反出来,函数列表最后多了一个 CPython 里根本不存在的 my_function:
[..., 'add_dll_directory', 'process_cpu_count', 'my_function']
选 os 下手很讲究:解释器启动阶段必然 import os,所以模块级代码一定会跑,main.py 干不干活完全无所谓。
4.1 被注入的模块级代码
反汇编 <frozen os> 模块级字节码的尾部(dis 需要用真正的 CPython 3.13,否则 opcode 表对不上),还原出来是:
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()
对应的模块常量:
先把提示解出来(XOR 85):
提示,需要还原的代码
def my_function():
print("恭喜成功!")
请输入key还原代码(请输入6个字符)
题目把要求说得很清楚了:找到 6 字符的 key,让 my_function 被还原成能打印「恭喜成功!」的样子。
5. 已知明文攻击
重复密钥异或本身不难,难点是”正确的明文长什么样”。这里作者留了一个致命的巧合:
>>>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 b6 t c r y b6 t c r y b6 t c r y b6 t c r y b
完美的 6 字节周期,key = yb6tcr,白送。
双向验证
#
用这个 key 解另一段密文 succ:
succ ^ yb6tcr = '恭喜成功!'
正好就是提示里承诺的那句输出。两条独立约束(字节码必须合法且等于原码、成功串必须是合法 UTF-8 且内容对得上)同时满足,密钥唯一。
#
6. 一键 solver
solve_q2.py:从原始 exe 一路自动解到 key,全程静态,不需要运行样本、不需要 Windows。
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
提交返回:
{ "code": "0", "message": "哟, 不错哦,答对了!!" }
#
8. 小结
这题的难度不在密码学(重复密钥异或 + 已知明文,两行代码的事),而在信息藏得够深,且前面铺了一条完整的死路:
-
PyInstaller 是幌子。
入口脚本
import sys; sys.exit(0),PYZ 和 base_library 全是标准库。老老实实用 pyinstxtractor + 反编译走到底,会得到”这文件什么都没有”的结论。 -
bootloader 也是幌子。
与 PyInstaller 6.20.0 官方
run.exe的.text逐字节相同。这一步反而是最有价值的——用上游二进制做对照,能把 190 KB 的搜索空间一次性清零,比在 IDA 里瞎翻高效得多。 -
真正的载体是被重编的解释器。
判定依据有三条,任意一条都够:主 DLL 缺 Authenticode 签名而同套
.pyd都有签名、体积比官方大 1 MB、字符串里带着H:\样本分析\789456\Python-3.13.1\的构建路径。 -
落点是 frozen
os。选
os是因为解释器启动必定 import 它,天然拿到执行权,完全绕开了脚本层。 -
自修改字节码。
用
code.replace(co_code=...)+func.__code__ = ...在运行期换掉函数体,动态调试时看到的和静态反编译看到的不是一个东西。
而作者留下的破绽也很干净:替换后的字节码和原始字节码等长且完全相同(因为常量是单独换的)。这等于把明文直接放在了旁边,重复密钥异或在已知明文面前一击即溃。
题面里那句”文件分段加密:古典密码、时间戳算法、硬件密钥”和”十六个碎片分散在十六个存储扇区”纯属剧情包装 —— 实际只有一层重复密钥异或,和 8 个 16 字节的提示文本块。
球分享
球点赞
球在看
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 2026 KCTF 2026 KCTF《2026 KCTF | 第二题《巳时·绿光幽语》设计思路及解析》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论