跳转到帖子

背景

反序列化问题在 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 和任意多态;缓存和消息队列改为结构化数据并做来源校验。这样即使未来出现新的组件漏洞,也能明显降低实际暴露面。

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

0篇意见

推荐意见

没有意见。

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