Java 安全编码实战——反序列化漏洞、SSRF、XXE 的防御方法与测试
Java 安全编码实战——反序列化漏洞、SSRF、XXE 的防御方法与测试
适读人群:Java 开发者,希望提升代码安全意识和实践能力的同学 | 阅读时长:约 17 分钟 | 核心价值:三类高危漏洞的原理、真实案例、可运行的防御代码,以及如何自己测试
2022 年,我们做了一次内部安全评审,找了一个专业的安全团队做渗透测试。
结果很难看:3 个严重漏洞,7 个高危漏洞,其中有 2 个我直接参与开发的功能榜上有名。一个是 SSRF,一个是 XXE。
当时我的第一反应是:这些漏洞我听说过,但我不知道我写的代码里有。
安全问题的可怕之处就在这里——不是你不知道,是你以为自己没有,但其实有。这篇文章把我排查和修复的过程整理出来,希望你在上线之前就把这些坑堵上。
反序列化漏洞:最危险的一类
原理在之前的序列化文章里提过,这里重点说防御。
场景:接收外部传入的序列化数据
如果你的接口接收 Java 原生序列化的字节流(比如老版 RMI、某些消息队列格式),攻击者可以构造恶意字节流,在反序列化时执行任意代码。
防御一:使用 ObjectInputFilter(Java 9+)
package com.example.security;
import java.io.*;
/**
* 使用 ObjectInputFilter 限制反序列化的类
* 白名单方式:只允许反序列化指定的类
*/
public class SafeObjectDeserializer {
// 白名单:只允许反序列化这些包下的类
private static final ObjectInputFilter SAFE_FILTER = ObjectInputFilter.Config.createFilter(
"com.example.model.*;" + // 允许自己的模型类
"java.util.ArrayList;" + // 允许常用 JDK 类
"java.util.HashMap;" +
"java.lang.String;" +
"!*" // 其他所有类都拒绝
);
/**
* 安全的反序列化方法
*/
public static <T> T deserialize(byte[] bytes, Class<T> expectedType)
throws IOException, ClassNotFoundException {
try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis)) {
// 设置过滤器,拒绝不在白名单里的类
ois.setObjectInputFilter(SAFE_FILTER);
Object obj = ois.readObject();
// 额外做类型检查
if (!expectedType.isInstance(obj)) {
throw new SecurityException("反序列化类型不匹配: 期望 " + expectedType.getName()
+ " 实际是 " + obj.getClass().getName());
}
return expectedType.cast(obj);
}
}
}防御二:业务上直接弃用 Java 原生序列化
如前文所说,对外接口换成 JSON 或 Protobuf,从根源上消除这类漏洞。这是最彻底的解法。
SSRF(服务端请求伪造):我亲自踩的坑
SSRF 的场景:你的服务提供了一个功能,根据用户传入的 URL 去请求数据(比如"获取 URL 对应网页的标题"、"下载用户指定的图片")。
攻击者传入的不是正常的外网 URL,而是内网地址:
http://169.254.169.254/latest/meta-data/(AWS 元数据,能拿到临时 AK/SK)http://192.168.1.1/admin(内网管理页面)http://localhost:8080/internal-api(服务本身的内部接口)
我们被发现的那个漏洞,是一个"预览外部图片"的功能,直接把用户传入的 URL 做了 HTTP 请求,没有任何校验。
有漏洞的代码:
// 有 SSRF 漏洞的代码
@GetMapping("/preview-image")
public byte[] previewImage(@RequestParam String imageUrl) throws IOException {
// 直接请求用户传入的 URL,攻击者可以探测内网
URL url = new URL(imageUrl);
return url.openStream().readAllBytes();
}防御实现:
package com.example.security;
import java.net.*;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
/**
* SSRF 防御:URL 安全校验
* 策略:白名单 + 内网 IP 黑名单
*/
public class SsrfDefender {
// 内网 IP 范围
private static final List<String> PRIVATE_PREFIXES = Arrays.asList(
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"127.",
"169.254." // AWS 元数据地址
);
// 允许的协议白名单
private static final List<String> ALLOWED_SCHEMES = Arrays.asList("http", "https");
/**
* 校验 URL 是否安全
* @throws SecurityException 如果 URL 不安全
*/
public static void validate(String urlString) throws SecurityException {
if (urlString == null || urlString.isBlank()) {
throw new SecurityException("URL 不能为空");
}
URL url;
try {
url = new URL(urlString);
} catch (MalformedURLException e) {
throw new SecurityException("非法 URL 格式: " + urlString);
}
// 校验协议
if (!ALLOWED_SCHEMES.contains(url.getProtocol().toLowerCase())) {
throw new SecurityException("不允许的协议: " + url.getProtocol()
+ ",仅允许 http/https");
}
// DNS 解析,获取实际 IP(防止 DNS 绑定攻击)
String host = url.getHost();
InetAddress address;
try {
address = InetAddress.getByName(host);
} catch (UnknownHostException e) {
throw new SecurityException("无法解析主机: " + host);
}
String ip = address.getHostAddress();
// 检查是否是内网 IP
for (String prefix : PRIVATE_PREFIXES) {
if (ip.startsWith(prefix)) {
throw new SecurityException("不允许访问内网地址: " + ip);
}
}
// 检查 loopback
if (address.isLoopbackAddress()) {
throw new SecurityException("不允许访问本地回环地址");
}
// 检查任意本地地址
if (address.isAnyLocalAddress()) {
throw new SecurityException("不允许访问本地地址");
}
}
}
// 使用方式
// @GetMapping("/preview-image")
// public byte[] previewImage(@RequestParam String imageUrl) throws IOException {
// SsrfDefender.validate(imageUrl); // 先校验
// URL url = new URL(imageUrl);
// return url.openStream().readAllBytes();
// }注意:DNS 重绑定(DNS Rebinding)攻击
上面的代码做了 DNS 解析,但有一个漏洞:在你解析 DNS 之后、真正发请求之前,攻击者可以让 DNS 的解析结果变成内网 IP(DNS Rebinding)。
更严格的方案是:用自定义的 HTTP 客户端,在连接建立后再次检查实际连接的 IP:
package com.example.security;
import java.net.InetAddress;
import java.net.Socket;
import javax.net.ssl.SSLSocketFactory;
/**
* 安全的 HTTP 连接工厂:在实际连接时验证 IP
* 防止 DNS Rebinding 攻击
*/
public class SsrfSafeSocketFactory extends javax.net.SocketFactory {
@Override
public Socket createSocket(String host, int port) throws java.io.IOException {
// 解析 IP
InetAddress address = InetAddress.getByName(host);
// 连接前再次验证
SsrfDefender.validate("http://" + address.getHostAddress());
return new Socket(address, port);
}
// 其他方法省略...
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
throws java.io.IOException { return createSocket(host, port); }
@Override
public Socket createSocket(InetAddress host, int port) throws java.io.IOException {
SsrfDefender.validate("http://" + host.getHostAddress());
return new Socket(host, port);
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
throws java.io.IOException { return createSocket(address, port); }
}XXE(XML 外部实体注入):另一个被我低估的漏洞
XXE 的场景:服务端解析用户上传的 XML 文件时,XML 里声明了外部实体,XML 解析器去加载这个外部实体(可能是本地文件或内网 URL)。
有漏洞的 XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>
<data>&xxe;</data>
</root>如果服务端把这段 XML 解析了,返回值里会包含 /etc/passwd 的内容。
我们被发现的漏洞: 一个导入功能,接收用户上传的 XML,用默认配置的 DocumentBuilder 解析,没有禁用外部实体。
有漏洞的代码:
// 有 XXE 漏洞的代码
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(userUploadedXmlStream); // 危险!防御代码(必须这样配置):
package com.example.security;
import org.xml.sax.SAXException;
import javax.xml.parsers.*;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.*;
import java.io.InputStream;
/**
* 安全的 XML 解析工具,禁用所有外部实体和 DTD
*/
public class SafeXmlParser {
/**
* 创建安全的 DocumentBuilderFactory
* 这些配置缺一不可,每一行都在堵一个漏洞
*/
public static DocumentBuilderFactory createSafeFactory() throws ParserConfigurationException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// 禁用 DOCTYPE 声明(最彻底的防御)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// 禁用外部普通实体
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
// 禁用外部参数实体
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
// 禁用外部 DTD
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
// 禁止使用内联 DOCTYPE 的外部实体
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
public static org.w3c.dom.Document parse(InputStream xmlStream)
throws ParserConfigurationException, Exception {
DocumentBuilderFactory factory = createSafeFactory();
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(xmlStream);
}
}踩坑实录一:禁了 DOCTYPE 导致合法业务 XML 解析失败
我们把上面的防御部署上去之后,发现有一个老业务的 XML 导入功能挂了,因为那个功能的 XML 确实有 DOCTYPE 声明(虽然没有外部实体,但用了内部 DTD 做结构定义)。
碰到这种情况,不能图省事把安全配置关掉,应该把这类 XML 提前转换,移除 DOCTYPE 后再解析,或者给这类业务场景单独维护一套 XML Schema 校验。
踩坑实录二:SSRF 校验漏掉了 IPv6 地址
我们的 SSRF 防御只检查了 IPv4,有同事测试时发现用 http://[::1]/admin(IPv6 loopback)可以绕过。
修复:在 InetAddress.isLoopbackAddress() 和 isAnyLocalAddress() 检查时,这两个方法对 IPv6 同样有效,关键是要确保 DNS 解析返回的地址被完整检查,不要只判断 ip.startsWith("127.") 这种字符串匹配。
踩坑实录三:测试安全漏洞的方法
自己验证安全修复是否有效,不需要专业工具,几行代码就能测:
package com.example.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SsrfDefenderTest {
@Test
void testBlocksLocalhostVariants() {
// 测试各种 localhost 变体
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://localhost/admin"));
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://127.0.0.1/secret"));
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://0.0.0.0/api"));
}
@Test
void testBlocksPrivateIpRanges() {
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://192.168.1.1/panel"));
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://10.0.0.1/internal"));
assertThrows(SecurityException.class, () -> SsrfDefender.validate("http://172.16.0.1/db"));
}
@Test
void testBlocksNonHttpSchemes() {
assertThrows(SecurityException.class, () -> SsrfDefender.validate("file:///etc/passwd"));
assertThrows(SecurityException.class, () -> SsrfDefender.validate("ftp://attacker.com/file"));
}
@Test
void testAllowsLegitimateUrls() {
assertDoesNotThrow(() -> SsrfDefender.validate("https://api.example.com/data"));
}
}安全问题不是一次性解决的,是需要持续关注的。 把安全测试加入 CI/CD,每次发布前跑一遍,比事后被渗透测试发现要好得多。
