2026KCTF|第二题《巳时·绿光幽语》设计思路及解析"

admin 2026-10-18 05:30:55 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

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 字节的提示文本块。

球分享

球点赞

球在看


    2026KCTF|第二题《巳时·绿光幽语》设计思路及解析" 网络安全文章

    2026KCTF|第二题《巳时·绿光幽语》设计思路及解析"

    2026KCTF第二题《巳时·绿光幽语》是一道逆向分析题,核心思路是将校验逻辑隐藏在重新编译的python313.dll中,通过植入frozenos模块在main.py执行前触发。解题需分析PyInstaller打包结构,定位被修改的DLL,提取frozen模块代码,并利用XOR密钥还原字节码。题目展示了AI对CTF竞赛效率的影响,出题者需思考未来赛题如何有效区分选手的分析与创新能力。
    第9篇-勒索软件恢复与业务连续性建设" 网络安全文章

    第9篇-勒索软件恢复与业务连续性建设"

    本文系统构建勒索软件场景下的业务连续性与灾难恢复体系,涵盖BCP/DRP框架适配、RTO/RPO设定方法论、业务影响分析及3-2-1-1-0备份原则。核心结论是组织需采用不可变备份、离线存储和定期演练来对抗勒索软件加密与删除威胁。可操作建议包括按业务优先级设定恢复目标、实施持续数据保护并验证备份完整性。
    公安部新规10月1日施行:算法安全首次纳入检查,这11项请对照自查" 网络安全文章

    公安部新规10月1日施行:算法安全首次纳入检查,这11项请对照自查"

    公安部发布第176号令《公安机关网络空间安全监督检查办法》,自2026年10月1日起施行,监管从互联网安全升级为网络空间安全。新规新增数据处理者等检查对象,首次将算法安全纳入检查,并明确11项重点检查内容。企业应开展自查,完善等保测评档案,建立应急预案,加强算法安全管理,以提前做好合规准备。
    评论:0   参与:  0