跳转到帖子

背景

Java 反序列化问题在很多老系统里并不罕见,尤其是历史项目中混用了多种 JSON、RPC、缓存和消息队列组件时,风险点经常不是单一漏洞,而是“危险依赖 + 不受控输入 + 宽松配置 + 缺少边界校验”的组合。

这篇文章整理一次偏防守视角的排查思路,重点放在如何识别项目中的反序列化风险、如何做代码审计、如何验证风险是否存在,以及如何给出可落地的修复建议。文中不会提供攻击载荷或外部目标利用步骤,适合作为内部安全评审、上线前检查和历史系统治理的参考。

问题场景

某 Java Web 系统经历过多次迭代,早期模块使用了多种序列化方式,包括 Java 原生序列化、JSON 反序列化、Redis 缓存对象序列化、消息队列消费端对象转换等。安全排查时发现几个典型特征:

  • pom.xml 中存在较老版本的 JSON 处理库和通用工具库。
  • 部分接口接收字符串后直接反序列化为对象。
  • 缓存层使用 JDK 默认序列化器存储复杂对象。
  • 消息消费端缺少消息来源校验和字段白名单校验。
  • 历史代码中存在 Object、Map、泛型擦除等宽泛类型接收方式。

这类系统不一定马上存在可利用漏洞,但已经具备较高的审计优先级。实际工作中,我通常不会一上来就“找 payload”,而是先确认数据流、组件版本、对象边界和运行时配置。

技术分析

反序列化风险的核心不是“用了某个库”这么简单,而是应用是否把不可信输入转换成了可执行语义更强的对象。常见风险入口大致有几类:

  • HTTP 请求体:JSON、XML、表单字段中包含复杂对象结构。
  • Cookie 或 Header:历史系统可能把用户状态序列化后放在客户端。
  • 缓存系统:Redis、Memcached 中存放 JDK 序列化对象或通用 JSON 对象。
  • 消息队列:消费者对消息体直接做对象反序列化。
  • 文件导入:上传文件被解析为对象图,例如配置导入、任务导入。
  • RPC 框架:服务间通信中使用 Hessian、Kryo、JDK Serialize 等。

审计时需要同时看“入口”和“类型控制”。如果代码只把 JSON 解析成固定 DTO,并且字段类型简单、无多态、无自动类型识别,风险相对可控;如果允许客户端指定类型,或服务端根据输入动态加载类,就需要重点关注。

依赖识别

第一步建议从依赖树开始,把项目里可能参与序列化和反序列化的组件列出来。Maven 项目可以使用如下命令:

mvn -q dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
mvn -q dependency:tree -Dincludes=com.alibaba:fastjson
mvn -q dependency:tree -Dincludes=org.springframework:spring-core
mvn -q dependency:tree -Dincludes=org.apache.commons:commons-collections4

如果项目是多模块工程,建议在根目录输出完整依赖树,保存为文件后再 grep 关键组件:

mvn dependency:tree -Dverbose > dependency-tree.txt
cat dependency-tree.txt | grep -Ei "fastjson|jackson|xstream|hessian|kryo|snakeyaml|commons-collections|serial"

Gradle 项目可以查看 runtimeClasspath:

./gradlew dependencies --configuration runtimeClasspath > dependencies.txt
grep -Ei "fastjson|jackson|xstream|hessian|kryo|snakeyaml|commons-collections|serial" dependencies.txt

这里的目标不是简单判断“有某依赖就有漏洞”,而是为后续代码检索提供方向。很多风险来自间接依赖,例如某个老组件引入了过期 jackson-databind,业务代码未必直接声明。

代码审计重点

依赖识别之后,需要从代码层面确认哪些位置真正处理了不可信输入。可以先用关键字粗筛:

