Java 基础体系 · 第 34/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 服务安全:反序列化、表达式注入、SSRF、TLS 和供应链

Java 服务安全问题常被分成若干独立分类:反序列化漏洞、表达式注入、SSRF、TLS 配置错误和依赖供应链风险。它们实际上共享一条因果链:

外部输入进入高权限解释器、对象构造器、网络客户端或构建系统,并获得了超出业务需要的能力。

因此,安全设计的核心不是“找到某个危险字符串”,而是明确以下四件事:

  1. 数据从哪里进入系统
  2. 系统在哪一步把数据解释成代码、对象、地址或证书信任
  3. 该解释动作拥有哪些权限
  4. 失败时是否会退化为更危险的默认行为

下面的内容以 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 原生序列化由 SerializableObjectOutputStreamObjectInputStream 构成。序列化时,运行时会保存类描述、字段值以及对象之间的引用关系;反序列化时,它会重新建立对象图。

反序列化不是简单的:

字节数组 -> 无害的字段集合

更准确的过程是:

字节流
  -> 读取类描述
  -> 分配对象
  -> 恢复字段和引用
  -> 调用特殊恢复逻辑
  -> 触发对象图中对象的行为

对象恢复过程中可能涉及:

  • 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 类型检查、过滤和允许列表的区别

需要区分三种防线:

  1. 类型转换:在对象已经被恢复之后检查类型,太晚;
  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。当代码计算:

E(x,C)E(x, C)

并且 x 能影响语法结构,C 又暴露了方法调用、对象构造、类访问、Bean 访问或文件/网络能力时,就存在表达式注入风险。

安全的参数化计算应当是:

E(e固定,{vx})E(e_{\text{固定}}, \{v \leftarrow x\})

其中:

  • 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;禁止某个字符也不能证明模板安全。应先确定:

  1. 哪个组件解析输入;
  2. 输入位于模板文本、表达式、字符串字面量还是变量位置;
  3. 评估上下文暴露了哪些对象;
  4. 是否允许方法调用和对象构造;
  5. 是否存在模板缓存或跨请求复用。

3.4 Spring Security 中的表达式边界

Spring Security 的方法安全和 Web 授权表达式通常由应用代码固定,例如:

@PreAuthorize("hasAuthority('report:read')")
public Report getReport(long id) {
    // ...
}

这里的权限表达式是部署时的代码,不应让租户或普通管理员直接提交后进入 @PreAuthorizehasPermission 或动态安全元数据。

如果业务需要“可配置规则”,不要直接把管理员输入当作 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();
    }
}

每一步的意义:

  1. 只允许 https,避免明文传输和协议切换;
  2. 禁止 userinfo,避免把真正主机隐藏在 user@host 结构中;
  3. 限制端口,减少访问任意管理端口的机会;
  4. 使用精确主机名,而不是简单的后缀判断;
  5. 解析所有地址,而不是只检查一个地址;
  6. 拒绝 Java 能识别的本地、环回、链路本地和站点本地地址。

但它仍然有明显缺陷:检查得到的地址不一定就是 HTTP 客户端最终连接的地址。DNS 解析与连接之间存在时间窗口,这叫 TOCTOU(time-of-check to time-of-use)。如果攻击者控制 DNS,可以让检查阶段返回公网地址、连接阶段返回内网地址。

4.4 生产级设计应固定出口,而不是只过滤字符串

更可靠的方案通常是组合使用:

  1. 业务级允许列表:只允许访问明确的域名或资源 ID;
  2. 独立出站代理:应用不能直接访问任意网络,只能访问代理;
  3. 网络级出口策略:防火墙、NetworkPolicy 或安全组拒绝内网管理网段;
  4. 禁用自动重定向:每次重定向重新执行完整策略;
  5. 限制协议、端口、响应大小和超时
  6. 在连接层固定解析结果:避免“检查地址”和“连接地址”不一致;
  7. 隔离云元数据访问:不能依赖应用层 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 通常提供三个相关但不同的属性:

  1. 机密性:旁路观察者不能直接读取应用数据;
  2. 完整性:数据被篡改时,连接应检测到错误;
  3. 对端身份认证:客户端能够验证自己连接的是哪个服务。

只启用加密而不验证主机身份,会得到“加密地连接到了错误服务器”。因此,TLS 配置的核心不是“用了 HTTPS”,而是:

安全连接=证书链验证+主机名验证+合适的协议与算法\text{安全连接} = \text{证书链验证} + \text{主机名验证} + \text{合适的协议与算法}

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、基础镜像、系统包
  -> 编译产物、镜像和部署清单
  -> 运行时配置与更新渠道

供应链风险有两类:

  1. 已知漏洞:依赖存在公开安全缺陷;
  2. 恶意或被篡改产物:包、插件、镜像或构建脚本本身被替换。

漏洞扫描主要解决第一类,不能证明第二类不存在。

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 服务:

  1. 是否存在 ObjectInputStream.readObject()、缓存对象恢复或消息对象恢复入口?
  2. 反序列化前是否有限定类型、深度、引用数和字节数?
  3. 是否把用户输入直接交给 SpEL、EL、OGNL 或模板引擎?
  4. 动态规则是否使用固定语法模型,而不是任意表达式?
  5. 是否允许用户控制服务端请求的完整 URL?
  6. DNS、重定向、IPv6、代理和连接地址是否纳入策略?
  7. TLS 是否同时验证证书链和主机名?
  8. 是否存在“信任所有证书”或“忽略主机名”的代码?
  9. 生产产物是否包含可追踪的依赖、插件、JDK、镜像和 SBOM 信息?
  10. 安全控制失败时,系统是拒绝、降级到无害结果,还是绕过控制继续执行?

反序列化、表达式注入和 SSRF 保护的是“输入不能获得过多运行时能力”;TLS 保护的是“连接到的对端身份和传输完整性”;供应链安全保护的是“运行前进入系统的代码和构建过程”。只有把这几条边界同时纳入代码、网络、构建和发布流程,Java 服务的安全模型才是完整的。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。