一、基本介绍
SSRF(Server-Side Request Forgery)是一种安全漏洞,攻击者可以利用该漏洞诱使服务器向任意的内部或外部资源发起请求。由于请求是由服务器发起的,攻击者可以利用这一点来访问本不应公开的资源,从而造成信息泄露、服务中断或其他安全问题。
SSRF危害:
-
内网端口扫描和服务发现
-
发现内部服务:攻击者可以利用SSRF请求内网IP地址,识别内部运行的各种服务(如数据库、管理接口、API等)
-
本地应用的攻击
SSRF还可以直接攻击运行在服务器上的本地应用程序。例如:
-
访问本地资源:攻击者可以将请求发送到localhost或127.0.0.1,试图访问本地服务(如本地数据库或管理控制台)。 -
执行恶意操作:一旦访问到本地服务,攻击者可以执行未授权的操作,如修改数据、获取敏感信息等。
-
利用File协议读取本地文件
如果应用程序对请求没有进行严格的限制,攻击者可能利用File协议读取服务器上的本地文件:
-
敏感信息泄露:攻击者可以请求特定的文件路径,比如配置文件、日志文件等,获取敏感信息。 -
文件系统遍历:通过构造特定的请求,攻击者可能能够进行目录遍历,访问服务器上任意文件。
二、利用方式
SSRF(服务器端请求伪造)攻击可以分为有回显(Echo)和无回显(No Echo)两种类型,这两种类型的攻击方式在利用和处理信息的方式上有所不同。
-
有回显SSRF
有回显 SSRF 是指攻击者能够直接从目标服务器收到请求响应的攻击方式。在这种情况下,攻击者可以通过 SSRF 请求获取内部服务的响应内容。
攻击者利用 SSRF 漏洞请求内部 API,并且能够获取到响应数据。例如,攻击者可以请求内部的 Redis 服务,获取存储的数据。攻击者可以请求内部的元数据服务(如云服务提供商的元数据 API),从中获取敏感信息,如访问令牌、实例 ID 等。
-
无回显SSRF
无回显 SSRF 是指攻击者无法直接从目标服务器获取请求响应的攻击方式。在这种情况下,虽然攻击者仍然可以通过 SSRF 请求内部服务,但无法获取到响应数据。
这种情况下利用较为困难,一般需要配合其他信息泄露来进行综合利用。详细的可以参考 https://github.com/assetnote/blind-ssrf-chains
-
代码示例
如下代码,targetUrl 用户可控,且未做校验,例如用户传入http://127.0.0.1:6379 探测端口是否开放
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
@RestController
public class SSRFAttackController {
@GetMapping("/ssrf")
public String performSSRF(@RequestParam String targetUrl) {
StringBuilder response = new StringBuilder();
try {
// 创建 URL 对象
URL url = new URL(targetUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
// 读取响应
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
} catch (Exception e) {
e.printStackTrace();
return "Error: " + e.getMessage();
}
// 输出响应
return "Response from target: " + response.toString();
}
}
三、漏洞防御
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Set;
public class SSRFValidator {
// 定义内网 CIDR 范围
private static final String[] PRIVATE_CIDR_RANGES = {
"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
"192.168.0.0/16", // 192.168.0.0 - 192.168.255.255
"127.0.0.0/8", // 本地回环地址
"0.0.0.0/8" // 0.0.0.0,通常表示无效 IP 地址
};
// 定义白名单域名
private static final Set<String> ALLOWED_DOMAINS = new HashSet<>();
// 定义黑名单域名
private static final Set<String> DISALLOWED_DOMAINS = new HashSet<>();
static {
// 添加白名单一级域名
ALLOWED_DOMAINS.add("dewu.com");
ALLOWED_DOMAINS.add("shizhuang-inc.com");
ALLOWED_DOMAINS.add("poizon.com");
ALLOWED_DOMAINS.add("shizhuang-inc.net");
ALLOWED_DOMAINS.add("poizon.net");
ALLOWED_DOMAINS.add("dewu.net");
// 可以继续添加更多允许的一级域名
// 添加黑名单域名
DISALLOWED_DOMAINS.add("malicious.com");
DISALLOWED_DOMAINS.add("dangerous.com");
// 可以继续添加更多黑名单域名
}
/**
* 将 IP 地址转换为整型
* @param ip IP 地址
* @return 长整型表示的 IP 地址
*/
public static long ipToLong(String ip) {
String[] ipParts = ip.split("\.");
long result = 0;
for (int i = 0; i < ipParts.length; i++) {
result |= (Long.parseLong(ipParts[i]) << (24 - (8 * i)));
}
return result;
}
/**
* 判断给定的 IP 地址是否为内网地址
* @param ip IP 地址
* @return 如果是内网地址返回 true,否则返回 false
*/
public static boolean isInnerIPAddress(String ip) {
long ipLong = ipToLong(ip); // 将字符串转换为长整型表示的 IP
// 遍历每个内网 CIDR 范围,检查是否包含该 IP
for (String cidrRange : PRIVATE_CIDR_RANGES) {
String[] parts = cidrRange.split("/");
long baseIpLong = ipToLong(parts[0]); // 基础 IP 地址
int prefixLength = Integer.parseInt(parts[1]); // 前缀长度
long mask = (1L << (32 - prefixLength)) - 1; // 计算掩码
// 检查 IP 是否在 CIDR 范围内
if ((ipLong & ~mask) == (baseIpLong & ~mask)) {
return true; // 如果 IP 地址在内网范围内,返回 true
}
}
return false; // IP 地址不在内网范围内
}
/**
* 检查一级域名是否在白名单中
* @param domain 域名
* @return 如果在白名单中返回 true,否则返回 false
*/
public static boolean isAllowedDomain(String domain) {
// 获取一级域名
String[] domainParts = domain.split("\.");
String primaryDomain = domainParts.length > 1 ? domainParts[domainParts.length - 2] + "." + domainParts[domainParts.length - 1] : domain;
// 检查一级域名是否在白名单中,使用小写进行比较以避免大小写问题
return ALLOWED_DOMAINS.contains(primaryDomain.toLowerCase());
}
/**
* 检查一级域名是否在黑名单中
* @param domain 域名
* @return 如果在黑名单中返回 true,否则返回 false
*/
public static boolean isDisallowedDomain(String domain) {
// 获取一级域名
String[] domainParts = domain.split("\.");
String primaryDomain = domainParts.length > 1 ? domainParts[domainParts.length - 2] + "." + domainParts[domainParts.length - 1] : domain;
// 检查一级域名是否在黑名单中,使用小写进行比较以避免大小写问题
return DISALLOWED_DOMAINS.contains(primaryDomain.toLowerCase());
}
/**
* 检查给定 URL 是否存在 SSRF 风险
* @param url 需要检查的 URL
* @return 返回一个包含检查结果的 Pair 对象,包含是否有效和相应的消息
*/
public static Pair<Boolean, String> checkSSRF(String url) {
try {
// 解析 URL
java.net.URL parsedUrl = new java.net.URL(url);
String hostname = parsedUrl.getHost(); // 获取主机名
// 首先检查主机名是否在白名单中
if (isAllowedDomain(hostname)) {
return new Pair<>(true, "Domain is allowed"); // 如果在白名单中,直接返回允许
}
// 检查主机名是否在黑名单中
if (isDisallowedDomain(hostname)) {
return new Pair<>(false, "Domain is disallowed"); // 如果在黑名单中,返回不允许
}
// 获取 IP 地址
String ipAddress = InetAddress.getByName(hostname).getHostAddress();
// 检查是否为内网 IP 地址
if (isInnerIPAddress(ipAddress)) {
return new Pair<>(false, "Inner IP address attack"); // 返回内网地址的错误信息
}
return new Pair<>(true, "Success"); // 返回成功信息
} catch (Exception e) {
return new Pair<>(false, e.getMessage()); // 返回异常信息
}
}
// Pair 类用于返回多个值
public static class Pair<K, V> {
private K key; // 存储结果
private V value; // 存储消息
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key; // 获取结果
}
public V getValue() {
return value; // 获取消息
}
}
public static void main(String[] args) {
// 测试 URL
String testUrl = "http://www.dewu.com.com/api"; // 白名单中的地址
// String testUrl = "http://malicious.com/api"; // 黑名单地址测试
// String testUrl = "http://192.168.1.1/api"; // 内网地址测试
// String testUrl = "http://unknown.com/api"; // 不在黑白名单的地址
Pair<Boolean, String> result = checkSSRF(testUrl); // 检查 URL
System.out.println("Valid: " + result.getKey() + ", Message: " + result.getValue()); // 输出结果
}
}
-
输入****白名单:限制用户输入的 URL 仅允许访问特定的、信任的服务和域名。使用白名单机制来确保只允许合法的请求。 -
格式验证:检查输入的 URL 格式,确保它们符合预期的结构。可以使用正则表达式来验证 URL 的格式。 -
黑名单:维护一个黑名单,禁止访问恶意或未知的域名。
在做SSRF防御的时候,优先考虑白名单校验,仅允许特定域名白名单, 但总会有各种各样的业务需求,例如对网站在线翻译,无法一一枚举这些域名白名单,那就只能做黑名单方式,禁止传入内网地址。
四、如何校验内网
相信很多开发者第一眼看到这个就觉得很简单,直接校验10.x 和192.x网段不就可以了吗?
内网 IP 地址是根据 RFC 1918 和 RFC 3330 协议定义的。以下是常见的内网 IP 段:
-
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 -
192.168.0.0/16:192.168.0.0 到 192.168.255.255 -
127.0.0.0/8:本地回环地址 -
0.0.0.0/8:通常表示无效地址,在服务器中,0.0.0.0指的是本机上的所有IPV4地址,在Linux下,127.0.0.1与0.0.0.0都指向本地。
很多人会忘记 127.0.0.0/8 ,认为本地地址就是 127.0.0.1 ,实际上本地回环包括了整个127段。你可以访问http://127.233.233.233/
,会发现和请求127.0.0.1是一个结果
所以我们需要防御的实际上是5个段,只要IP不落在这5个段中,就认为是“安全”的。
类似网上常见的通过正则表达式校验,判断目标IP是否在这几个段中,这种判断方法通常是会漏报的,例如
-
利用八进制、十六进制IP地址绕过 -
利用IP地址的省略写法绕过
例如:012.0.0.1 、 0xa.0.0.1 、 167772161 、 10.1 、 0xA000001 这几个实际上都请求的是10.0.0.1, 如果编写正则表达式,实际这几个都无法命中。
防御方法
为了有效防止这些绕过,我们可以采取以下步骤:
-
使用标准库进行 IP 地址解析:Java 提供了对 IP 地址的处理功能,可以将不同格式的 IP 地址转换为标准的 IPv4 或 IPv6 地址。这种方式可以避免八进制、十六进制、十进制和省略写法的绕过。 -
进行 IP 范围检查:在获取标准 IP 地址后,检查它是否在定义的内网段中。
五、如何校验Host
5.1 如何正确获取用户输入的 URL 的 Host?
直接使用正则表达式来解析 URL 是不可靠的。使用标准库函数来解析 URL 是获取 Host 的正确方法。以下是 Java 中的实现示例,类似于 Python 的 urlparse
, 这一步是关键,http://[email protected]:8080 类似这种实际请求的是10.0.0.1,但如果用正则很容易出错。
import java.net.URL;
public class URLParser {
public static String getHostFromUrl(String url) {
try {
URL parsedUrl = new URL(url);
return parsedUrl.getHost(); // 获取 Host
} catch (Exception e) {
// 处理异常
return null;
}
}
}
5.2 如何判断Host地址不是内网IP
检查 Host 是否为内网 IP 并不足以防止 SSRF 攻击。还需要考虑以下几个方面:
-
域名解析:如果 Host 是一个域名,您需要将其解析为 IP 地址,并检查解析得到的 IP 是否为内网 IP。这是因为域名可以指向任何 IP 地址,包括内网 IP。 -
DNS劫持:攻击者可以通过 DNS 劫持将域名解析为内网 IP,因此您必须确保解析后的 IP 地址不在内网范围内。
以下是一个示例,展示如何在 Java 中进行域名解析并检查是否为内网 IP:
import java.net.InetAddress;
public class SSRFProtection {
public static boolean isSafeHost(String host) {
try {
InetAddress[] addresses = InetAddress.getAllByName(host); // 获取所有解析的 IP 地址
for (InetAddress address : addresses) {
String ipAddress = address.getHostAddress();
if (isInnerIPAddress(ipAddress)) {
return false; // 如果解析到的 IP 是内网 IP,返回不安全
}
}
return true; // 所有解析的 IP 都不是内网 IP,返回安全
} catch (Exception e) {
// 处理异常
return false; // 解析失败,返回不安全
}
}
// 检查是否为内网 IP 的方法
public static boolean isInnerIPAddress(String ip) {
// ... (使用之前提到的内网 IP 检查逻辑)
return false; // 示例返回
}
}
5.3 如何确保 Host 指向的 IP 不是内网 IP?
当请求的目标返回 30X 状态码时,如果没有禁止跳转的设置,大多数 HTTP 库会自动跟随重定向。这种情况下,如果跳转的地址是内网地址,将会导致 SSRF 漏洞。
以 Python 的 requests
库为例,allow_redirects
参数控制是否自动跟随重定向。默认情况下,该参数为 True
,这意味着请求会自动跟踪 Location
头中指向的地址。
import requests
response = requests.get("https://www.baidu.com/", allow_redirects=True)
print(response.status_code) # 返回最终页面的状态码
如果设置 allow_redirects=False
,则不会跟随重定向:
为了避免 SSRF 漏洞,可以采取以下两种解决方案:
-
禁止自动重定向: -
设置 allow_redirects=False
,不允许自动跟随跳转。这种方法虽然有效,但可能会影响业务逻辑,特别是在需要跳转的情况下。 -
手动处理重定向: -
在每次跳转时检查新的 Host 是否是内网 IP。只有在最终目标的 Host 通过 is_inner_ipaddress
检查后,才允许继续请求。这样可以确保安全性,同时不影响业务逻辑。
六、总结
为了有效防止SSRF漏洞的发生,建议实现一个统一的校验框架。该框架的主要目标是对所有的外部请求进行严格的验证和限制,确保请求的安全性。具体实现步骤如下:
-
请求验证: -
对请求的URL进行白名单校验,限制允许的IP和域名。 -
禁止访问 localhost
、127.0.0.1
及其他内部网络地址。 -
协议限制: -
限制HTTP/HTTPS协议,禁止其他协议(如File、FTP等)的使用。 -
参数过滤: -
对请求参数进行严格的验证和过滤,防止恶意构造的请求。 -
统一调用: -
业务逻辑层的各个模块应调用该统一校验框架进行请求验证,确保每一个发起的请求都经过校验。 -
日志记录和监控: -
对所有请求进行日志记录,监控异常请求行为,及时发现和响应潜在的攻击。
原文始发于微信公众号(SDL安全):SSRF漏洞详解与防护措施
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论