在Python中学习和对抗SSRF漏洞

早就知道了SSRF,听说那是有钱人的漏洞,只是一直没有花时间去实践和深入了解过,那么就边学习边写个博客记录下。

什么是SSRF?

SSRF的全称是 Server-Side Request Forgery ,也就是服务端请求伪造。

一般是服务端对用户输入的URL验证和处理不当导致,通过SSRF漏洞可以对服务端所在的内网进行扫描,发现内部敏感信息和脆弱系统,并取得控制器,如常见的Redis未授权访问。当服务端允许用户使用不同的协议时,可通过 file、 gophar 等协议获取特定的内容。

常见问题

  • URL为内网地址,攻击者可以对内网系统发起恶意请求。
  • URL中含有端口,攻击者可对内网系统扫描和攻击。
  • URL可使用自定义协议,攻击者可通过不同的协议对服务器发起攻击。

写一个问题代理脚本

假设公司机房的网络可以直接访问谷歌,为了更方便的翻墙写一个代理脚本放到机房并映射到外网,这样就能在家愉快的看Youtube,代码如下:

from urllib.request import urlopen
from flask import Flask, request

app = Flask(__name__)

@app.route('/proxy', methods=['GET'])
def proxy():
    url = request.args.get("url")
    req = urlopen(url)
    content = req.read()
    return content

if __name__ == '__main__':
    app.run()

使用这个脚本可以访问墙外内容再返回给自己,像这样:

http://127.0.0.1:5000/proxy?url=https://www.youtube.com/

通过查看代码可以发现这个脚本在裸奔,没有检查和处理url参数,以此可以发起SSRF攻击。

像这样访问本地服务或内网资源:http://127.0.0.1:5000/proxy?url=http://localhost:9200/


或者是这样读取服务器文件:http://127.0.0.1:5000/proxy?url=file:///C:/Windows/win.ini

针对问题做修复

URL可以是内网IP,如何解决?

检查被请求的Host是否为内网IP,这里可以使用Python标准库中的ipaddress库来检查。

def is_private(address):
    try:
        ip = ipaddress.IPv4Address(address)
    except ipaddress.AddressValueError:
        # When address is domain use socket to get IP.
        host = urlparse(address).hostname
        ip = socket.gethostbyname(host)
        ip = ipaddress.ip_address(ip)

    return ip.is_private
"""
>>> is_private("http://www.baidu.com")
Flase
>>> is_private("http://192.168.10.10")
True
"""

URL可以自定义协议头和端口,如何解决?

使用正则匹配或urllib.parse库检查URL。

    # Check protocol and port.
    if urlparse(address).scheme not in ['', 'http', 'https']:
        return "Illegal protocal."
    if urlparse(address).port and urlparse(address).port not in [80, 443]:
        return "Illegal port."

新增代码如下:

def safe_request(address):
    # Check protocol and port.
    if urlparse(address).scheme not in ['', 'http', 'https']:
        return "Illegal protocal."
    if urlparse(address).port and urlparse(address).port not in [80, 443]:
        return "Illegal port."

    # Check if the IP is private.
    if is_private(address) is True:
        return "Inner ip address attack."

    # Reqeuest and return response
    req = urlopen(address)
    content = req.read()

    return content
"""
>>> safe_request("file:///C:/Windows/win.ini")
'Illegal protocal.'
>>> safe_request("http://www.10.0.0.1.xip.io/")
'Inner ip address attack.'
>>> safe_request("http://localhost:9200/")
'Illegal port.'
"""

安全了么?

当请求的地址响应状态为30x时,多数的请求库都会自动跟随跳跳转,那么来试试短域名:http://127.0.0.1:5000/proxy?url=http://wz3.in/0jFl

通过查看流量发现短域名是一个302跳转,这就得出一个新问题:

  • URL是一个合法地址,但它能通过302跳转到内网地址。

30x跳转,如何解决?

请求URL后检查状态码,如果是30x就对新的地址进行检查或者不跟随跳转。

翻了下urllib文档,只有geturl()可以获取跳转URL,获取到跳转URL后再次使用safe_request函数请求该URL。

def safe_request(address):
    ...

    # Reqeuest and return response.
    req = urlopen(address)

    # Check redirect url
    url = req.geturl()
    if url != address:
        content = safe_request(url)
    else:
        content = req.read()

    return content
"""
>>> safe_request("http://wz3.in/0jFl")
'Illegal port.'
"""

不会再有问题了?

DNS Rebinding Attack

前面检查SSRF攻击流程是这样的:

  • 获取地址后检查协议、端口
  • 通过socket获取域名解析的IP地址
  • 验证IP地址是否为内网地址
  • 验证地址合法后使用urlopen请求该地址

