跳转到帖子

背景

Java Web 项目里,反序列化问题一直比较典型:漏洞触发点往往不明显,利用链依赖运行时环境,修复也不是简单“升级一个包”就能彻底解决。实际审计中,我更建议把它当成一类输入边界问题来处理,而不是只盯着某个 CVE。

本文整理一次偏通用的代码审计与安全复现思路,重点放在如何定位风险点、如何搭建隔离环境验证、以及如何给出可落地的修复建议。文中不提供攻击载荷构造和恶意控制类操作,只讨论安全研究和防护层面的关键细节。

问题场景

某 Java Web 系统中存在一个历史接口,用于接收客户端提交的状态数据。代码最初设计时为了“方便扩展”,把部分对象直接序列化后放入请求体或缓存字段中,服务端再还原为对象继续处理。

这种设计常见于以下场景:

  • 早期 RPC 或内部管理接口,直接使用 Java 原生序列化。
  • 将复杂对象写入 Redis、文件、消息队列,再由服务端读取。
  • 使用第三方框架自动处理对象转换,但未限制类型。
  • 历史系统迁移后,旧接口仍暴露在网关或内网服务中。

安全风险的核心不在于“序列化”本身,而在于:服务端对不可信数据执行了对象还原,并且运行时类路径中存在可能被滥用的调用链。

审计入口

代码审计时,我一般先全局搜索以下关键词,快速定位可能的反序列化入口:

ObjectInputStream
readObject
readUnshared
XMLDecoder
XStream
Kryo
HessianInput
BurlapInput
readResolve
writeReplace
Serializable

其中最需要关注的是直接或间接调用 ObjectInputStream.readObject() 的位置。如果该数据来源可控,例如 HTTP 请求、Cookie、Header、上传文件、消息队列、缓存内容,就需要进一步分析。

一个典型的风险代码形态如下:

public Object decode(byte[] data) throws IOException, ClassNotFoundException {
    ByteArrayInputStream bis = new ByteArrayInputStream(data);
    ObjectInputStream ois = new ObjectInputStream(bis);
    return ois.readObject();
}

这段代码的问题是没有任何类型约束,传入什么对象都尝试还原。即使业务层后面只期望某个 DTO,风险也已经在 readObject() 执行时发生。

数据流分析

定位到疑似入口后,不建议立刻下结论。需要继续确认三件事:

  • 数据来源是否可信:是否来自外部请求、跨服务调用、队列、缓存或用户可影响的文件。
  • 是否存在认证或权限边界:接口是否只在内网可访问,是否有签名校验,校验是否发生在反序列化之前。
  • 运行时依赖是否复杂:是否引入 commons-collections、groovy、spring、rome、fastjson 老版本等历史风险组件。

特别要注意顺序问题。有些代码看起来有签名校验,但实际流程是先反序列化,再读取对象里的字段做校验,这种校验没有意义。

// 错误示例:先还原对象,再校验签名
RequestObj obj = (RequestObj) decode(requestBody);
if (!checkSign(obj.getSign(), obj.getData())) {
    throw new SecurityException("invalid sign");
}

更合理的方式是先在字节级别或文本级别完成完整性校验,通过后再进行受限解析。

安全复现思路

漏洞验证建议在隔离环境完成,目标是证明“不可信输入可到达危险反序列化点”,而不是构造破坏性效果。一个比较稳妥的做法是使用无害的测试类观察方法调用链,例如在 readObject() 中打印日志或抛出可识别异常。

public class SafeProbe implements Serializable {
    private static final long serialVersionUID = 1L;

    private void readObject(ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        System.out.println("[probe] SafeProbe readObject invoked");
    }
}

测试目标不是执行命令,而是确认服务端确实对外部输入执行了对象还原。如果日志中出现标记,或者接口返回可控异常,就可以证明风险路径成立。

在复现环境中建议保留以下证据:

  • 请求入口:接口路径、参数位置、请求方法。
  • 调用链路:Controller、Service、工具类中到达 readObject() 的路径。
  • 数据来源证明:请求体、缓存字段、消息内容等。
  • 触发结果:日志标记、异常栈、调试断点截图或文本记录。
  • 影响范围:哪些接口、哪些服务、哪些队列消费者会走到同一段解析逻辑。

关键代码审计点

除了显式的 ObjectInputStream,还要注意一些“包装后不明显”的入口。例如工具类里封装了反序列化方法,业务代码只看到 CodecUtil.decode()、Serializer.deserialize() 这类名字。

