-
-
[原创]看雪·2026 KCTF 第二题:巳时·绿光幽语
-
发表于: 3天前 2
-
题目:第二题 · 巳时 · 绿光幽语(Reverse,难度 220 / 精巧 50)
附件:题目.rar → 题目.exe(7,438,179 字节)
SHA256:F6B192F9EE87C4AD2BC8681291CA428CF8E98FEC26FA5D7B82C6E791A7A90EAD
答案:
一层套一层的藏匿题,真正的算法只有几行:
7 个节区加起来到文件偏移 0x54600 就结束了,而文件有 7.4 MB——7,092,579 字节的 overlay。overlay 开头是 78 DA(zlib best-compression)。
文件尾 88 字节是 PyInstaller 的 cookie:
按 CArchive 格式(每项 18 字节头 + 变长名)解出 21 项:
[s] 是 PYSOURCE,三项里 main 就是打包的用户脚本,159 字节。它是裸的 marshal code object(无 pyc 头),直接 marshal.loads 就能读:
即:
另两项 pyiboot01_bootstrap / pyi_rth_inspect 对比常量表确认是原版 PyInstaller 运行时钩子,没动过。PYZ 里 112 个模块全是标准库,base_library.zip 154 个成员也干净。
到这里如果只顺着"PyInstaller → 解包 → 反编译主脚本"的常规路子走,就断了。
输出是 GBK。程序显然是活的,那提示串一定在某处。
把提示串按 GBK 编码当作探针(提示 = CC E1 CA BE)在 exe 本体和全部解包产物里搜:
这就是题眼。命中位置在 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>。关键在于执行顺序:
所以主脚本是空的完全说得通。
在 DLL 里从命中点往回扫,找能被 marshal.loads 成功解析、且子树里含 my_function 的最靠后的起点:
对比真实 os.py,多出来一个函数:
宿主机只有 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 里成对相邻:
校验通过(每个名字唯一):
3.13 的指令一律 2 字节(opcode, arg),inline cache 以 CACHE(opcode 0)实体存在于字节流中。所以线性扫描 + 跳过 0 号 opcode 就能得到正确的指令序列与操作数,写个几十行的最小反汇编器即可。
这个办法对控制流不可靠,见第 9 节。但读算法够用了。
<frozen os> 模块级字节码尾部(偏移 2770 起):
(BINARY_OP 的 nb_op 编号:0 +、6 %、12 ^、13 +=。)
等价源码:
那 8 段 × 16 = 128 字节(故事里的"十六个存储扇区")异或 0x55:
常量表里躺着的 0x55 得到印证。这一步顺手确认了整段逻辑的还原是对的。
c 是 26 字节、重复异或、密钥 6 字节。明文是被约束死的。
my_function 的函数体只可能是 print("…"),在 CPython 3.13 下编译结果唯一:
正好 26 字节。 这不是猜——同一个 DLL 的冻结模块里还有 4 处 co_code 与它逐字节相同(任何 def f(): print(x) 都编译成这个),可以当交叉验证。
再看 DLL 里实际装着的 my_function.co_code:
与明文只差字节 0、2、3——RESUME 和 LOAD_GLOBAL print 被抹掉了。也就是说随包发布的 my_function 是个残废壳子,必须靠 key 还原。
异或:
26 字节全部呈严格 6 周期,且是可打印 ASCII:
① 周期自洽:26/26 字节密钥流严格 6 周期,随机巧合概率可忽略。
② 第二段密文独立印证:15 字节的 co_consts 密文用同一密钥解:
合法 UTF-8,且正是提示里显示的那句 print("恭喜成功!")。这段密文的推导完全独立于第 7 节的 crib。
③ 实机:
ExitProcess(0),单次循环即退出(错误 key 会一直重问)。
如实说明。
注入块前面有一处门禁,反汇编读到的是 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 之间浮动,说明边界判定有误,跳转目标随之算偏。
我做了对照实验来确认这是工具问题而不是题目做了手工汇编——拿未被篡改的标准库函数跑同样的校验(跳转目标是否落在合法指令边界上):