grep -RIn "ObjectInputStream\|readObject\|readUnshared" src/main/java
grep -RIn "JSON.parse\|parseObject\|parseArray" src/main/java
grep -RIn "ObjectMapper\|readValue\|enableDefaultTyping\|activateDefaultTyping" src/main/java
grep -RIn "XStream\|fromXML\|Yaml\|loadAs" src/main/java
grep -RIn "RedisTemplate\|JdkSerializationRedisSerializer" src/main/java

审计时我会重点记录以下信息:

  • 输入来源:是否来自 HTTP、MQ、缓存、文件、第三方回调。
  • 目标类型:是否为固定 DTO,还是 Object、Map、接口、抽象类。
  • 类型控制:是否启用自动类型、多态类型、默认类型推断。
  • 过滤逻辑:是否有字段白名单、长度限制、签名校验、来源校验。
  • 异常处理:是否吞掉异常导致异常输入被重试或进入补偿流程。
  • 调用链位置:反序列化后对象是否进入表达式执行、模板渲染、反射调用等高风险路径。

一个比较实用的判断标准:如果不可信输入能够影响“实例化什么类”或“调用什么方法”,审计优先级就应该提高。

典型风险模式

下面列几个实际项目里常见的风险模式,便于对照检查。

风险模式常见表现建议处理
JDK 原生反序列化ObjectInputStream 读取外部输入避免直接使用;必须使用时加入 ObjectInputFilter 和类型白名单
JSON 自动类型允许输入影响类名或多态类型关闭自动类型;仅反序列化到固定 DTO
宽泛类型接收Object、Map、List<Object> 接收请求体改为明确 DTO;字段做白名单校验
缓存对象序列化Redis 使用 JDK 序列化复杂对象改用 JSON DTO 或专用序列化格式;限制可读写方
消息体直接转换MQ 消费端信任所有消息内容增加 topic 权限、签名校验、schema 校验

关键步骤:建立可复现的安全验证流程

验证反序列化风险时,建议只在本地或授权测试环境中做,且验证目标应是“确认危险配置是否生效”,而不是尝试执行系统命令或控制目标。一个可控的验证方式是构造无害类,观察是否被非预期实例化。

例如在内部测试模块中定义一个只打印日志的类,用来验证某段反序列化逻辑是否允许外部输入影响类型选择。注意,这类测试代码不要带入生产环境。

