SSRF 攻击及其防御策略
1. 什么是 SSRF 攻击⚔
SSRF 全称 Server-side Request Forgery,即服务器端请求伪造。
其原理就是利用服务器端的某些漏洞,在客户端访问到服务器内部或者服务器所在内网的信息;而在正常情况下,这些信息是无法直接访问到的。
2. 一个简单的例子🌰
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import requests from flask import Flask, request app = Flask(__name__) @app.route("/") def index(): url = request.args.get("url") data = requests.get(url).text return data if __name__ == "__main__": app.run() |
这个例子中,我们实现了一个简易的 HTTP 服务器,这个服务器的功能只有一个,那就是获取 url
这个唯一的 URL 参数,在服务器端请求这个 url
参数所对应的地址,得到响应之后将响应的数据返回给客户端。
假设客户端通过某种途径知道了服务器所在的内网的某些地址,比如说 http://10.0.0.1/api/get
。在正常情况下,我们是无法直接访问到这个地址的。
如果这台服务器对外公布的地址为 http://test.test:5000
,通过刚才的服务器程序,我们只需请求 http://test.test:5000/?url=http://10.0.0.1/api/get
,即可借助服务器的 SSRF 漏洞,访问到 http://10.0.0.1/api/get
的内容。
这看起来是十分危险的,所以在服务器端必须采取某些措施来修复这一可能带来重大安全隐患的漏洞。
3. 基本的应对措施🛡
-
首先要限制住可以请求的协议,通常只允许
http/https
协议。 -
其次,要限制住可以请求的端口号,通常情况下,
http
协议只允许80
端口访问;https
协议只允许443
端口访问。 -
SSRF 攻击的一个重中之重是可以访问到内网的数据,因此我们必须解析出要请求的地址所对应的 IP,如果这个 IP 是内网 IP 的话,就必须拒绝这次请求。
下面总结内网 IP 的类型。
IP 段 解释 10/8 第一类 IP 地址中的内网 IP 172.16/12 第二类 IP 地址中的内网 IP 192.168/16 第三类 IP 地址中的内网 IP 127/8 本地环回测试地址 0/8 指向本网络,例如通过 0.0.0.0:4000
可以访问到127.0.0.1:4000
因为同一个 IP 段的前若干位(
/
后面的数字表示这一前缀的二进制位数)的二进制表示为一个定值,我们只需将解析出的 IP 通过二进制前缀比较的方式逐一判断是否为内网 IP 即可。 -
某些网站可能并没有解析为内网地址,但是它们通过
301/302
重定向的方式,还是可以请求到内网的地址。因此,非必要情况下,我们需要禁止所有的重定向请求/在服务器端以无跳转模式请求内容。如果一定要重定向,则可使用requests
包中的hook
,具体可参考:https://www.leavesongs.com/PYTHON/defend-ssrf-vulnerable-in-python.html#0x04-requestshooksssrf。 -
很多时候,我们只需要请求某一种特定类型的内容,比如图片。因此,在得到响应数据之后,我们可以对数据做类型检验,对于类型不符的内容,拒绝返回响应数据。
4. 上述应对措施的简单 Python 实现📟
4.1 准备工作 1:IP 地址的转换
我们知道,IP 地址通常使用点分十进制的形式表示,但在本例中,为方便处理,我们统一将点分十进制形式的 IP 地址转化为 32 位二进制整数。
1 2 3 4 | # 解析点分十进制 IP 字符串为 32 位二进制整数 def parse_ip_str(ip_str): ip_part_str = ip_str.split(".") return (int(ip_part_str[0]) << 24) + (int(ip_part_str[1]) << 16) + (int(ip_part_str[2]) << 8) + int(ip_part_str[3]) |
其原理就是将 8 位一组的 IP 子串取出,转化为 int
类型之后进行移位操作,将其归到正确的位置即可。
4.2 准备工作 2:内网 IP 检测
根据上文总结的内网 IP 类型,再结合刚才处理好的 32 位二进制形式的 IP 地址,我们只需比较 IP 地址的固定前缀是否相同即可判断这个 IP 地址是否为内网地址。取到前缀最简便的方法是通过二进制右移来得到指定长度的前缀。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 检查 IP 地址是否为内网地址或者本地环回测试地址 def check_if_intranet_ip(ip_str): binary_ip = parse_ip_str(ip_str) if (binary_ip >> 24) == (parse_ip_str("10.0.0.1") >> 24): return True elif (binary_ip >> 20) == (parse_ip_str("172.16.0.1") >> 20): return True elif (binary_ip >> 16) == (parse_ip_str("192.168.0.1") >> 16): return True elif (binary_ip >> 24) == (parse_ip_str("127.0.0.1") >> 24): return True elif (binary_ip >> 24) == (parse_ip_str("0.0.0.1") >> 24): return True return False |
4.3 步骤 1:检查协议和端口
这里利用到了 urllib.parse()
类。
这个类可以将一个 URL 字符串进行解析,格式如下:
1 | <scheme>://<netloc>/<path>?<query>#<fragment> |
其中,urllib.parse.urlparse(url).scheme
返回 URL 所使用的协议,urllib.parse.urlparse(url).port
返回 URL 所显式请求的端口(只有显式请求的端口才能被获取到),如果没有显式请求的话,则返回 None
。
1 2 3 4 5 6 7 8 9 10 | url_info = urllib.parse.urlparse(url) # Step 1: 只允许 http/https 协议,并只允许 80/443 端口 protocol = str(url_info.scheme) port = url_info.port if protocol != "http" and protocol != "https": return None if port is not None: if not ((protocol == "http" and port == 80) or (protocol == "https" and port == 443)): return None |
4.4 步骤 2:解析域名,获得 IP,并进行内网 IP 检测
我们通过 urllib.parse.urlparse(url).hostname
获取到所请求的域名。
之后,我们通过 socket.getaddrinfo()
对域名进行解析,这个函数的运行结果如下所示:
1 2 3 4 5 6 7 8 | Python 3.7.6 (default, Dec 30 2019, 19:38:28) [Clang 11.0.0 (clang-1100.0.33.16)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> import socket >>> socket.getaddrinfo("codecho.xyz", "443") [(<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('39.105.149.86', 443)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('39.105.149.86', 443))] >>> socket.getaddrinfo("codecho.xyz", "80") [(<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_DGRAM: 2>, 17, '', ('39.105.149.86', 80)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('39.105.149.86', 80))] |
有两点需要注意:
- 这个函数需要传入两个参数,分别是域名和端口号。在本例的情况下,对于
http
协议的请求,传入80
即可;对于https
协议的请求,传入443
即可。 - 这个函数返回一个五元组列表,其中我们要用的是每个五元组的第 4 个元素中的第 0 个元素。同时我们发现每个域名可以解析出多个 IP,对于每个 IP 都要进行检测。
因此有了下面的代码:
1 2 3 4 5 6 7 8 9 | # Step 2: 判断是否解析为了内网 IP,如果是就直接返回 None domain = url_info.hostname if protocol == "http": ip_list = [addr_info[4][0] for addr_info in socket.getaddrinfo(domain, "80")] else: ip_list = [addr_info[4][0] for addr_info in socket.getaddrinfo(domain, "443")] for ip in ip_list: if check_if_intranet_ip(ip): return None |
4.5 禁用跳转
这步很简单,对于基于 requests
包的请求,只需加上 allow_redirects=False
参数即可。
1 2 3 | # Step 3: 以无跳转模式请求内容 data = requests.get(url, allow_redirects=False, **kwargs).content return data |
4.6 完整代码(基于 Python 3 实现)
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | import requests import urllib.parse import socket # 解析点分十进制 IP 字符串为 32 位二进制整数 def parse_ip_str(ip_str): ip_part_str = ip_str.split(".") return (int(ip_part_str[0]) << 24) + (int(ip_part_str[1]) << 16) + (int(ip_part_str[2]) << 8) + int(ip_part_str[3]) # 检查 IP 地址是否为内网地址或者本地环回测试地址 def check_if_intranet_ip(ip_str): binary_ip = parse_ip_str(ip_str) if (binary_ip >> 24) == (parse_ip_str("10.0.0.1") >> 24): return True elif (binary_ip >> 20) == (parse_ip_str("172.16.0.1") >> 20): return True elif (binary_ip >> 16) == (parse_ip_str("192.168.0.1") >> 16): return True elif (binary_ip >> 24) == (parse_ip_str("127.0.0.1") >> 24): return True elif (binary_ip >> 24) == (parse_ip_str("0.0.0.1") >> 24): return True return False def secure_get(url, **kwargs): url_info = urllib.parse.urlparse(url) # Step 1: 只允许 http/https 协议,并只允许 80/443 端口 protocol = str(url_info.scheme) port = url_info.port if protocol != "http" and protocol != "https": return None if port is not None: if not ((protocol == "http" and port == 80) or (protocol == "https" and port == 443)): return None # Step 2: 判断是否解析为了内网 IP,如果是就直接返回 None domain = url_info.hostname if protocol == "http": ip_list = [addr_info[4][0] for addr_info in socket.getaddrinfo(domain, "80")] else: ip_list = [addr_info[4][0] for addr_info in socket.getaddrinfo(domain, "443")] for ip in ip_list: if check_if_intranet_ip(ip): return None # Step 3: 以无跳转模式请求内容 data = requests.get(url, allow_redirects=False, **kwargs).content return data |
5. 参考资料
https://www.leavesongs.com/PYTHON/defend-ssrf-vulnerable-in-python.html#0x04-requestshooksssrf
https://www.cnblogs.com/hnrainll/archive/2011/10/13/2210101.html
https://xz.aliyun.com/t/2115