如果我们让域名的解析结果在socket里面是外网,urlopen里面是内网呢?

这个时候就需要用到DNS Rebinding,设置域名的 TTL(Time-To-Live) 值为0,当两次请求的间隔时间大于TTL时,域名会被重新请求和解析。

使用ceye.io现成的DNS Rebinding工具,添加两个解析IP,一个外网一个内网。

为了测试重绑定攻击成功,使用Python的http.server监听本地80端口。

print(safe_request(“http://a.r.*****.ceye.io/”)) ,成功获取内网网页内容。

DNS Rebinding Attck 如何解决?

使用第一次检查URL获取到的IP作为请求地址,使用最初的Domain作为Host加入到Header中。

private, host, ip = is_private(address)
...
headers = {"Host": host}
request_address = address.replace(host, ip)
req = requests.get(request_address, allow_redirects=False, headers=headers)

新的问题

在给表哥看这个脚本的时候他发现了一个问题,我解决30x跳转时犯了错误,在urlopen请求后再取Location,这个时候已经请求成功了,即使我判断Location后不返回响应内容,但任然存在盲打的风险。

编程能力有限,看了半天的urllib.request文档都没找到不自动跳转的方法,于是使用requests重写这个脚本来解决所有问题。

重写后的代码如下,或通过Github查看

import ipaddress
import socket
from urllib.parse import urlparse

import requests
from flask import Flask, request

app = Flask(__name__)


def is_private(address):
    host = urlparse(address).hostname
    ip = socket.gethostbyname(host)
    is_private = ipaddress.ip_address(ip).is_private

    return is_private, host, ip


def safe_request(address):
    # Check protocol and port.
    if urlparse(address).scheme not in ['http', 'https']:
        return "Illegal protocal."
    if urlparse(address).port and urlparse(address).port not in [80, 443]:
        return "Illegal port."

    # Check if the IP is private.
    private, host, ip = is_private(address)
    if private is True:
        return "Inner ip address attack."

    # Reqeuest and return response.
    # Use IP as the request address and add host to haeders to prevent DNS Rebinding Attack.
    headers = {"Host": host}
    request_address = address.replace(host, ip)
    req = requests.get(request_address, allow_redirects=False, headers=headers)
    # Check redirect url
    if req.is_redirect:
        content = safe_request(req.headers['Location'])
    else:
        content = req.text

    return content


@app.route('/proxy', methods=['GET'])
def proxy():
    url = request.args.get("url")
    content = safe_request(url)

    return content


if __name__ == '__main__':
    app.run()

一个URL被传入时,应使用正确的方式取出协议头、Host、端口,并考虑各种可能出现的问题,如30x跳转和DNS重绑定等。

最后写出的代码可用性很差,为了安全性而放弃可用性… 总绝对怪怪的。或许还有更好的解决办法?

参考:
Use DNS Rebinding to Bypass IP Restriction
谈一谈如何在Python开发中拒绝SSRF漏洞

4 Replies to “在Python中学习和对抗SSRF漏洞”

  1. Hi,

    I tried the script in ubuntu vm:
    learn_SSRF_in_Python.py.After executing it i tried executing the following command in firefox.
    http://127.0.0.1:5000/proxy?url=https://www.example.com/.
    I got the following error:
    requests.exceptions.SSLError: HTTPSConnectionPool(host=’0.0.0.0(some ip)’, port=443): Max retries exceeded with url: / (Caused by SSLError(SSLError(“bad handshake: Error([(‘SSL routines’, ‘tls_process_server_certificate’, ‘certificate verify failed’)])”)))

    1. Because of requests verifies SSL certificates for HTTPS requests, just like a web browser.
      Requests can also ignore verifying the SSL certificate if you set verify to False.
      requests.get(‘https://www.example.com/’, verify=False)

      1. Hi,

        I already tried your suggestion but it didn’t help.
        I could not set up the dns rebinding attack as i could not understand this:
        Use cey.io off-the-shelf DNS Rebinding tool to add two parsing IPs, one external network and one intranet.
        where i could find this cey.io tool.Or is there any alternative

        1. Error about SSL. you can fix it by looking at the requests document.
          DNS Rebinding tool i only know ceye.io.

          “Use cey.io off-the-shelf DNS Rebinding tool to add two parsing IPs, one external network and one intranet.” set one external network to bypass ip check. after the check is passed, can request the intranet IP.
          The docs for ceye.io describes the principles and how to use them.

          I have a question. why not to go google english blog. but look at my chinese blog?you really interesting.

发表评论

电子邮件地址不会被公开。 必填项已用*标注