作者:phith0n@长亭科技

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

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

0x01 判断 SSRF 漏洞

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

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

  1. SSRF漏洞
  2. XSS漏洞

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

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

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

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

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

0x02 鸡肋 redis 服务?

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

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

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

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

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

看到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

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

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

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

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

  1. CONFIG SET dir /tmp,传递斜线/的时候必须进行二次编码(%252f),否则 urllib2 会抛出URLError: <urlopen error no host given>的异常。
  2. url 过长会导致抛出 UnicodeError: label empty or too long的异常,所以我需要依次传入CONFIG SETSAVE等几个命令。

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

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

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

0x04 Python 反序列化逆袭

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

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

  1. 写入 ssh key 进行 getshell。但扫端口发现似乎并没有开放22,推测是更换了 ssh 端口并进行的 IP 限制,或者直接没有运行 sshd 。
  2. 写入 cron 尝试反弹 shell ,但没成功。也许是 redis 没权限,也许是因为目标是 ubuntu 或 debian ,这两个系统对于 cron 文件格式限制会比较严,很难用 redis 反弹 shell 。
  3. 写入 python 的 webshell ,但可能也会遇到文件格式要求过严导致 python 运行失败,而且通常写入python脚本需要重启服务器才能奏效
  4. 写入 jinja2 模板文件,并通过模板引擎支持的语法执行命令。

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

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

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

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

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

后续利用就和这篇文章一个套路。目标站使用 redis 存储 session 数据,且 session 数据使用序列化保存,我们可以通过反序列化来执行任意命令。

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

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

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

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

成功反弹 shell :

总结

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

  1. Web 层面出现 SSRF 漏洞
  2. Python 版本过低,存在 CVE-2016-5699 头注入漏洞
  3. Redis 版本过低,新版 Redis 写入的文件权限一般是660,可以极大程度上避免写文件造成的漏洞

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

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

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

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


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/337/