作者:ph17h0n
稿费:500RMB(不服你也来投稿啊!
)
投稿办法:发送邮件至linwei#360.cn,或上岸网页版在线投稿

0x01 SSRF漏洞常见防御手腕及绕过方法
SSRF是一种常见的Web漏洞,常日存在于须要要求外部内容的逻辑中,比如本地化网络图片、XML解析时的外部实体注入、软件的离线下载等。当攻击者传入一个未履历证的URL,后端代码直接要求这个URL,将会造成SSRF漏洞。
详细危害表示在以下几点上:
URL为内网IP或域名,攻击者将可以通过SSRF漏洞扫描目标内网,查找内网内的漏洞,并想办法反弹权限
URL中包含端口,攻击者将可以扫描并创造内网中机器的其他做事,再进一步进行利用
当要求方法许可其他协议的时候,将可能利用gophar、file等协议进行第三方做事利用,如利用内网的redis获取权限、利用fastcgi进行getshell等
特殊是这两年,大量利用SSRF攻击内网做事的案例被爆出来,导致SSRF漏洞逐步受到重视。这就给Web运用开拓者提出了一个难题:如何在担保业务正常的情形下防御SSRF漏洞?
很多开拓者认为,只要检讨一下要求url的host不为内网IP,即可防御SSRF。这个不雅观点实在提出了两个技能要点:
1.如何检讨IP是否为内网IP
2.如何获取真正要求的host
于是,攻击者通过这两个技能要点,针对性地想出了很多绕过方法。
0x02 如何检讨IP是否为内网IP
这实际上是很多开拓者面临的第一个问题,很多新手乃至连内网IP常用的段是多少也不清楚。
何谓内网IP,实际上并没有一个硬性的规定,多少到多少段必须设置为内网。有的管理员可能会将内网的IP设置为233.233.233.0/24段,当然这是一个比较极度的例子。
常日我们会将以下三个段设置为内网IP段,所有内网内的机器分配到的IP是在这些段中:
123
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是一个结果:
以是我们须要防御的实际上是4个段,只要IP不落在这4个段中,就认为是“安全”的。
网上一些开拓者会选择利用“正则”的办法判断目标IP是否在这四个段中,这种判断方法常日是会遗漏或误判的,比如如下代码:
这是Sec-News最老版本判断内网IP的方法,里面利用正则判断IP是否在内网的几个段中。这个正则也是我当时临时在网上搜的,很明显这里存在多个绕过的问题:
1. 利用八进制IP地址绕过
2. 利用十六进制IP地址绕过
3. 利用十进制的IP地址绕过
4. 利用IP地址的省略写法绕过
这四种办法我们可以依次试试:
四种写法(5个例子):012.0.0.1 、 0xa.0.0.1 、 167772161 、 10.1 、 0xA000001 实际上都要求的是10.0.0.1,但他们一个都匹配不上上述正则表达式。
更聪明一点的人是不会用正则表达式来检测IP的(大概这类人并不知道内网IP的正则该怎么写)。Wordpress的做法是,先将IP地址规范化,然后用“.”将其分割成数组parts,然后根据parts[0]和parts[1]的取值来判断:
实在也略显麻烦,而且曾经也涌现过用进制方法绕过的案例( WordPress <4.5 SSRF 剖析 ),不推举利用。
我后来选择了一种更为大略的方法。众所周知,IP地址是可以转换成一个整数的,在PHP中调用ip2long函数即可转换,在Python利用inet_aton去转换。
而且IP地址是和2^32内的整数逐一对应的,也便是说0.0.0.0 == 0,255.255.255.255 == 2^32 - 1。以是,我们判断一个IP是否在某个IP段内,只需将IP段的起始值、目标IP值全部转换为整数,然后比较大小即可。
于是,我们可以将之前的正则匹配的方法修正为如下方法:
这便是一个最大略的方法,也最随意马虎理解。
如果你懂一点掩码的知识,你该当知道IP地址的掩码实际上便是(32 - IP地址所代表的数字的末端bit数)。以是,我们只须要担保目标IP和内网边界IP的前“掩码”位bit相等即可。借助位运算,将以上判断修正地更加大略:
123456789101112from
socket
import
inet_aton
from
struct
import
unpack
def
ip2long(ip_addr):
return
unpack(
\"大众!L\"大众
, inet_aton(ip_addr))[
0
]
def
is_inner_ipaddress(ip):
ip
=
ip2long(ip)
return
ip2long(
'127.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'10.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'172.16.0.0'
) >>
20
=
=
ip >>
20
or
\
ip2long(
'192.168.0.0'
) >>
16
=
=
ip >>
16
以上代码也便是Python中止定一个IP是否是内网IP的终极方法,利用时调用is_inner_ipaddress(...)即可(把稳自己编写捕捉非常的代码)。
0x03 host获取与绕过
如何获取\"大众真正要求\"大众的Host,这里须要考虑三个问题:
1. 如何精确的获取用户输入的URL的Host?
2. 只要Host只要不是内网IP即可吗?
3. 只要Host指向的IP不是内网IP即可吗?
如何精确的获取用户输入的URL的Host?
第一个问题,看起来很大略,但实际上有很多网站在获取Host上犯过一些缺点。最常见的便是,利用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。这种方法利用的是程序员对URL解析的缺点,有很多程序员乃至会用正则去解析URL。
在Python 3下,精确获取一个URL的Host的方法:
1234from
urllib.parse
import
urlparse
url
=
'https://10.0.0.1/index.php'
urlparse(url).hostname
这一步一定不能犯错,否则后面的事情就白做了。
只要Host只要不是内网IP即可吗?
第二个问题,只要检讨一下我们获取到的Host是否是内网IP,即可防御SSRF漏洞么?
答案是否定的,缘故原由是,Host可能是IP形式,也可能是域名形式。如果Host是域名形式,我们是没法直接比对的。只要其解析到内网IP上,就可以绕过我们的is_inner_ipaddress了。
网上有个做事 http://xip.io ,这是一个“神奇”的域名,它会自动将包含某个IP地址的子域名解析到该IP。比如 127.0.0.1.xip.io ,将会自动解析到127.0.0.1,www.10.0.0.1.xip.io将会解析到10.0.0.1:
这个域名极大的方便了我们进行SSRF漏洞的测试,当我们要求http://127.0.0.1.xip.io/info.php的时候,表面上要求的Host是127.0.0.1.xip.io,此时实行is_inner_ipaddress('127.0.0.1.xip.io')是不会返回True的。但实际上要求的却是127.0.0.1,这是一个标准的内网IP。
以是,在检讨Host的时候,我们须要将Host解析为详细IP,再进行判断,代码如下:
123456789101112131415161718192021222324252627282930
import
socket
import
re
from
urllib.parse
import
urlparse
from
socket
import
inet_aton
from
struct
import
unpack
def
check_ssrf(url):
hostname
=
urlparse(url).hostname
def
ip2long(ip_addr):
return
unpack(
\"大众!L\"大众
, inet_aton(ip_addr))[
0
]
def
is_inner_ipaddress(ip):
ip
=
ip2long(ip)
return
ip2long(
'127.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'10.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'172.16.0.0'
) >>
20
=
=
ip >>
20
or
\
ip2long(
'192.168.0.0'
) >>
16
=
=
ip >>
16
try
:
if
not
re.match(r
\公众^https?://./.$\"大众
, url):
raise
BaseException(
\"大众url format error\公众
)
ip_address
=
socket.getaddrinfo(hostname,
'http'
)[
0
][
4
][
0
]
if
is_inner_ipaddress(ip_address):
raise
BaseException(
\"大众inner ip address attack\公众
)
return
True
,
\"大众success\公众
except
BaseException as e:
return
False
,
str
(e)
except
:
return
False
,
\公众unknow error\"大众
首先判断url是否是一个HTTP协议的URL(如果不检讨,攻击者可能会利用file、gophar等协议进行攻击),然后获取url的host,并解析该host,终极将解析完成的IP放入is_inner_ipaddress函数中检讨是否是内网IP。
只要Host指向的IP不是内网IP即可吗?
第三个问题,是不是做了以上事情,解析并判断了Host指向的IP不是内网IP,即防御了SSRF漏洞?
答案连续是否定的,上述函数并不能精确防御SSRF漏洞。为什么?
当我们要求的目标返回30X状态的时候,如果没有禁止跳转的设置,大部分HTTP库会自动跟进跳转。此时如果跳转的地址是内网地址,将会造成SSRF漏洞。
这个缘故原由也很好理解,我以Python的requests库为例。requests的API中有个设置,叫allow_redirects,当将其设置为True的时候requests会自动进行30X跳转。而默认情形下(开拓者未传入这个参数的情形下),requests会默认将其设置为True:
以是,我们可以试试要求一个302跳转的网址:
默认情形下,将会跟踪location指向的地址,以是返回的status code是终极访问的页面的状态码。而设置了allow_redirects的情形下,将会直接返回302状态码。
以是,纵然我们获取了http://t.cn/R2iwH6d的Host,通过了is_inner_ipaddress检讨,也会由于302跳转,跳到一个内网IP,导致SSRF。
这种情形下,我们有两种办理方法:
1. 设置allow_redirects=False,不许可目标进行跳转
2. 每跳转一次,就检讨一次新的Host是否是内网IP,直到抵达末了的网址
第一种情形明显是会影响业务的,只是规避问题而未办理问题。当业务上须要目标URL能够跳转的情形下,只能利用第二种方法了。
以是,归纳一下,完美办理SSRF漏洞的过程如下:
1. 解析目标URL,获取其Host
2. 解析Host,获取Host指向的IP地址
3. 检讨IP地址是否为内网IP
4. 要求URL
5. 如果有跳转,拿出跳转URL,实行1
0x04 利用requests库的hooks属性来检讨SSRF
那么,上一章说的5个过程,详细用Python怎么实现?
我们可以写一个循环,循环条件便是“该次要求的状态码是否是30X”,如果是就连续实行循环,连续跟进location,如果不是,则退出循环。代码如下:
1234567
r
=
requests.get(url, allow_redirects
=
False
)
while
r.is_redirect:
url
=
r.headers[
'location'
]
succ, errstr
=
check_ssrf(url)
if
not
succ:
raise
Exception(
'SSRF Attack.'
)
r
=
requests.get(url, allow_redirects
=
False
)
这个代码思路大概没有问题,但非常简陋,而且效率不高。
只要你翻翻requests的源代码,你会创造,它在处理30X跳转的时候考虑了很多地方:
所有要求放在一个requests.Session()中
跳转有个缓存,当下次跳转地址在缓存中的时候,就不用多次要求了
跳转数量有最大限定,不可能无穷无尽跳下去
办理307跳转涌现的一些BUG等
如果说就按照之前简陋的代码编写程序,固然可以防御SSRF漏洞,但上述提高效率的方法均没用到。
那么,有更好的办理方法么?当然有,我们翻一下requests的源代码,可以看到一行分外的代码:
hook的意思便是“挟制”,意思便是在hook的位置我可以插入我自己的代码。我们看看dispatch_hook函数做了什么:
123456789101112
def
dispatch_hook(key, hooks, hook_data,
kwargs):
\"大众\公众\"大众Dispatches a hook dictionary on a given piece of data.\公众\公众\"大众
hooks
=
hooks
or
dict
()
hooks
=
hooks.get(key)
if
hooks:
if
hasattr
(hooks,
'__call__'
):
hooks
=
[hooks]
for
hook
in
hooks:
_hook_data
=
hook(hook_data,
kwargs)
if
_hook_data
is
not
None
:
hook_data
=
_hook_data
return
hook_data
hooks是一个函数,或者一系列函数。这里做的事情便是遍历这些函数,并调用:_hook_data = hook(hook_data,kwargs)
我们翻翻文档,可以找到hooks event的解释 http://docs.python-requests.org/en/master/user/advanced/?highlight=hook#event-hooks :
文档中定义了一个print_url函数,将其作为一个hook函数。在要求的过程中,相应工具被传入了print_url函数,要求的域名被打印了下来。
我们可以考虑一下,我们将检讨SSRF的过程也写为一个hook函数,然后传给requests.get,在之后的要求中一旦获取response就会调用我们的hook函数。这样,纵然我设置allow_redirects=True,requests在每次要求后都会调用一次hook函数,在hook函数里我只需检讨一下response.headers['location']即可。
说干就干,先写一个hook函数:
当r.is_redirect为True的时候,也便是说这次要求包含一个跳转。获取此时的r.headers['location'],并进行一些处理,末了传入check_ssrf。当检讨不通过期,抛出一个非常。
然后编写一个要求函数safe_request_url,意思是“安全地要求一个URL”。利用这个函数要求的域名,将不会涌现SSRF漏洞:
我们可以看到,在第一次要求url前,还是须要check_ssrf一次的。由于hook函数_request_check_location只是检讨30X跳转时是否存在SSRF漏洞,而没有检讨最初要求是否存在SSRF漏洞。
不过上面的代码还不算完善,由于_request_check_location覆盖了原有(用户可能定义的其他hooks)的hooks属性,以是须要大略调度一下。
终极,给出完全代码:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
import
socket
import
re
import
requests
from
urllib.parse
import
urlparse
from
socket
import
inet_aton
from
struct
import
unpack
from
requests.utils
import
requote_uri
def
check_ssrf(url):
hostname
=
urlparse(url).hostname
def
ip2long(ip_addr):
return
unpack(
\"大众!L\"大众
, inet_aton(ip_addr))[
0
]
def
is_inner_ipaddress(ip):
ip
=
ip2long(ip)
return
ip2long(
'127.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'10.0.0.0'
) >>
24
=
=
ip >>
24
or
\
ip2long(
'172.16.0.0'
) >>
20
=
=
ip >>
20
or
\
ip2long(
'192.168.0.0'
) >>
16
=
=
ip >>
16
try
:
if
not
re.match(r
\公众^https?://./.$\"大众
, url):
raise
BaseException(
\"大众url format error\公众
)
ip_address
=
socket.getaddrinfo(hostname,
'http'
)[
0
][
4
][
0
]
if
is_inner_ipaddress(ip_address):
raise
BaseException(
\"大众inner ip address attack\公众
)
return
True
,
\"大众success\"大众
except
BaseException as e:
return
False
,
str
(e)
except
:
return
False
,
\"大众unknow error\公众
def
safe_request_url(url,
kwargs):
def
_request_check_location(r,
args,
kwargs):
if
not
r.is_redirect:
return
url
=
r.headers[
'location'
]
# The scheme should be lower case...
parsed
=
urlparse(url)
url
=
parsed.geturl()
# Facilitate relative 'location' headers, as allowed by RFC 7231.
# (e.g. '/path/to/resource' instead of 'http://domain.tld/path/to/resource')
# Compliant with RFC3986, we percent encode the url.
if
not
parsed.netloc:
url
=
urljoin(r.url, requote_uri(url))
else
:
url
=
requote_uri(url)
succ, errstr
=
check_ssrf(url)
if
not
succ:
raise
requests.exceptions.InvalidURL(
\"大众SSRF Attack: %s\"大众
%
(errstr, ))
success, errstr
=
check_ssrf(url)
if
not
success:
raise
requests.exceptions.InvalidURL(
\"大众SSRF Attack: %s\公众
%
(errstr,))
all_hooks
=
kwargs.get(
'hooks'
,
dict
())
if
'response'
in
all_hooks:
if
hasattr
(all_hooks[
'response'
],
'__call__'
):
r_hooks
=
[all_hooks[
'response'
]]
else
:
r_hooks
=
all_hooks[
'response'
]
r_hooks.append(_request_check_location)
else
:
r_hooks
=
[_request_check_location]
all_hooks[
'response'
]
=
r_hooks
kwargs[
'hooks'
]
=
all_hooks
return
requests.get(url,
kwargs)
外部程序只要调用safe_request_url(url)即可安全地要求某个URL,该函数的参数与requests.get函数参数相同。
完美在Python Web开拓中办理SSRF漏洞。其他措辞的办理方案类似,大家可以自己去探索。
参考内容:
http://www.luteam.com/?p=211
http://docs.python-requests.org/