public static <T> T deserialize(byte[] bytes) {
    try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
        return (T) input.readObject();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

这类泛型封装会削弱审计时的类型感知。调用方写成 UserInfo user = deserialize(data),不代表底层只会接受 UserInfo。类型转换发生在反序列化之后,不能作为安全边界。

还需要关注异常处理。如果代码吞掉异常并继续走默认逻辑,可能导致风险被隐藏,也可能形成额外的业务绕过问题。

修复建议

修复优先级上,我一般按以下顺序建议:

  • 能移除 Java 原生序列化的,优先改为 JSON、Protobuf 等明确结构的数据格式。
  • 必须保留序列化的,增加严格类型白名单,不允许任意类还原。
  • 校验必须发生在反序列化之前,例如对原始字节做签名校验。
  • 减少高风险依赖,清理无用 jar,避免类路径过于复杂。
  • 对历史接口增加访问控制、网络隔离和调用来源限制。
  • 增加日志监控,对异常反序列化、未知类型、解析失败进行告警。

JDK 9 之后可以使用 ObjectInputFilter 增加过滤。示例仅用于说明思路,实际白名单应结合业务对象精确配置:

ObjectInputFilter filter = info -> {
    Class<?> clazz = info.serialClass();
    if (clazz == null) {
        return ObjectInputFilter.Status.UNDECIDED;
    }

    String name = clazz.getName();
    if (name.startsWith("com.example.dto.")) {
        return ObjectInputFilter.Status.ALLOWED;
    }

    if (clazz.isArray() || clazz.isPrimitive() || name.startsWith("java.lang.")) {
        return ObjectInputFilter.Status.UNDECIDED;
    }

    return ObjectInputFilter.Status.REJECTED;
};

try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
    ois.setObjectInputFilter(filter);
    Object obj = ois.readObject();
}

如果使用的是较老 JDK,也可以继承 ObjectInputStream,重写 resolveClass() 做白名单校验:

public class WhitelistObjectInputStream extends ObjectInputStream {
    private static final Set<String> ALLOWED = Set.of(
        "com.example.dto.UserState",
        "com.example.dto.OrderState"
    );

    public WhitelistObjectInputStream(InputStream in) throws IOException {
        super(in);
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc)
            throws IOException, ClassNotFoundException {
        String className = desc.getName();
        if (!ALLOWED.contains(className)) {
            throw new InvalidClassException("Unauthorized deserialization", className);
        }
        return super.resolveClass(desc);
    }
}

注意这里不要使用过宽的包名前缀,例如直接允许 com.example.,否则内部工具类、第三方扩展类也可能被纳入范围。白名单越接近业务 DTO 越好。

依赖治理

反序列化风险与依赖治理关系很大。即使入口暂时不可达,复杂依赖也会增加未来风险。建议在 CI 中固定做依赖扫描和版本收敛。

mvn dependency:tree
mvn -DskipTests package

# 如果项目使用 Gradle
./gradlew dependencies

审计时可以重点看几类情况:

  • 引入了多年未升级的通用工具库。
  • 业务代码未使用,但依赖树中存在高风险组件。
  • 多个版本同时存在,实际运行版本不清晰。
  • 应用服务器或插件目录额外加载了 jar,未体现在构建文件中。

生产环境里经常出现“代码仓库看不到,但服务器 lib 目录存在旧 jar”的情况。排查时不要只看 pom.xml,也要确认最终打包产物和运行时 classpath。

日志与检测

防护层面,可以增加一些低成本监控。比如记录反序列化失败的类名、来源 IP、接口路径、调用方服务名等信息。不要把完整请求体直接打日志,避免敏感数据落盘。

try {
    return safeDeserialize(data);
} catch (InvalidClassException e) {
    log.warn("deserialize rejected, class={}, source={}", e.classname, sourceInfo);
    throw e;
} catch (Exception e) {
    log.warn("deserialize failed, source={}, reason={}", sourceInfo, e.getClass().getSimpleName());
    throw e;
}

如果系统接入了网关或 RASP,也可以对 Java 序列化魔数进行基础识别。Java 原生序列化数据通常以 AC ED 00 05 开头,但不能只依赖这一点,因为数据可能经过 Base64、压缩或二次封装。

常见误区

  • 只升级依赖,不处理不可信反序列化入口。入口还在,风险只是降低,不是消失。
  • 认为内网接口就安全。很多事件里,反序列化入口来自消息队列、缓存污染或 SSRF 后的内网访问。
  • 用黑名单过滤类名。黑名单很难覆盖完整利用面,新增依赖后还会失效。
  • 签名校验放在对象还原之后。校验顺序错误时,安全边界并未生效。
  • 白名单过宽。允许整个业务包名,等于把大量非 DTO 类也放行。

总结

Java 反序列化漏洞的审计重点不是记某条利用链,而是建立稳定的判断方法:不可信数据是否进入对象还原流程,校验是否在反序列化之前完成,类型边界是否足够严格,运行时依赖是否可控。

在修复上,最优解是移除不必要的原生序列化;确实无法移除时,至少要做精确白名单、前置完整性校验、依赖收敛和运行时监控。这样即使未来依赖发生变化,也能把风险限制在可控范围内。

实际项目里,反序列化问题往往藏在历史兼容逻辑中。审计时多追一层数据来源和调用顺序,通常比单纯扫关键词更有价值。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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