public class SafeProbe {
    public SafeProbe() {
        System.out.println("SafeProbe constructed for deserialization test");
    }

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

如果某段解析逻辑本来只应该得到 UserDTO,却能根据输入实例化 SafeProbe,就说明类型边界存在问题。此时不需要进一步尝试危险利用,已经足够支撑风险结论。

对于 Jackson,多态配置需要重点检查。历史代码中常见如下危险配置:

// 需要重点排查:历史代码中可能存在类似配置
ObjectMapper mapper = new ObjectMapper();
// enableDefaultTyping 已不建议使用,容易扩大类型反序列化边界
// mapper.enableDefaultTyping();

更稳妥的方式是固定目标类型,并对 DTO 字段做校验:

ObjectMapper mapper = new ObjectMapper();
UserCreateRequest req = mapper.readValue(json, UserCreateRequest.class);

if (req.getUsername() == null || req.getUsername().length() > 64) {
    throw new IllegalArgumentException("invalid username");
}

JDK ObjectInputFilter 的使用

如果由于兼容性原因短期内无法移除 JDK 原生序列化,至少应该增加类型过滤。JDK 9 之后可以使用 ObjectInputFilter;JDK 8 的部分更新版本也提供了相关能力。

ObjectInputStream ois = new ObjectInputStream(inputStream);
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.dto.*;java.base/*;!*"
);
ois.setObjectInputFilter(filter);
Object obj = ois.readObject();

过滤规则要尽量收敛,只允许业务 DTO 和必要的 JDK 基础类型。不要为了省事写成大范围通配,例如允许整个第三方包或所有类,这样基本失去了过滤价值。

Redis 序列化器治理

很多老项目使用 RedisTemplate 默认或历史配置,可能把复杂对象以 JDK 序列化格式写入 Redis。这个问题有两个隐患:一是可读性差,不利于排查;二是一旦 Redis 写入边界失控,反序列化风险会被放大。

建议检查 RedisTemplate 配置,确认 key、value、hashValue 的序列化方式:

@Bean
public RedisTemplate<String, UserCacheDTO> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, UserCacheDTO> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    template.afterPropertiesSet();
    return template;
}

这里也要注意,JSON 序列化不等于天然安全。如果启用了过宽的类型信息,仍然可能出现问题。更推荐缓存固定结构的 DTO,避免缓存业务实体、Session 对象或包含复杂行为的对象。

消息队列消费端校验

MQ 场景中,反序列化风险常常来自“默认信任内部消息”。但在真实环境里,消息可能来自多个服务、测试工具、补偿脚本,甚至历史遗留生产者。消费者应当把消息当作不可信输入处理。

建议至少做到:

  • 按 topic 和 consumer group 收敛权限,不允许无关服务写入。
  • 消息体使用固定 schema,例如明确 DTO 字段。
  • 消费前校验版本号、来源标识、签名或内部认证信息。
  • 反序列化失败不要无限重试,避免形成异常消息堆积。
  • 对高风险字段做长度、格式、枚举值校验。
public void consume(String body) throws Exception {
    OrderEvent event = objectMapper.readValue(body, OrderEvent.class);

    if (!"order-service".equals(event.getSource())) {
        throw new IllegalArgumentException("invalid source");
    }
    if (event.getOrderId() == null || event.getOrderId().length() > 64) {
        throw new IllegalArgumentException("invalid orderId");
    }

    handle(event);
}

修复建议

反序列化风险的修复要按优先级推进,避免只做版本升级却留下危险调用方式。

  • 优先移除不必要的原生反序列化入口。
  • 升级存在公开安全问题的组件版本,并确认兼容性。
  • 关闭自动类型识别、默认类型推断等宽松配置。
  • 所有外部输入反序列化到固定 DTO,不直接接收 Object。
  • 引入字段级校验,包括长度、格式、枚举、范围。
  • 缓存和 MQ 中只传输数据对象,不传输带行为的复杂对象。
  • 为必须保留的 ObjectInputStream 配置类型白名单。
  • 在 CI 中加入依赖扫描和危险 API 检索。

CI 中可以加入简单的危险 API 检查,先作为提示项,不一定一开始就阻断构建:

#!/bin/sh
set -e

TARGET="src/main/java"

grep -RIn "ObjectInputStream\|readObject\|enableDefaultTyping\|activateDefaultTyping\|JdkSerializationRedisSerializer" "$TARGET" || true

如果团队代码量较大,可以进一步把这些规则接入 Semgrep、CodeQL 或内部静态扫描平台,并维护误报白名单。

注意点

  • 不要只依赖 WAF 拦截。反序列化入口可能在 MQ、缓存、RPC,不一定经过 HTTP 网关。
  • 不要把“升级版本”当成唯一修复。危险配置和宽泛类型仍然可能带来风险。
  • 不要在生产环境验证危险载荷。确认类型边界失控即可证明问题。
  • 不要忽略内部系统。很多事故来自内部链路的信任假设失效。
  • 不要把实体类直接作为外部 DTO。实体类字段复杂,后续改动容易扩大攻击面。

总结

Java 反序列化治理更像是一项工程化工作:先识别依赖,再确认入口,然后收敛类型边界,最后通过 CI 和配置基线持续约束。单次排查可以解决已知问题,但长期看,真正有效的是把“固定 DTO、白名单类型、字段校验、最小权限”变成开发习惯。

我的经验是,审计这类问题时不要急着追求复杂利用链。只要能证明不可信输入可以影响反序列化类型,或者关键入口缺少类型和字段约束,就应该推动修复。安全评审的目标不是展示攻击技巧,而是把风险在上线前或事故前收住。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…