ssrf 学习笔记

最近被学弟问了一道极客大挑战的题,题目如下:

考点是 ssrf ,但关于 ssrf 我也只是略有耳闻,对此题也是无从下手。
怀着羞愧的心情,恶补了一下 ssrf 。

什么是 ssrf

SSRF(Server-Side Request Forgery:服务器端请求伪造) 是一种由攻击者构造形成由服务端发起请求的一个安全漏洞。一般情况下,SSRF攻击的目标是从外网无法访问的内部系统。(正是因为它是由服务端发起的,所以它能够请求到与它相连而与外网隔离的内部系统)
SSRF 形成的原因大都是由于服务端提供了从其他服务器应用获取数据的功能且没有对目标地址做过滤与限制。比如从指定URL地址获取网页文本内容,加载指定地址的图片,下载等等。

举个例子

一种很常见的实现场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php 
if (isset(\$_GET['url']))
{
$link = $_GET['url'];
$filename = './curled/'.rand().'txt';
$curlobj = curl_init($link);
$fp = fopen($filename,"w");
curl_setopt($curlobj, CURLOPT_FILE, $fp);
curl_setopt($curlobj, CURLOPT_HEADER, 0);
curl_exec($curlobj);
curl_close($curlobj);
fclose($fp);
$fp = fopen($filename,"r");
$result = fread($fp, filesize($filename));
fclose($fp);
echo $result;
}
?>

使用 curl 获取指定 url 的数据并将其保存在本地的 curled 目录下,再展示给用户。在指定 url 后,网页呈现出了目标 url 的内容。

同时在 curled 目录下也生成了 txt 文件。

通过 Bp 抓包,我们可以看到,在这个过程中,我们只发起了一次请求,因为加载指定 url 内容的请求是由服务端发起的。

像这个例子$url可控,通过 curl 就造成了 ssrf 漏洞。类似的 file_get_contents()、curl()、fsocksopen() 均可能造成SSRF漏洞。

漏洞利用

继续用上面的例子来说明。

端口扫描

我们在指定 url 时如果是无效的,大部分的web应用都会返回错误信息。因此可以输入一些不常见的但是有效的 url,比如:

http://example.com:8080/dir/images/

http://example.com:22/dir/public/image.jpg

http://example.com:3306/dir/images/

然后根据服务器的返回信息来判断端口是否开放。大部分应用并不会去判断端口,只要是有效的URL,就发出了请求。而大部分的TCP服务,在建立socket连接的时候就会发送banner信息,banner信息是ascii编码的,能够作为原始的html数据展示。当然,服务端在处理返回信息的时候一般不会直接展示,但是不同的错误码,返回信息的长度以及返回时间都可以作为依据来判断远程服务器的端口状态。
在这根据返回时间用 python 写了一个简单的端口扫描脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests, time
import argparse
import threading

def portScan(tgtHost, port):
url = "http://localhost/ssrf/nom.php?url=http://%s:%d/" % (tgtHost, port)
try:
r = requests.get(url, timeout=3)
print "[*] %d is open !\n" % port
except:
return None

def main():
parser = argparse.ArgumentParser()
parser.add_argument("-t", "--tgtHost", type=str, help="target Host")
parser.add_argument("-p", "--Port", type=int, help="target Port", required=True)
args = parser.parse_args()
tgtHost = args.tgtHost
port = args.Port
k = 0
for i in range(20, port):
t = threading.Thread(target=portScan, args=(tgtHost, i))
t.start()
k += 1
if k == 8:
k = 0
time.sleep(3)

if __name__ == "__main__":
main()

攻击应用程序

web ssrf作为跳板可攻击内网多种应用如redis,discuz,fastcgi,memcache,webdav,struts,jboss,axis2等应用。
这部分的利用姿势好像也比较多,由于现了解的比较少,就暂时不说。
dict、gopher这两个协议的利用还得再看看。

内网web应用指纹识别

识别内网应用使用的框架,平台,模块以及cms可以为后续的攻击提供很多帮助。大多数web应用框架都有一些独特的文件和目录。通过这些文件可以识别出应用的类型,甚至详细的版本。根据这些信息就可以针对性的搜集漏洞进行攻击。比如可以通过访问下列文件来判断phpMyAdmin是否安装:

Request: http://127.0.0.1:8080/phpMyAdmin/themes/original/img/b_tblimport.png
Request: http://127.0.0.1:8081/wp-content/themes/default/images/audio.jpg
Request: http://127.0.0.1:8082/profiles/minimal/translations/README.txt

读取本地文件

如果我们指定file协议,也可能读到服务器上的文件。如下的请求会让应用读取本地文件:

Request: file:///C:/Windows/win.ini

ssrf的防御以及绕过

一些普通的方法就不说了,重点分析这道题所用 WAF 。

ssrf防御思路

这个 WAF 是根据P师傅“谈一谈如何在Python开发中拒绝SSRF漏洞”中的思路所写的,其中有三个重点部分。

如何检查IP是否为内网IP?

何谓内网IP,实际上并没有一个硬性的规定,多少到多少段必须设置为内网。有的管理员可能会将内网的IP设置为233.233.233.0/24段,当然这是一个比较极端的例子。
通常我们会将以下三个段设置为内网IP段,所有内网内的机器分配到的IP是在这些段中:

