【技术分享】Python安全 - 从SSRF到命令执行惨案

https://p1.ssl.qhimg.com/t01db83fd997cf8226b.jpg

作者:ph17h0n@leavesongs.com

前两天遇到的一个问题,起源是在某个数据包里看到url=这个关键字,当时第一想到会不会有SSRF漏洞。

以前乌云上有很多从SSRF打到内网并执行命令的案例,比如有通过SSRF+S2-016漏洞漫游内网的案例,十分经典。不过当时拿到这个目标,我只是想确认一下他是不是SSRF漏洞,没想到后面找到了很多有趣的东西。截图不多(有的是后面补得),大家凑合看吧。

0x01 判断SSRF漏洞

目标example.com,根据其中csrf_token的样式,我猜测其为flask开发(当然也可能是一个我不太熟悉的框架使用了和flaskwtf相似的代码):

http://p9.qhimg.com/t01d73ae2a8132d1ba8.png

开着代理浏览了一遍整个网站的功能,功能点不多,比较小众的一个分享型站点。偶然间在数据包里看到url=,看了一下发现是一个本地化外部图片这么一个功能。这种功能很容易出现两种漏洞:

SSRF漏洞

XSS漏洞

SSRF漏洞就不用多说了,在拉取外部资源的时候没有检查URL,导致可以向内网发送请求;XSS漏洞容易被忽略,拉取到目标后储存的时候没有过滤特殊字符,就可能导致XSS漏洞。

简单fuzz一下,依次访问http://127.0.0.1:80/、http://127.0.0.1:80/404404404not_found、http://127.0.0.1:12321/

http://p2.qhimg.com/t01b64244ea527c3372.png

http://p9.qhimg.com/t0102ef1108089c914f.png

http://p4.qhimg.com/t01d0f956bf34b1aad9.png

依次返回了error和两个500,这三个结果分别代表什么?

因为平时做Python开发比较多,这种500的情况也见的比较多,通常是因为代码没有捕捉异常导致返回500。感觉第二个可能是HTTP请求404导致抛出异常,而第三个可能是TCP连接被拒绝(12321端口未开放)导致抛出异常。

虽然我还没理清目标的代码逻辑,但我能肯定这其中存在SSRF漏洞。

0x02 鸡肋redis服务?

经过简单的测试,我发现目标站点下载外部资源后,会检查资源类型是否是图片,不是图片则返回error。这样就很尴尬了,这是一个没有回显的SSRF漏洞。

这时候我突然想到,既然是判断图片,会不会是用imagemagick组件来判断的?然后我将imagetragick的POC保存到外网的某个poc.gif里,然后让其访问:

http://p9.qhimg.com/t018f6319ec144b18be.png

直接把内容返回了,没出现error,也没500,但命令也没执行成功。

当时没想清楚目标究竟是怎么判断图片的,后来拿到shell以后看了源码才知道:目标是判断返回包的content-type,如果不是图片就直接返回error,我想的太复杂了。

imagemagick这条路死了,我就没再研究这块逻辑。因为我不知道目标内网IP段,所以准备先探测一下127.0.0.1的端口,我列了一些常用端口,用Burp跑了一下:

http://p0.qhimg.com/t01f4c7c4736903839b.png

看到6379是200的时候,我着实激动了一下,众所周知,在渗透中遇到redis是一件很愉快的事情。

不过我很快发现,GET请求我没法控制Redis的命令。

科普一下,Redis的协议是简单的文本流,比如我可以向6379端口发送如下TCP流:

SET x 1

SET y 2

每行代表一个命令,上述数据包执行了两条set命令。但现在尴尬的是,普通GET请求的数据包如下:

GET / HTTP/1.1
Host: example.com
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close

我控制不了任意一行的起始部分,也就没法自定义redis命令了,非常鸡肋……

0x03 CVE-2016-5699 化腐朽为神奇

真的鸡肋么?

