Java 基础体系 · 第 34/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 服务安全:反序列化、表达式注入、SSRF、TLS 和供应链
Java 服务安全问题常被分成若干独立分类:反序列化漏洞、表达式注入、SSRF、TLS 配置错误和依赖供应链风险。它们实际上共享一条因果链:
外部输入进入高权限解释器、对象构造器、网络客户端或构建系统,并获得了超出业务需要的能力。
因此,安全设计的核心不是“找到某个危险字符串”,而是明确以下四件事:
- 数据从哪里进入系统;
- 系统在哪一步把数据解释成代码、对象、地址或证书信任;
- 该解释动作拥有哪些权限;
- 失败时是否会退化为更危险的默认行为。
下面的内容以 Java 25 及其常见 Web 服务运行方式为范围,涉及 Spring 时会区分 Java、Servlet、Spring Framework 和 Spring Security 各自提供的能力。
一、先建立安全边界:数据、解释器与能力
1.1 不可信输入不只来自 HTTP 参数
服务端通常会把以下内容视为输入:
- HTTP 路径、查询参数、请求体、请求头和 Cookie;
- JWT、OAuth2 Token、SAML 文档;
- 数据库中历史保存的 JSON、XML 或二进制字段;
- 消息队列、缓存、文件上传和对象存储;
- 环境变量、配置中心和启动参数;
- Maven/Gradle 依赖、插件、构建脚本和容器基础镜像;
- 远程地址、证书、DNS 结果和重定向响应。
“来自数据库”不等于可信。数据库中的值可能由过去的漏洞写入,也可能由低权限用户间接控制。
1.2 解释器是风险放大器
一个普通字符串本身通常没有执行能力。危险发生在系统把它交给了具有特殊语义的组件:
| 输入被交给的组件 | 输入可能被解释为 |
|---|---|
ObjectInputStream |
类名、字段和对象图 |
| SpEL、EL、OGNL、模板引擎 | 方法调用、属性访问或表达式 |
| HTTP 客户端 | DNS 名称、IP、端口、重定向目标 |
| JSSE/TLS | 对端身份、证书链和密码套件 |
| Maven、Gradle、容器构建器 | 依赖、插件、脚本和二进制产物 |
安全边界应当限制“数据可以变成什么”。例如,业务只需要一个排序字段,就不应让调用方提交一段可以被解释器执行的表达式;业务只需要访问固定的图片存储域名,就不应允许任意 URL。
二、Java 反序列化:对象恢复不是普通数据解析
2.1 Java 原生序列化做了什么
Java 原生序列化由 Serializable、ObjectOutputStream 和 ObjectInputStream 构成。序列化时,运行时会保存类描述、字段值以及对象之间的引用关系;反序列化时,它会重新建立对象图。
反序列化不是简单的:
字节数组 -> 无害的字段集合
更准确的过程是:
字节流
-> 读取类描述
-> 分配对象
-> 恢复字段和引用
-> 调用特殊恢复逻辑
-> 触发对象图中对象的行为
对象恢复过程中可能涉及:
readObject;readResolve;validateObject;Externalizable.readExternal;- 集合、代理和其他对象的间接恢复逻辑。
因此,即使业务代码没有显式调用一个危险方法,反序列化过程本身也可能触发类库代码。所谓 gadget chain,就是一组现有类按照特定对象关系组合后,在恢复对象时形成意外的调用链。
关键点是:漏洞不要求攻击者上传一个“恶意 Java 类”。如果目标进程的 classpath 中已经存在可利用的类组合,攻击者只需控制序列化数据即可。
2.2 一个最小的危险边界示例
下面的代码可以运行,但其输入边界是危险的:
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
public final class UnsafeRead {
public static Object read(byte[] bytes) throws Exception {
try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
return in.readObject();
}
}
}
危险不在于返回类型写成了 Object,而在于 ObjectInputStream 默认允许输入声明任意可加载类。即使调用方随后写成:
User user = (User) UnsafeRead.read(bytes);
也不能把风险降到 User。强制类型转换发生在反序列化之后;危险对象可能已经在 readObject 等阶段执行了逻辑。
2.3 类型检查、过滤和允许列表的区别
需要区分三种防线:
- 类型转换:在对象已经被恢复之后检查类型,太晚;
- 反序列化过滤器:在恢复对象图时限制类、深度、引用数和字节数;
- 格式替换或允许列表:从协议层面不使用原生序列化,或者只允许明确的类型集合。
Java 提供 ObjectInputFilter 机制。下面是一个允许少量业务类型并限制对象图规模的示例:
import java.io.ByteArrayInputStream;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
public final class FilteredRead {
private static final ObjectInputFilter FILTER =
ObjectInputFilter.Config.createFilter(
"com.example.api.User;"
+ "java.base/java.lang.String;"
+ "java.base/java.util.ArrayList;"
+ "maxdepth=8;"
+ "maxrefs=200;"
+ "maxbytes=1048576;"
+ "!*");
public static Object read(byte[] bytes) throws Exception {
try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(FILTER);
return in.readObject();
}
}
}
过滤器字符串中的含义是:
com.example.api.User:允许指定类;java.base/java.lang.String:允许字符串;java.base/java.util.ArrayList:允许列表;maxdepth=8:对象图嵌套深度不能超过 8;maxrefs=200:引用数量不能超过 200;maxbytes=1048576:读取字节数不能超过 1 MiB;!*:其他未匹配类型拒绝。
对象图限制主要防止资源耗尽,并不等于阻止所有代码执行。一个允许列表过宽的过滤器仍可能允许危险类进入;一个只限制 maxbytes 的过滤器也不能证明类安全。
拒绝时通常会抛出 InvalidClassException,具体日志中可看到被过滤的类或资源条件。生产诊断应记录协议来源、过滤器拒绝原因和请求关联 ID,但不要把完整攻击载荷写入日志。
Java 还支持通过 jdk.serialFilter 配置全局过滤器。全局过滤器可以作为统一兜底,但应用级允许列表仍需要按协议设计。全局规则过宽会放大所有组件的信任范围,过窄则可能导致不相关组件启动或运行失败。
2.4 更可靠的协议替换
如果服务控制通信双方,通常应把原生序列化替换成显式数据协议,例如 JSON、CBOR 或 Protocol Buffers。替换的安全意义不是“JSON 天然安全”,而是:
- 字段集合由协议定义;
- 类型构造通常不由输入中的 Java 类名决定;
- 业务可以明确拒绝未知字段、过长字符串和异常嵌套;
- 序列化数据不会自动恢复任意 Java 对象图。
例如,使用 Jackson 时应避免把外部输入绑定到带有多态类型信息的任意基类:
public record User(String id, String displayName) {}
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(
requestBody,
User.class
);
这里的安全边界是固定的 User 类型。相反,使用类似“根据 JSON 中的类型字段决定任意 Java 类”的默认多态反序列化,会重新引入类名驱动对象构造的风险。具体配置取决于 Jackson 版本,不能用一个通用开关证明所有类型安全;应优先使用明确的基类允许列表,并限制输入大小和嵌套深度。
常见误解
- “只要实现了
Serializable的类没有危险代码就安全。”
对象图可能包含其他类,且风险来自整个 classpath。 - “先反序列化,再检查
instanceof就够了。”
恢复行为已经发生。 - “加密或签名后的序列化数据安全。”
加密能保护机密性,签名能保护完整性,但被授权发送方仍可能提交危险对象;如果密钥泄露,边界完全失效。 - “过滤器配置成功就万事大吉。”
过滤器需要测试拒绝路径、版本升级后的新类和所有反序列化入口。
三、表达式注入:字符串何时从数据变成程序
3.1 表达式注入的形式化条件
设用户输入为 x,应用有一个表达式解释器 E,上下文和权限为 C。当代码计算:
并且 x 能影响语法结构,C 又暴露了方法调用、对象构造、类访问、Bean 访问或文件/网络能力时,就存在表达式注入风险。
安全的参数化计算应当是:
其中:
e_fixed是开发者固定的表达式;x只是变量值;- 变量值不会改变表达式的语法树。
这就是“数据参数化”和“表达式拼接”的根本差异。
3.2 Spring SpEL 的危险和安全写法
危险写法:
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
public String evaluate(String userInput) {
ExpressionParser parser = new SpelExpressionParser();
return parser.parseExpression(userInput).getValue(String.class);
}
userInput 在这里是完整表达式。输入不再只是用户名或排序字段,而可能被解析成属性访问、方法调用或其他 SpEL 语义。具体可访问能力取决于评估上下文,但“输入控制整个表达式”本身已经是错误边界。
如果业务确实需要固定表达式,应让输入只作为变量:
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
public final class SafeExpression {
private final Expression expression;
public SafeExpression() {
ExpressionParser parser = new SpelExpressionParser();
this.expression = parser.parseExpression("#name.trim().length()");
}
public int lengthOf(String name) {
var context = SimpleEvaluationContext.forReadOnlyDataBinding()
.withRootObject(new Object())
.build();
return expression.getValue(
context,
java.util.Map.of("name", name),
Integer.class
);
}
}
这里:
- 表达式由代码固定;
name只作为变量值;SimpleEvaluationContext的能力比通用评估上下文更窄;- 空值、长度和业务字符集仍应由业务代码校验。
如果只是计算字符串长度,直接调用 name.trim().length() 更好。引入表达式引擎会增加语法、权限和调试复杂度。
3.3 不同表达式场景不要混为一谈
以下组件都可能出现表达式注入,但语法和防护机制不同:
- Spring SpEL;
- JSP/统一 EL;
- Thymeleaf 表达式;
- Apache Commons JEXL;
- OGNL;
- 规则引擎、SQL-like 查询语言;
- 自定义模板引擎。
一个针对 SpEL 的修复不能自动修复 OGNL;禁止某个字符也不能证明模板安全。应先确定:
- 哪个组件解析输入;
- 输入位于模板文本、表达式、字符串字面量还是变量位置;
- 评估上下文暴露了哪些对象;
- 是否允许方法调用和对象构造;
- 是否存在模板缓存或跨请求复用。
3.4 Spring Security 中的表达式边界
Spring Security 的方法安全和 Web 授权表达式通常由应用代码固定,例如:
@PreAuthorize("hasAuthority('report:read')")
public Report getReport(long id) {
// ...
}
这里的权限表达式是部署时的代码,不应让租户或普通管理员直接提交后进入 @PreAuthorize、hasPermission 或动态安全元数据。
如果业务需要“可配置规则”,不要直接把管理员输入当作 SpEL。更稳妥的做法是定义有限的规则模型:
public record Rule(String field, Operator operator, String value) {}
public enum Operator {
EQUALS, PREFIX
}
然后由应用自己执行:
boolean matches(Rule rule, String actual) {
return switch (rule.operator()) {
case EQUALS -> actual.equals(rule.value());
case PREFIX -> actual.startsWith(rule.value());
};
}
这不是为了“避免所有动态规则”,而是把规则语言限制在可审计的语法和能力集合内。
失败表现与诊断
表达式注入常见表现包括:
- 请求延迟突然升高;
- 应用线程栈出现反射、模板引擎或表达式解析调用;
- 访问日志中出现大量语法错误;
- 错误响应泄露类名、Bean 名称或堆栈;
- 某些输入触发文件、网络或系统属性访问。
诊断时应关联:
- 原始参数的哈希或脱敏值;
- 解析器名称和版本;
- 使用的
EvaluationContext类型; - 是否启用了方法调用、Bean 解析器和类型定位器;
- 请求是否具有管理员或内部服务权限。
四、SSRF:服务端替用户发起网络请求
4.1 SSRF 的精确定义
SSRF(Server-Side Request Forgery,服务端请求伪造)是指攻击者控制服务端发出的目标地址或请求部分,使服务端访问攻击者原本无法访问的网络资源。
数据流通常是:
flowchart LR
A[外部请求中的 URL] --> B[URL 解析]
B --> C[DNS 解析]
C --> D[建立 TCP/TLS 连接]
D --> E[发送 HTTP 请求]
E --> F[内网服务/云元数据/本机端口]
F --> G[响应回到应用]
G --> H[攻击者可见结果或副作用]
SSRF 的危害不只在于读取响应,还包括:
- 访问内网管理面板;
- 访问云平台实例元数据;
- 探测端口和服务存在性;
- 使用服务端网络位置绕过访问控制;
- 向内部服务发送有副作用的请求;
- 通过重定向或 DNS 变化突破初始检查。
4.2 URL 校验为何容易失败
下面这种代码看似限制了内网访问:
if (url.startsWith("https://trusted.example/")) {
return httpClient.get(url);
}
它至少存在这些问题:
- 大小写、默认端口和 URL 规范化;
- 用户名密码部分;
- 编码字符和路径解析差异;
- 服务器重定向到其他主机;
- DNS 将域名解析到内网地址;
- 解析检查和实际连接之间的 DNS 变化;
- IPv4、IPv6、IPv4-mapped IPv6 表示差异;
- HTTP 客户端代理配置改变实际出口;
- TLS 证书验证与 URL 主机不一致。
java.net.URI 适合做结构解析,但它不是 SSRF 防火墙。URI.getHost() 得到的是语法层面的主机名,不代表该主机安全,也不代表最终连接地址安全。
4.3 一个“只用于说明边界”的检查示例
下面代码可以作为固定域名策略的教学起点,但不能单独作为生产 SSRF 防护:
import java.net.InetAddress;
import java.net.URI;
import java.util.Arrays;
import java.util.Locale;
public final class UrlPolicy {
public static void validate(String raw) throws Exception {
URI uri = URI.create(raw);
if (!"https".equalsIgnoreCase(uri.getScheme())) {
throw new SecurityException("only https is allowed");
}
if (uri.getUserInfo() != null) {
throw new SecurityException("userinfo is not allowed");
}
if (uri.getPort() != -1 && uri.getPort() != 443) {
throw new SecurityException("unexpected port");
}
String host = uri.getHost();
if (host == null || !host.toLowerCase(Locale.ROOT)
.equals("cdn.example.com")) {
throw new SecurityException("unexpected host");
}
var addresses = InetAddress.getAllByName(host);
if (Arrays.stream(addresses).anyMatch(UrlPolicy::isPrivateOrLocal)) {
throw new SecurityException("private address is not allowed");
}
}
private static boolean isPrivateOrLocal(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress();
}
}
每一步的意义:
- 只允许
https,避免明文传输和协议切换; - 禁止
userinfo,避免把真正主机隐藏在user@host结构中; - 限制端口,减少访问任意管理端口的机会;
- 使用精确主机名,而不是简单的后缀判断;
- 解析所有地址,而不是只检查一个地址;
- 拒绝 Java 能识别的本地、环回、链路本地和站点本地地址。
但它仍然有明显缺陷:检查得到的地址不一定就是 HTTP 客户端最终连接的地址。DNS 解析与连接之间存在时间窗口,这叫 TOCTOU(time-of-check to time-of-use)。如果攻击者控制 DNS,可以让检查阶段返回公网地址、连接阶段返回内网地址。
4.4 生产级设计应固定出口,而不是只过滤字符串
更可靠的方案通常是组合使用:
- 业务级允许列表:只允许访问明确的域名或资源 ID;
- 独立出站代理:应用不能直接访问任意网络,只能访问代理;
- 网络级出口策略:防火墙、NetworkPolicy 或安全组拒绝内网管理网段;
- 禁用自动重定向:每次重定向重新执行完整策略;
- 限制协议、端口、响应大小和超时;
- 在连接层固定解析结果:避免“检查地址”和“连接地址”不一致;
- 隔离云元数据访问:不能依赖应用层 URL 检查作为唯一防线。
Java HttpClient 的默认重定向策略是 NEVER,但应显式写出策略,避免更换客户端时改变行为:
import java.net.http.HttpClient;
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(java.time.Duration.ofSeconds(3))
.build();
读取响应时还要限制总大小。仅限制连接超时不能防止服务器发送一个很大的响应;仅限制响应大小也不能防止连接池被慢请求占满。
如果业务只需要下载图片,最佳接口往往不是:
POST /download
{ "url": "https://..." }
而是:
POST /download
{ "assetId": "a-123" }
服务端根据 assetId 从数据库映射到预先登记的对象存储路径。这样把“任意网络请求”变成“有限资源读取”。
SSRF 的验证方法
应测试的不只是正常 URL,还包括:
- 域名大小写和尾随点;
- 显式端口;
- IPv4 和 IPv6;
- 重定向到不同主机;
- 多次 DNS 解析返回不同地址;
- 超大响应和慢响应;
- 代理环境变量;
- 连接失败、TLS 失败、读取超时;
- 内网、环回、链路本地和云元数据地址。
日志中应记录策略结果、规范化主机、解析地址、最终连接地址和重定向次数,但对用户提供的完整 URL 应进行脱敏,避免把 Token、签名参数写入日志。
五、TLS:加密、身份认证和协议状态
5.1 TLS 解决的不是一个问题
TLS 通常提供三个相关但不同的属性:
- 机密性:旁路观察者不能直接读取应用数据;
- 完整性:数据被篡改时,连接应检测到错误;
- 对端身份认证:客户端能够验证自己连接的是哪个服务。
只启用加密而不验证主机身份,会得到“加密地连接到了错误服务器”。因此,TLS 配置的核心不是“用了 HTTPS”,而是:
5.2 Java JSSE 的基本生命周期
Java 的 JSSE(Java Secure Socket Extension)通常经过以下步骤:
应用发起连接
-> ClientHello:协议版本、随机数、支持的算法
-> ServerHello:服务器选择参数
-> 服务端发送证书链
-> 客户端验证证书链和主机名
-> 双方协商密钥
-> Finished 校验握手完整性
-> 应用数据使用对称密钥传输
证书链验证回答的是:
这个证书是否由客户端信任的 CA 链签发,且当前时间、用途等条件满足?
主机名验证回答的是:
证书中的身份是否匹配我请求的主机名?
两者不能互相替代。
5.3 Java HTTPS 客户端示例
使用 HttpClient 时,默认 SSLContext 通常使用 JDK 的默认信任库和系统安全属性:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class TlsGet {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NEVER)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/health"))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
System.out.println(response.statusCode());
}
}
预期结果是打印 HTTP 状态码,例如 200。如果证书链不受信任,可能抛出 SSLHandshakeException;如果主机名不匹配,也应失败,而不是静默接受证书。
不要使用以下“修复”:
trustManagerThatTrustsEverything();
hostnameVerifierThatAlwaysReturnsTrue();
它们会把 TLS 从身份认证协议降级成带加密的任意连接。测试环境需要自签名证书时,应把测试 CA 放入专用 truststore,而不是关闭验证。
5.4 服务端 TLS 与双向 TLS
普通 HTTPS 是单向认证:
客户端验证服务端证书
服务端通常不验证客户端证书
mTLS(mutual TLS,双向 TLS)则要求:
客户端验证服务端证书
服务端请求并验证客户端证书
服务端需要分别配置:
- keystore:服务端私钥和证书链;
- truststore:服务端信任的客户端 CA 或证书;
- 启用客户端证书认证;
- 将证书身份映射到应用用户或服务身份。
mTLS 适合服务到服务的强身份场景,但证书轮换、吊销、连接池重建和身份映射都需要运维流程。它不会替代 HTTP 层授权:证书证明“哪个服务或工作负载”,不自动证明“可以访问哪个业务资源”。
5.5 TLS 诊断
遇到 TLS 故障,应区分握手阶段:
PKIX path building failed:常见于信任链不完整或 truststore 不正确;No subject alternative names present或 hostname mismatch:证书身份与请求主机不匹配;handshake_failure:协议、算法、客户端证书或服务端策略不兼容;- 连接建立后 HTTP 失败:可能已不是 TLS 问题,而是认证、授权或应用协议问题。
临时诊断可以启用:
java -Djavax.net.debug=ssl,handshake -jar app.jar
输出会非常详细,可能包含证书和连接信息,不应在长期生产运行,也不应无保护地上传完整日志。验证修复时,应检查:
- 实际使用的 JDK 和 truststore;
- 证书 SAN,而不是只看 CN;
- 中间证书是否由服务端发送;
- 系统时间是否正确;
- 连接池是否缓存了旧的 TLS 配置;
- 代理是否终止并重新建立 TLS。
六、供应链安全:服务运行前就可能已经被改变
6.1 供应链的边界
Java 服务的供应链不只有业务依赖,还包括:
源代码
-> Git 仓库与 CI Action
-> Maven/Gradle 插件
-> 依赖与传递依赖
-> JDK、基础镜像、系统包
-> 编译产物、镜像和部署清单
-> 运行时配置与更新渠道
供应链风险有两类:
- 已知漏洞:依赖存在公开安全缺陷;
- 恶意或被篡改产物:包、插件、镜像或构建脚本本身被替换。
漏洞扫描主要解决第一类,不能证明第二类不存在。
6.2 传递依赖为何重要
假设应用只直接声明:
<dependency>
<groupId>com.example</groupId>
<artifactId>web-client</artifactId>
<version>1.2.3</version>
</dependency>
web-client 可能又引入:
web-client
-> http-core
-> logging-api
-> codec
应用实际运行的是完整依赖图,而不是 pom.xml 中的几行直接依赖。可以用 Maven 查看:
mvn dependency:tree -Dverbose
典型输出会显示直接依赖和传递依赖,例如:
com.example:service:jar:1.0
+- com.example:web-client:jar:1.2.3:compile
| \- org.example:http-core:jar:4.5.0:compile
\- org.slf4j:slf4j-api:jar:2.0.0:compile
-Dverbose 还可能显示被 Maven 冲突仲裁省略的版本。若两个依赖需要同一个库的不同版本,Maven 的依赖仲裁结果必须被审查;“能编译”不代表运行时行为正确或安全。
6.3 版本、哈希和来源证明
一个可审计的构建至少应固定:
- 直接依赖版本;
- 关键传递依赖版本;
- Maven/Gradle 插件版本;
- JDK 发行版和版本;
- 容器基础镜像摘要;
- 构建工具和构建环境;
- 产物哈希;
- 依赖下载仓库和凭据访问范围。
版本号解决“选择哪一个逻辑版本”,哈希解决“下载到的字节是否是预期内容”。二者不同:
版本固定 != 字节完整性证明
供应商签名、仓库校验和、内部制品代理、SBOM 和签名证明应组合使用。SBOM(软件物料清单)回答“产物包含什么”;它不自动回答“这些内容是否安全”或“是否未被篡改”。
6.4 构建插件具有高权限
Maven 插件可能在构建阶段执行 Java 代码;容器构建脚本可能执行 shell 命令;CI Action 可能读取 Token。因而下面这些内容必须和运行时依赖一样受控:
maven-compiler-plugin、测试、打包和代码生成插件;- 自定义 Gradle Plugin;
- GitHub/GitLab CI Action;
- Dockerfile 中的远程下载;
- 安装脚本和发布脚本。
一个常见错误是只扫描最终 JAR,却不审查构建过程。恶意插件可以在 JAR 生成之前窃取凭据或修改源码,最终产物扫描未必能发现。
6.5 漏洞治理中的因果判断
收到一个 CVE 后,不应直接得出“升级”或“忽略”的结论,应按以下链路判断:
漏洞组件
-> 是否进入最终产物
-> 是否使用受影响模块和代码路径
-> 是否满足漏洞前置条件
-> 是否可由当前攻击面触发
-> 是否已有缓解措施
-> 升级后的兼容性与回滚方案
例如,一个只在测试 classpath 中存在的组件与生产运行时组件,处置优先级不同;但测试插件仍可能影响 CI,因此不能简单删除记录。
升级时应同时验证:
- 单元测试和集成测试;
- TLS、序列化和认证行为;
- 依赖树是否引入新的版本冲突;
- 容器启动、健康探针和优雅终止;
- 灰度实例的错误率、延迟和资源使用;
- 回滚产物是否仍然可获取且未被覆盖。
七、五类问题的共同防线:能力最小化与失败关闭
7.1 从“输入校验”升级为“能力控制”
单纯的黑名单很脆弱,因为不同解析器对输入有不同规范化规则。更稳的模型是:
输入
-> 结构解析
-> 业务允许列表
-> 能力受限的解释器/客户端
-> 网络与进程级隔离
-> 可观测的拒绝和恢复
对应到本文主题:
- 反序列化:固定数据协议,或对类和资源做允许列表;
- 表达式:固定表达式,变量参数化,限制评估上下文;
- SSRF:固定资源标识、出站代理和网络拒绝策略;
- TLS:固定信任根、验证主机名、限制协议和算法;
- 供应链:固定来源、版本、哈希和构建权限。
7.2 失败路径必须明确
安全控制经常在异常路径失效:
- 反序列化过滤器抛错后,代码是否尝试无过滤器重试?
- URL 检查失败后,是否降级到直接请求?
- TLS 握手失败后,是否切换到 HTTP?
- 依赖扫描不可用时,CI 是否仍然发布?
- 表达式解析失败后,是否执行一个更宽松的默认表达式?
- 证书轮换失败时,是否临时信任所有证书?
生产系统应明确采用 fail closed:安全条件不满足时拒绝操作,而不是扩大权限继续运行。对于可用性要求极高的服务,应把“安全拒绝”和“服务降级”分开设计,例如返回缓存数据,而不是跳过认证或访问控制。
八、与 Spring Security 和生产交付的连接
8.1 Filter Chain 不是所有安全问题的边界
Spring Security Filter Chain 主要处理 HTTP 请求进入应用后的认证、授权、CSRF、会话等问题。它不能自动防护:
- 消息队列中的 Java 反序列化;
- 定时任务读取的危险对象;
- 管理后台中的表达式引擎;
- 应用主动发起的 SSRF;
- JDK 或 Maven 依赖中的漏洞;
- TLS truststore 和证书轮换错误。
因此,不能因为请求经过了 Spring Security,就认为后续的 URL 客户端或对象解析安全。
8.2 JWT、CSRF 和 SSRF 的关系
JWT 解决的是身份凭据传递和验证,不会阻止 SSRF。相反,如果 SSRF 目标是一个内部服务,而内部服务错误地只依赖“来源网络位置”而不验证 Token,风险会扩大。
CSRF 保护浏览器自动携带 Cookie 的跨站请求;它不保护服务端主动向目标 URL 发起的请求,也不替代出站访问控制。
认证与授权应当形成独立链路:
请求身份认证
-> 用户/服务主体确定
-> 业务资源授权
-> 服务端出站访问策略
-> 目标连接的 TLS 身份验证
每一层判断不同问题,不能用其中一层代替其他层。
8.3 容器、JVM 参数和探针
容器隔离可以降低 SSRF 命中宿主机或内网的影响,但不能代替 URL 策略;JVM 参数可以配置序列化过滤器和 TLS 属性,但必须纳入发布配置审计;健康探针不能只检查进程存活,还应区分:
- liveness:进程是否还能运行;
- readiness:实例是否能接收流量;
- startup:应用是否完成初始化。
安全配置错误时,探针应让实例停止接收流量,而不是在不安全状态下继续承载请求。灰度发布期间,需特别观察 TLS 握手失败、反序列化拒绝、依赖升级后的类加载错误和出站请求超时。
九、一个可执行的安全验证流程
以下命令和检查适合纳入 Java 服务交付流程:
# 1. 确认编译和运行 JDK
java -version
javac -version
# 2. 查看依赖图和冲突
mvn dependency:tree -Dverbose
# 3. 构建测试
mvn -B clean verify
# 4. 检查 HTTPS 证书链和主机名信息
keytool -printcert -sslserver example.com:443
# 5. 仅在临时诊断时打开 JSSE 握手日志
java -Djavax.net.debug=ssl,handshake -jar app.jar
验证结果应这样解释:
java -version确认实际运行时,不只是编译机版本;dependency:tree确认最终依赖关系,但不等于漏洞扫描;mvn verify只证明测试和构建阶段通过,不证明攻击面安全;keytool可以帮助查看服务端证书链,但不能替代应用真实连接测试;- JSSE debug 日志用于定位握手过程,不应长期打开。
自动化测试还应覆盖拒绝路径:
给定任意类的序列化数据 -> 过滤器拒绝
给定用户输入表达式 -> 仅作为变量值,不改变表达式
给定内网地址 -> 出站策略拒绝
给定错误证书或错误主机名 -> TLS 握手失败
给定含漏洞版本依赖 -> 构建门禁失败或产生明确豁免记录
测试的重点不是只验证“正常请求成功”,而是验证安全边界在异常、重定向、超时、升级和回滚时仍然存在。
十、最后的判断标准
可以用以下问题审查一个 Java 服务:
- 是否存在
ObjectInputStream.readObject()、缓存对象恢复或消息对象恢复入口? - 反序列化前是否有限定类型、深度、引用数和字节数?
- 是否把用户输入直接交给 SpEL、EL、OGNL 或模板引擎?
- 动态规则是否使用固定语法模型,而不是任意表达式?
- 是否允许用户控制服务端请求的完整 URL?
- DNS、重定向、IPv6、代理和连接地址是否纳入策略?
- TLS 是否同时验证证书链和主机名?
- 是否存在“信任所有证书”或“忽略主机名”的代码?
- 生产产物是否包含可追踪的依赖、插件、JDK、镜像和 SBOM 信息?
- 安全控制失败时,系统是拒绝、降级到无害结果,还是绕过控制继续执行?
反序列化、表达式注入和 SSRF 保护的是“输入不能获得过多运行时能力”;TLS 保护的是“连接到的对端身份和传输完整性”;供应链安全保护的是“运行前进入系统的代码和构建过程”。只有把这几条边界同时纳入代码、网络、构建和发布流程,Java 服务的安全模型才是完整的。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 可观测性:Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO
- 下一篇:Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
- 延伸:Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论