192.168.0.0/16 => 192.168.0.0 ~ 192.168.255.255
10.0.0.0/8 => 10.0.0.0 ~ 10.255.255.255
172.16.0.0/12 => 172.16.0.0 ~ 172.31.255.255

所以通常,我们只需要判断目标IP不在这三个段,另外还包括一个 127.0.0.0/8 段即可。
很多人会忘记 127.0.0.0/8 ,认为本地地址就是 127.0.0.1 ,实际上本地回环包括了整个127段。你可以访问http://127.233.233.233/,会发现和请求127.0.0.1是一个结果。
之前大部分的验证思路有通过正则或IP规范化的,但都可以利用进制转化的方法绕过。而且处理起来显得十分累赘,所以安全隐患很高。
因为IP地址是可以转换为整数的,在PHP中调用ip2long函数即可转换。所以,只要转化整数后比大小即可。
而且IP地址是和2^32内的整数一一对应的,也就是说0.0.0.0 == 0,255.255.255.255 == 2^32 - 1。所以,我们判断一个IP是否在某个IP段内,只需将IP段的起始值、目标IP值全部转换为整数,然后比较大小即可。
至于为什么要做这个位运算,是因为IP地址在计算机中是以二进制储存的,每个网段有8位。
举个例子,假设有一个 IP 地址: 192.168.0.1,转换为二进制的IP地址就是: 11000000.10101000.00000000.00000001。那么截取网段 192.168.0.0/16 只要右移十六位就可以了。
也就是题目中这块代码的作用:

1
return ip2long('127.0.0.0') >> 24 == $int_ip >> 24 || ip2long('10.0.0.0') >> 24 == $int_ip >> 24 || ip2long('172.16.0.0') >> 20 == $int_ip >> 20 || ip2long('192.168.0.0') >> 16 == $int_ip >> 16

如何获得请求URL真正的HOST?

要让上一步的过滤代码起作用,就必须要获得请求的真正IP。
http://233.233.233.233@10.0.0.1:8080/、http://10.0.0.1#233.233.233.233这样的URL,让后端认为其Host是233.233.233.233,实际上请求的却是10.0.0.1。同时网上有个服务 http://xip.io ,这是一个“神奇”的域名,它会自动将包含某个IP地址的子域名解析到该IP。比如 127.0.0.1.xip.io ,将会自动解析到127.0.0.1。
在 PHP 下可以使用函数 gethostbyname() 来正确的解析URL的IP。如果输入的是域名形式,那么它将通过DNS服务器来获取IP;如果输入的是IP形式,就会直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$match_result=preg_match('/^(http|https)?:\/\/.*(\/)?.*$/',$url); 
if (!$match_result)
{
die('url fomat error');
}
try
{
$url_parse=parse_url($url);
}
catch(Exception $e)
{
dir('url fomat error');
return false;
}
$hostname=$url_parse['host'];

$ip=gethostbyname($hostname);

首先判断URL是不是采用的HTTP协议(如果不检查,攻击者可能会利用file、gophar等协议进行攻击),然后获取url的host,并解析该host,最终将解析完成的IP用ip2long进行比较。

只要Host指向的IP不是内网IP即可吗?

是不是做了以上工作,解析并判断了Host指向的IP不是内网IP,即防御了SSRF漏洞?
答案继续是否定的。
当我们请求的目标返回30X状态的时候,如果没有禁止跳转的设置,大部分HTTP库会自动跟进跳转。此时如果跳转的地址是内网地址,将会造成SSRF漏洞。
那么如何来预防这种情况?
可行的方法是,每跳转一次,就检查一次新的Host是否是内网IP,直到抵达最后的网址。

1
2
3
4
5
$result_info = curl_getinfo($ch); 
if ($result_info['redirect_url'])
{
safe_request_url($result_info['redirect_url']);
}

在 Waf 代码中这段代码会判断URL是否发生跳转,如果发生跳转,那么跳转的URL会记录在 $result_info['redirect_url'] 中,并再次访问。
最后总结出 ssrf 的防御策略:

  1. 解析目标URL,获取其Host
  2. 解析Host,获取Host指向的IP地址
  3. 检查IP地址是否为内网IP
  4. 请求URL
  5. 如果有跳转,拿出跳转URL,执行1

最后写出的代码就像题目中的那样。

绕过思路

0.0.0.0

IPV4中,0.0.0.0地址被用于表示一个无效的,未知的或者不可用的目标。

  • 在服务器中,0.0.0.0指的是本机上的所有IPV4地址,如果一个主机有两个IP地址,192.168.1.1 和 10.1.2.1,并且该主机上的一个服务监听的地址是0.0.0.0,那么通过两个ip地址都能够访问该服务。
  • 在路由中,0.0.0.0表示的是默认路由,即当路由表中没有找到完全匹配的路由的时候所对应的路由。

这道题由于没过滤这个IP所以可以用 0.0.0.0 绕过。

DNS reblind

DNS重绑定,这个在后面有一篇笔记会详细说明。

Reference

ssrf漏洞挖掘经验
SSRF绕过总结
127.0.0.1与0.0.0.0的区别
ssrf攻击概述
DNS域名解析全过程
P师傅的waf思路
柠檬师傅的出题思路