背景
Java 反序列化问题在企业系统里并不新,但它长期存在的原因往往不是“没人知道危险”,而是调用链分散、历史组件复杂、边界校验缺失,以及一些框架默认行为不够直观。最近整理内部老项目安全基线时,发现几类典型风险:接口直接接收序列化对象、缓存中间件存储 Java 原生对象、消息队列消费端缺少类型约束、遗留 RPC 框架暴露了不必要的反序列化入口。
这篇文章不讨论攻击利用细节,只分享一套偏防守视角的排查方法:如何在代码审计中定位风险点,如何构造安全的本地验证场景,如何做运行时加固,以及上线前后应该关注哪些指标。
问题场景
典型业务场景如下:
- 老系统使用
ObjectInputStream从文件、缓存或网络流中恢复对象。 - 内部接口为了“方便扩展”,将请求体保存为 Java 序列化字节流。
- Redis、MQ、RPC 框架中存在二进制对象传输,消费端默认信任来源。
- 项目依赖中包含复杂 gadget 链相关组件,例如部分历史版本的 commons-collections、groovy、spring 生态组件等。
从防守角度看,风险不只在于某一行 readObject(),而在于“不可信输入 + 自动类型恢复 + 类路径中存在危险调用链”这三个条件是否同时成立。
技术分析
Java 原生反序列化的核心风险在于:反序列化过程会根据字节流中的类型信息恢复对象,并触发对象图中部分类的特殊方法,例如 readObject、readResolve、validateObject 等。如果输入来源不可控,且类路径中存在可被串联触发的危险方法,就可能造成严重后果。
审计时建议先把风险拆成三层:
- 入口层:是否存在从请求、消息、缓存、文件、数据库字段读取字节流并调用反序列化的逻辑。
- 类型层:是否允许任意类被恢复,是否有白名单或过滤器。
- 依赖层:类路径中是否存在高风险历史依赖,是否有不必要的复杂组件暴露给运行时。
这三层中,入口层最容易定位,类型层最容易被忽视,依赖层则需要结合构建文件和运行时实际加载情况一起看。
代码审计定位方法
第一步通常是全局检索危险 API。下面这些关键字适合作为初筛,不代表命中就一定有漏洞,但可以快速缩小范围。
rg -n "ObjectInputStream|readObject\(|readUnshared\(|XMLDecoder|XStream|Hessian|Kryo|FST|SerializationUtils" src/如果是 Maven 项目,可以同时检查依赖树,关注历史版本组件和不再维护的序列化框架:
mvn dependency:tree -DverboseGradle 项目可以使用:
./gradlew dependencies --configuration runtimeClasspath命中后建议逐个确认数据来源,重点看以下问题:
- 输入是否来自 HTTP 请求、Cookie、Header、上传文件、MQ 消息、Redis 值、数据库字段。
- 调用前是否做了签名校验、来源校验、长度限制和类型限制。
- 反序列化是否发生在高权限进程中,例如管理后台、调度中心、网关服务。
- 反序列化后的对象是否继续参与反射、表达式解析、模板渲染、脚本执行等高风险逻辑。
一个常见的风险代码形态如下:
public Object restore(byte[] data) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
return ois.readObject();
}
}问题不在于这段代码一定会被利用,而在于它没有说明允许恢复哪些类型,也没有说明 data 是否可信。审计报告里应避免笼统写“存在反序列化漏洞”,更准确的表述是:存在不受类型约束的原生反序列化入口,需要结合输入来源和依赖环境评估影响。
安全验证思路
验证时不建议在生产环境构造复杂载荷,更不建议把公开利用工具直接接到业务系统上。比较稳妥的方式是做最小化本地验证:确认入口是否可达、确认类型过滤是否生效、确认异常是否被监控捕获。
可以定义一个普通测试类,用于验证系统是否会恢复非预期类型:
public class AuditProbe implements Serializable {
private static final long serialVersionUID = 1L;
private String marker;
public AuditProbe(String marker) {
this.marker = marker;
}
public String getMarker() {
return marker;
}
}本地测试重点不是执行危险行为,而是观察以下现象:
- 服务是否接受了该对象类型。
- 日志中是否出现反序列化异常或类型拦截记录。
- 对象恢复前是否有长度、签名、来源校验。
- 异常是否被吞掉,导致安全监控无法感知。
如果服务对任意测试类都能正常恢复,基本可以判断缺少类型边界,需要进入加固阶段。
加固方案一:优先移除原生反序列化
最推荐的方案是从设计上避免接收 Java 原生序列化数据。跨服务传输建议使用 JSON、Protocol Buffers、Avro 等格式,并显式定义 schema。这样做不仅降低安全风险,也能减少版本兼容问题。
例如,将原本的任意对象恢复改成明确 DTO:
public class UserEvent {
private String eventId;
private String userId;
private String action;
// getter/setter
}配合 JSON 解析时也要避免开启过宽的自动类型识别能力。对 Jackson、Fastjson 等组件,应关闭不必要的 auto type 或默认多态反序列化能力,只允许明确字段映射。
加固方案二:使用 JEP 290 过滤器
如果短期无法移除原生反序列化,可以使用 JDK 提供的反序列化过滤机制。JEP 290 从 JDK 8u121 之后可用,可以限制可反序列化的类、数组深度、引用数量和字节数。
全局参数示例:
-Djdk.serialFilter=maxdepth=10;maxrefs=1000;maxbytes=1048576;java.base/*;com.example.dto.*;!*这条策略表达的含义是:限制深度、引用数、字节数,只允许 JDK 基础类型和业务 DTO 包,其他类型全部拒绝。实际使用时不要照抄,需要根据业务对象图调整。
也可以在代码中针对单个流设置过滤器:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"maxdepth=10;maxrefs=1000;maxbytes=1048576;com.example.dto.*;java.base/*;!*"
);
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
ois.setObjectInputFilter(filter);
Object obj = ois.readObject();
}注意:过滤器不是银弹。它能降低任意类型恢复风险,但前提是规则足够收敛。如果规则中出现过宽的 *,或者允许了大量第三方包,防护价值会明显下降。
加固方案三:依赖治理与运行时最小化
很多反序列化风险依赖复杂类路径。即使入口暂时无法完全清除,也应尽量减少运行时不需要的依赖。
mvn dependency:analyze
mvn versions:display-dependency-updates建议重点做三件事:
- 移除运行时不需要的测试组件、脚本引擎、表达式引擎、老旧工具包。
- 升级存在公开安全公告的历史版本依赖。
- 将后台管理、任务调度、内部调试功能与公网入口隔离。
依赖治理看起来不像“漏洞修复”,但实际收益很高。很多时候,降低类路径复杂度比单点补丁更稳定。
日志与监控建议
反序列化风险排查不能只停留在代码层,运行时监控同样重要。建议记录以下信息:
- 反序列化入口调用次数、来源 IP、业务身份。
- 输入数据长度分布,异常长度要告警。
- 被过滤器拒绝的类名和原因。
InvalidClassException、StreamCorruptedException、ClassNotFoundException等异常趋势。
日志中不要直接打印完整二进制内容,避免污染日志系统,也避免敏感数据泄露。更合适的做法是记录摘要、长度、来源和 traceId。
审计清单
| 检查项 | 风险表现 | 建议处理 |
|---|---|---|
| ObjectInputStream | 直接读取外部输入 | 移除或增加类型过滤 |
| 缓存对象 | Redis/Memcached 中保存原生序列化对象 | 改为 JSON/Protobuf,增加签名 |
| 消息消费 | MQ 消费端信任任意二进制消息 | 限制 topic 权限和消息 schema |
| 依赖版本 | 历史组件长期未升级 | 升级、排除或替换依赖 |
| 异常处理 | 反序列化异常被吞掉 | 记录安全日志并接入告警 |
容易踩的坑
- 只修入口,不看调用链:同一个工具类可能被多个业务复用,修一个接口不代表风险消失。
- 只做黑名单:黑名单很难覆盖所有危险类型,优先使用白名单。
- 过滤规则过宽:
com.*、org.*这类规则基本等于没收敛。 - 忽视内部接口:很多事故不是公网直接触发,而是内部消息、任务系统或运维通道被滥用。
- 升级后不回归:序列化对象对类结构敏感,升级依赖或改 DTO 后要做兼容性测试。
总结
Java 反序列化风险的治理重点不是寻找某个“神奇 payload”,而是把不可信输入挡在对象恢复之前,把可恢复类型限制到最小,把复杂依赖从运行时环境中清掉。实际落地时可以按这个顺序推进:先定位入口,再确认输入来源,再加类型过滤,最后做依赖治理和监控闭环。
个人经验是:反序列化问题最怕“历史包袱 + 默认信任”。只要能把类型边界和数据边界讲清楚,大部分风险都能被稳定压住。
推荐意见