去年,Python的urllib库曾出过一个头注入的漏洞,CVE-2016-5699( http://blog.neargle.com/SecNewsBak/drops/Python%20urllib%20HTTP%E5%A4%B4%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E.html )

因为在CTF里有过类似的思路,所以我基本第一时间就想到了。我在外网服务器用nc开了个端口,在目标web页面传入http://[vps-ip]%0d%0aX-injected:%20header:12345/foo,发现果然注入成功了:

http://p9.qhimg.com/t01654256b6586d15bb.png

有点小激动,因为之前只是听说过这个漏洞,没有真实案例的依托,这次真遇到了。如果我们能注入HTTP头,也就能控制发送往Redis的数据包的某一行,这样就能执行任意Redis命令了。

有点怕影响目标站,我先在本地搭了个类似的环境。

攻击Redis有几个思路,核心就在于写文件。在本地测试,发现了几个巨坑:

CONFIG SET dir /tmp,传递斜线/的时候必须进行二次编码(%252f),否则urllib2会抛出URLError: <urlopen error no host given>的异常。

url过长会导致抛出UnicodeError: label empty or too long的异常,所以我需要依次传入CONFIG SET、SAVE等几个命令。

最后,我依次发送http://127.0.0.1%0d%0aCONFIG%20SET%20dir%20%252ftmp%0d%0a:6379/foo、http://127.0.0.1%0d%0aCONFIG%20SET%20dbfilename%20evil%0d%0a:6379/foo、http://127.0.0.1%0d%0aSET%20foo%20bar%0d%0aSAVE%0d%0a:6379/foo,最后成功在本地写入/tmp/evil文件。

http://p9.qhimg.com/t016e3ed582579766d9.png

不过目标环境就有点蛋疼了,一是完全没有回显,我无法知道我是否写入成功;二是失败原因我无法预测,有可能是redis有密码,或redis是普通权限,或config set命令被禁用等等。

感觉又是一个比较蛋疼和鸡肋的情境。

0x04 Python反序列化逆袭

果然在线上环境尝试写入cron文件,都没成功反弹回shell。

在这个地方卡了很久,思路一直在考虑“是否真的成功写入文件”这个问题,如果“成功写入了文件”,为什么没有反弹到shell;如果没有成功写入文件,是不是没有权限,是否可以写入python的webshell?总结起来有几个思路:

写入ssh key进行getshell。但扫端口发现似乎并没有开放22,推测是更换了ssh端口并进行的IP限制,或者直接没有运行sshd。

写入cron尝试反弹shell,但没成功。也许是redis没权限,也许是因为目标是ubuntu或debian,这两个系统对于cron文件格式限制会比较严,很难用redis反弹shell。

写入python的webshell,但可能也会遇到文件格式要求过严导致python运行失败,而且通常写入python脚本需要重启服务器才能奏效

写入jinja2模板文件,并通过模板引擎支持的语法执行命令。

总结起来,第4个方法最靠谱,因为模板文件对格式要求不严,只要我需要执行的语句放在类似{{ }}的标签中即可。但经过测试,还是有几个问题:一是web路径(关键是存放模板文件的路径)和模板名称都需要猜,这个太难;二是redis如果是从源进行安装,一般是redis用户运行,一般无法写入web目录。

吃个夜宵再回来想想,我觉得首先得解决“是否真的成功写入文件”这个问题。后面fuzz了一下,测试了一堆目录,发现成功在/var/www/html/static下写入了文件,并通过http://example.com/static/xxxfile直接可以访问!

下载刚写入的文件,其实这个文件即为redis的导出文件。我将之导入到自己本地的redis环境中,又是一个惊喜:

http://p5.qhimg.com/t01e1bc192e977491f2.png

看到这个样式的数据,我就知道这一定是反序列化数据,而且是Python2.7的反序列化数据。

这里科普一下,Python2.7和3.5默认使用的序列化格式有所区别,一般带有括号和换行的序列化数据是2.7使用的,而包含x00的一般是3.5使用的。

后续利用就和 https://www.leavesongs.com/PENETRATION/zhangyue-python-web-code-execute.html 这篇文章一个套路。目标站使用redis存储session数据,且session数据使用序列化保存,我们可以通过反序列化来执行任意命令。

使用python2.7构造一个执行反弹shell脚本的序列化数据,并通过SSRF漏洞设置成session:hacker的值,然后访问目标站点的时候设置Cookie session=hacker。

不过有一点需要注意,就是SSRF时URL太长的话会抛出错误(之前本地测试的时候说过),所以需要曲线救国,使用redis的append命令,将数据一段一段写入,类似于这样:

http://p8.qhimg.com/t01c4b8073e5fdc1306.png

另外还有个坑,写入的时候,特殊字符(如换行)需要转义:http://127.0.0.1%0d%0aAPPEND%20session:hacker%20"(S'id'np1ntp2nRp3n."%0d%0aSAVE%0d%0a:6379/,而且只有值被引号包裹时转义符才能转义,否则转义符又会被转义……这个把我坑了好久,差点就以为功亏一篑了。

最后感觉,挖漏洞思路还是得跳,之前一直在考虑怎么通过redis写文件来进行getshell,却没想到通过读取redis的备份文件,找到了突破口。

成功反弹shell:

http://p6.qhimg.com/t013a5ddc2419b46c77.png

总结

这一次案例,出现漏洞的根本原因有几个:

Web层面出现SSRF漏洞

Python版本过低,存在CVE-2016-5699头注入漏洞

Redis版本过低,新版Redis写入的文件权限一般是660,可以极大程度上避免写文件造成的漏洞

拿到shell以后我看了下源码,其逻辑是这样:获取用户传入的url参数,直接发送HTTP请求并拿到返回对象,判断返回对象的Content-Type是否包含image,如果包含则离线数据并显示出来,否则返回error。

这就导致HTTP请求一旦出现错误,服务器就会抛出500,而返回error是手工判断的结果,所以状态码还是200。

另外还有个感想,我怕把目标环境搞坏了,整个过程中多次用到了本地环境进行测试,而所有本地环境都是用docker启动的 ,非常方便。

之后我应该会模拟一下这个目标,做一个vulhub环境给大家,有时间再说吧……

(完)