sekai ctf 外卡赛 Discrepancy

题目源码
1 | ### IMPORTS ### |
可以观察到这三个函数
py_pickle_wrapper() | 使用 Python 实现的 pickle 解析器 解析字节序列,成功返回 True。 |
---|---|
c_pickle_wrapper() | 使用 C 实现的 pickle 解析器 解析字节序列,成功返回 True。 |
pickletools_wrapper() | 使用 pickletools.dis 反汇编字节序列,成功返回 True。 |
给出了三个判定器
- py_pickle_wrapper(b): 用 纯 Python _Unpickler 反序列化,重写 find_class(一旦调用即退出),成功返回 True,抛异常返回 False。
- c_pickle_wrapper(b): 用 C 加速 _pickle.Unpickler,同样重写 find_class,规则同上。
- pickletools_wrapper(b): pickletools.dis(b) 反汇编,若成功返回 True,异常为 False。
程序接受我们构造的有效的 pickle,取前 8 字节(bytes.fromhex(inp)[:8]
),所以每个测试项只允许用最多 8 字节的 pickle bytes。
然后下面还有5个check
Check 1
条件:py=True, c=True, dis=False
纯 Python unpickler 和 C unpickler 都能运行成功(返回 True),但 pickletools.dis
在静态分析/验证阶段抛异常或报错(返回 False)
Check 2
条件:py=False, c=True, dis=True
C 实现能接受并 .load()
成功;pickletools.dis
能正确反汇编;但纯 Python 实现会在 .load()
阶段抛出异常(返回 False)。
Check 3
条件:py=True, c=False, dis=True
纯 Python unpickler 能成功 load;pickletools.dis
能反汇编;而 C 实现(_pickle
)在 .load()
时抛异常或无法接受。
Check 4
条件:py=False, c=False, dis=True
pickletools
能反汇编(语法/静态检查通过),但两个运行时 Unpickler 在 .load()
阶段都会失败(返回 False)
Check 5
条件:py=False, c=True, dis=False
C unpickler 能成功;纯 Python unpickler失败;但 pickletools.dis
在静态反汇编/验证阶段也失败(不识别或抛异常)。
然后题目给了一段描述
让我去阅读pickle的源码,但是题目只给了八个有效识别的字节,我们完全可以本地模拟这三个反编译函数的实现从而来爆破出来有效字节
所以我们直接修改题目源码,把原题的三个“判定函数”在本地复现(把 exit(1)
换成 raise
),然后在 opcode 集合穷举长度 ≤8 的字节序列来爆破五个 check(直接爆破计算时间过于庞大)。
缩小搜索空间:用 pickletools.opcodes
的 opcode 字节集作为 alphabet(只在 opcode 集中穷举字节),
从 Python 自身的 pickletools
获取
1 | from pickletools import opcodes |
然后结合ai,得到了理论上更容易命中这些check的opcode列表
1 | seed_bytes = [b'\x29', b'\x28', b'\x4e', b''] # EMPTY_TUPLE / MARK / NONE / empty |
一句话结论(AI对这些种子的解释)
这些 seed 是 pickle 协议里单字节就能改变运行时栈状态/语义的常用 opcode(EMPTY_TUPLE
、MARK
、NONE
),在字节预算极紧(≤8 bytes)下它们能以最小的代价制造出栈状态差异或语义边界,而这些正是让 pure-Python
、_pickle(C)
与 pickletools.dis
三者表现不同的关键。
在穷举时优先固定 seed,然后对剩余 positions 做穷举(比从全字节开始穷举更快)。
记录 dis()
输出与两种 unpickler 的异常并比较,异常信息直接给出定位线索。
若没命中,再尝试加入 PROTO
前缀或 STOP
后缀,或把 seed 扩到其它高价值 opcode(如 MEMOIZE
、PROTO
等)。
然后来编写脚本进行爆破
1 | from pickle import _Unpickler as py_unpickler |
构建 base_variants
:对 coreb
产生多个变体
- 直接
coreb
,或coreb + STOP
。 - 在前面加入
PROTO
头(0x80, ver
),再组合coreb
与可选 STOP。 - 在前面加入
seed
(EMPTY_TUPLE/MARK/NONE/空
)再组合coreb
与可选 STOP。
找到五个check,编写交互脚本
1 | from pwn import * |
然后就get flag
总结:看到这个题目,其实以前并没有接触过pickle,在本地测试了这三个的反汇编结果之后,打算试一试这道题目,这道题目实际上dis 比 unpickle 严格,所以通过unpickle简单,但是通过dis就有点难度。一开始自己手动能够构造出check1
EMPTY_TUPLE 29
EMPTY_TUPLE 29
STOP 2e
dis() 因为语义STOP 后栈不空 报错,不通过dis()。然后后面就想着既然只有8个字节,本地直接模拟爆破,第一次爆了两个小时只有check1,check2和check4。结合ai不断修改opcode的范围,灵光乍现根据已经爆破出来的来设置种子减少穷举范围(跟yzb打完游戏之后的灵感,果然,多跟yzb打游戏),最后才搞出来check3和check5。
- Title: sekai ctf 外卡赛 Discrepancy
- Author: luyanpei
- Created at : 2025-08-19 10:30:05
- Updated at : 2025-08-24 10:18:15
- Link: https://redefine.ohevan.com/posts/572.html
- License: All Rights Reserved © luyanpei