背景
反序列化问题在 Java Web 项目里并不新,但它的麻烦点在于:风险不一定出现在业务代码最显眼的位置,更多时候藏在 RPC 框架、缓存客户端、消息队列、历史兼容接口、第三方组件默认配置里。很多团队做漏洞应急时,会先查某个 CVE 是否命中,但实际代码审计中更值得关注的是:项目里是否存在不可信数据进入反序列化流程,以及反序列化过程中是否允许加载危险类型。
这篇文章整理的是我在审计 Java Web 项目时常用的一套排查思路,重点放在识别、验证和修复,不涉及攻击利用链构造,也不提供可直接用于攻击的操作步骤。适合用于内部安全自查、代码审计复盘和安全基线建设。
典型场景
一个比较常见的场景是:业务系统已经运行多年,期间接入过缓存、RPC、定时任务、消息消费、老接口兼容等模块。某次依赖升级或漏洞通告后,安全团队要求确认是否存在反序列化风险。开发同学通常会先搜索 ObjectInputStream,但结果往往不完整,因为很多风险点不是直接写在业务层,而是通过框架封装后间接触发。
我一般会把排查范围分成四类:
- 原生 Java 反序列化:ObjectInputStream、readObject、readResolve 等。
- JSON/XML/YAML 反序列化:Fastjson、Jackson、XStream、SnakeYAML 等。
- RPC/序列化框架:Hessian、Dubbo、Kryo、FST、Protostuff 等。
- 中间件数据反序列化:Redis、MQ、Session、缓存穿透历史数据等。
问题本质
反序列化风险的核心并不是“用了某个库就一定有漏洞”,而是以下几个条件叠加后形成风险:
- 输入数据来源不可信,或者攻击者可以间接影响序列化数据内容。
- 反序列化过程允许解析任意类型,缺少白名单或过滤器。
- 运行时 classpath 中存在可被触发副作用的类或历史脆弱组件。
- 框架版本存在已知缺陷,或者默认配置过于宽松。
- 反序列化前缺少签名、完整性校验、来源校验。
因此,代码审计时不要只看“有没有某个危险函数”,还要确认数据流:数据从哪里来,经过哪些校验,最终被哪个组件解析,解析时允许哪些类型。
第一步:依赖和版本梳理
先把依赖树拉出来,确认项目里到底引入了哪些序列化相关组件。Maven 项目可以直接使用下面的命令:
mvn dependency:tree -Dincludes=com.alibaba:fastjson,com.fasterxml.jackson.core:jackson-databind,com.thoughtworks.xstream:xstream,org.yaml:snakeyaml,com.caucho:hessian,com.esotericsoftware:kryo如果项目比较大,可以先输出完整依赖树,再用 grep 过滤:
mvn dependency:tree > deps.txt
grep -Ei "fastjson|jackson|xstream|snakeyaml|hessian|kryo|fst|dubbo|serialization" deps.txt对于 Gradle 项目:
./gradlew dependencies --configuration runtimeClasspath > deps.txt
grep -Ei "fastjson|jackson|xstream|snakeyaml|hessian|kryo|fst|dubbo|serialization" deps.txt这里要注意传递依赖。很多项目表面上没有直接声明某个序列化库,但通过老版本 RPC SDK、报表组件、任务调度组件间接引入了。审计结论里建议写清楚“直接依赖”和“传递依赖”,否则后续修复时容易遗漏。
第二步:代码入口检索
依赖只是风险面,真正要确认的是调用点。可以先做关键字检索:
grep -RIn --exclude-dir=target --exclude-dir=.git \
-E "ObjectInputStream|readObject\(|readResolve\(|XMLDecoder|fromJson\(|parseObject\(|readValue\(|XStream|Yaml\(|HessianInput|Kryo|deserialize" \
src/检索结果不要机械地判定为漏洞,需要继续看上下文。重点关注以下位置:
- Controller、Filter、Interceptor 是否接收外部输入后直接解析。
- 消息消费者是否信任 MQ 消息体中的类型字段。
- Redis 或数据库中的二进制字段是否会被反序列化。
- Session 共享、RememberMe、导入导出功能是否处理序列化对象。
- 历史兼容接口是否保留了 Java 原生序列化格式。
一个常见误区是只看 HTTP 入口。实际审计中,MQ 消费和定时任务读取外部系统数据的风险并不少见,尤其是多系统集成环境里,“内部来源”不等于“可信来源”。
第三步:数据流确认
确认调用点后,要追踪数据来源和校验逻辑。可以按下面几个问题记录:
- 数据是否来自用户请求、第三方系统、消息队列、缓存、文件上传或数据库历史数据?
- 反序列化前是否校验签名、MAC、来源身份或数据完整性?
- 是否存在类型字段,例如 className、@type、type、serializer 等?
- 是否允许客户端指定目标类型?
- 反序列化目标类型是否固定为 DTO,还是 Object、Map、泛型 Object?
如果代码里出现“根据请求参数决定类名再反序列化”的设计,通常需要重点整改。即使当前没有已知利用链,这类设计也会让系统长期处于高风险状态。
第四步:原生 Java 反序列化的修复建议
如果项目确实需要使用 ObjectInputStream,建议至少使用 JEP 290 的 ObjectInputFilter 做类型和资源限制。示例仅用于说明过滤思路:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.dto.*;java.base/*;!*"
);
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
ois.setObjectInputFilter(filter);
Object obj = ois.readObject();
if (!(obj instanceof SafeMessage)) {
throw new IllegalArgumentException("unexpected object type");
}
SafeMessage message = (SafeMessage) obj;
}这里有几个要点:
- 白名单优先,尽量只允许业务 DTO 包路径。
- 不要使用过宽的通配规则,例如直接允许整个应用包或全部第三方包。
- 反序列化后仍要做 instanceof 校验和字段级校验。
- 对数组长度、嵌套深度、字节大小设置限制,避免资源消耗型问题。
如果是 JDK 全局配置,也可以结合系统参数进行基线控制:
-Djdk.serialFilter=maxdepth=10;maxarray=10000;maxbytes=1048576;com.example.dto.*;java.base/*;!*全局过滤适合作为兜底,但不要代替业务层白名单。全局规则过严可能影响历史组件,过宽又失去意义,建议先在测试环境开启日志观察。
第五步:JSON 反序列化的风险控制
JSON 反序列化风险经常出现在“为了通用处理方便,把目标类型写成 Object 或 Map”的场景里。以 Jackson 为例,不建议在面向外部输入的接口上开启宽泛的默认多态类型支持。更稳妥的方式是明确 DTO 类型:
ObjectMapper mapper = new ObjectMapper();
UserProfile profile = mapper.readValue(json, UserProfile.class);如果确实需要多态,建议使用明确的子类型白名单,而不是允许任意类名参与解析:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = TextMessage.class, name = "text"),
@JsonSubTypes.Type(value = ImageMessage.class, name = "image")
})
public interface MessagePayload {
}Fastjson 场景下,重点确认是否启用了 autoType,以及是否存在 parseObject 到 Object.class、JSONObject 后再动态转换的逻辑。修复方向通常包括:
- 升级到官方维护版本。
- 关闭不必要的 autoType。
- 固定目标类型,避免使用 Object 接收外部输入。
- 对需要支持的类型做显式白名单。
第六步:缓存和消息队列中的历史数据
不少项目会把 Java 对象序列化后放进 Redis,或者把对象消息写进 MQ。后续升级依赖时,历史数据可能成为兼容负担,也可能成为风险点。建议排查以下内容:
- RedisTemplate 是否使用 JdkSerializationRedisSerializer。
- 消息体是否是 Java 原生序列化字节流。
- 消费者是否会自动信任消息头里的类型信息。
- 是否存在跨系统共用 Topic、Queue、Key 前缀的情况。
Spring Redis 中如果没有强依赖 Java 原生序列化,建议切换为 JSON 序列化并固定类型,或者使用字符串/结构化字段:
@Bean
public RedisTemplate<String, UserSession> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, UserSession> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(UserSession.class));
return template;
}迁移时不要直接一刀切删除历史 Key。比较稳的做法是:新旧格式并行读取一段时间,读取旧格式后转换写回新格式,并为旧 Key 设置较短过期时间。
第七步:验证方式
修复验证不建议只依赖漏洞扫描器。扫描器能发现一部分已知问题,但很难覆盖业务数据流。比较可靠的验证方式包括:
- 单元测试:构造合法和非法类型输入,确认非法类型被拒绝。
- 集成测试:覆盖 Controller、MQ Consumer、Redis 读取等真实调用链。
- 日志审计:观察过滤器拒绝记录,确认没有误拦截核心业务。
- 依赖复查:确认最终构建产物中不存在被误引入的高风险旧版本。
可以为反序列化过滤器补一组基础测试:
@Test
void shouldRejectUnexpectedType() {
assertThrows(InvalidClassException.class, () -> {
deserializeWithFilter(untrustedBytes);
});
}测试里不需要放入危险利用样本。只要能证明“非白名单类型无法通过”,就能覆盖主要修复目标。
审计记录建议
为了方便后续复盘,我通常会在审计记录中保留下面这些信息:
| 项目 | 建议记录内容 |
| 组件版本 | 序列化相关依赖、直接依赖或传递依赖来源 |
| 调用点 | 文件路径、方法名、触发入口 |
| 数据来源 | HTTP、MQ、Redis、文件、第三方接口等 |
| 限制措施 | 类型白名单、签名校验、大小限制、深度限制 |
| 验证结果 | 测试用例、日志、构建产物依赖确认 |
这类记录的价值在于,下一次依赖升级或安全通告出现时,可以快速判断影响面,而不是每次都从头翻代码。
注意点
- 不要把“内网接口”直接等同于可信接口,内部系统被污染后同样会扩大影响。
- 不要只升级依赖不改配置,很多风险来自不安全的使用方式。
- 不要允许客户端传入任意 className、beanName、typeName 决定反序列化类型。
- 不要在日志中输出完整序列化数据,可能泄露敏感字段。
- 不要忽略历史数据迁移,缓存和 MQ 中的旧格式数据经常导致修复不完整。
总结
Java 反序列化治理更像是一项工程化工作,而不是单点漏洞修补。比较有效的做法是:先梳理依赖,再定位调用点,然后追踪数据流,最后通过白名单、固定类型、签名校验、资源限制和测试用例形成闭环。
如果只能优先做几件事,我建议从三点开始:禁止不可信输入进入原生 Java 反序列化;JSON 解析固定 DTO 类型,避免 Object 和任意多态;缓存和消息队列改为结构化数据并做来源校验。这样即使未来出现新的组件漏洞,也能明显降低实际暴露面。
推荐意见