跳转到帖子

一次 Java 反序列化入口审计:从 ObjectInputStream 到可控 Gadget 的排查思路

精选回复

发布于

最近在看一个老 Java Web 系统时,遇到一个比较典型的反序列化风险点。这个问题本身不新,但在实际审计里仍然很常见:业务为了方便缓存、会话迁移或接口透传,把外部输入直接喂给 ObjectInputStream。如果依赖里刚好存在可利用的 gadget 链,就可能从普通对象恢复变成远程代码执行。

背景

项目技术栈大致是:

  • Java 8
  • Spring MVC
  • Tomcat
  • 依赖中包含 commons-collections 3.x、commons-beanutils 等老版本组件

审计入口来自一个内部接口,功能是接收客户端上传的状态数据并恢复为 Java 对象。代码写法比较直接:

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

从安全角度看,关键点不是 readObject() 一定会被打穿,而是它具备了触发对象图中任意类反序列化逻辑的能力。如果攻击者可以控制 data,后续就要看 classpath 里有什么可被利用的类。

技术分析

Java 反序列化风险通常需要同时满足几个条件:

  1. 存在反序列化入口,例如 ObjectInputStream#readObject、RMI、JMX、HTTP Invoker、一些消息队列消费端。
  2. 反序列化数据攻击者可控,至少能影响字节流内容。
  3. classpath 中存在可触发危险行为的 gadget 链。
  4. JDK、第三方库版本没有阻断对应链路。

审计时我一般先不急着跑利用工具,而是确认数据流。比如下面这种 controller 就明显有问题:

@PostMapping("/state/import")
@ResponseBody
public String importState(HttpServletRequest request) throws Exception {
    byte[] body = request.getInputStream().readAllBytes();
    Object obj = stateService.restore(body);
    return "ok:" + obj.getClass().getName();
}

这里外部 HTTP 请求体直接进入了 restore(),中间没有签名校验、白名单校验,也没有使用安全的反序列化过滤器。只要接口可访问,就应视作高危入口。

排查步骤

1. 搜索反序列化 API

代码审计时可以优先搜索这些关键字:

ObjectInputStream
readObject(
XMLDecoder
XStream
readResolve
readExternal
HessianInput
Kryo
SerializationUtils.deserialize

其中 SerializationUtils.deserialize 经常被忽略,实际底层仍然会走对象反序列化。例如 Apache Commons Lang 的用法:

Object obj = SerializationUtils.deserialize(inputBytes);

2. 确认输入是否可控

不是所有 readObject() 都能形成漏洞。需要继续追踪数据来源:

  • 是否来自 HTTP body、cookie、header、上传文件;
  • 是否来自 MQ 消息且生产方不可信;
  • 是否来自 Redis、数据库中可被低权限用户写入的数据;
  • 是否经过 HMAC 或数字签名校验。

如果数据虽然来自外部,但有强签名校验,并且密钥没有泄露,风险会明显降低。但只做 Base64、压缩、AES 固定密钥加密,并不等于安全。

3. 检查依赖版本

确认入口后,再看依赖。Maven 项目可以先看:

mvn dependency:tree | grep -E "commons-collections|commons-beanutils|groovy|spring|xalan|rome|jackson"

如果是 Gradle:

./gradlew dependencies | grep -E "commons-collections|commons-beanutils|groovy|spring|xalan|rome|jackson"

这里不是说看到这些库就一定有洞,而是它们曾经出现在大量 gadget 链里,需要结合版本、JDK 和调用路径判断。

安全复现思路

在授权测试环境里,建议先用无害载荷验证反序列化入口是否可达,不要一开始就执行系统命令。比如构造一个本地测试类,在 readObject() 中打印日志,确认对象确实被恢复。

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

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

    private String marker;

    public ProbeObject(String marker) {
        this.marker = marker;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        System.out.println("[deserialize probe] marker=" + marker);
    }
}

生成测试数据:

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class GenPayload {
    public static void main(String[] args) throws Exception {
        ProbeObject obj = new ProbeObject("audit-test-001");
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(obj);
        oos.close();
        System.out.println(Base64.getEncoder().encodeToString(bos.toByteArray()));
    }
}

如果服务端 classpath 中存在 ProbeObject,并且日志打印出来,说明该入口确实会触发对象反序列化流程。真实项目里一般不会有这个类,所以更常见的做法是在测试环境增加一个探测类,或者通过现有业务类观察副作用。

修复方案

1. 优先移除 Java 原生反序列化

最稳妥的方式是不要对外部输入使用 Java 原生序列化。可以改成 JSON、Protocol Buffers、Avro 等明确结构的数据格式,并且只映射到固定 DTO。

public class StateDTO {
    private String userId;
    private long timestamp;
    private Map<String, String> attributes;

    // getter / setter
}

反序列化时只允许目标类型:

ObjectMapper mapper = new ObjectMapper();
StateDTO dto = mapper.readValue(body, StateDTO.class);

注意 Jackson 也要避免开启危险的多态类型功能,例如不应随意启用 enableDefaultTyping 或允许外部控制 @class。

2. 使用 JEP 290 过滤器

如果历史原因必须保留 ObjectInputStream,至少要加类型白名单。Java 9+ 支持 ObjectInputFilter,Java 8 的较新版本也有 backport。

import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;

public Object safeRestore(byte[] data) throws Exception {
    try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
        ObjectInputFilter filter = info -> {
            Class<?> clazz = info.serialClass();
            if (clazz == null) {
                return ObjectInputFilter.Status.UNDECIDED;
            }

            if (clazz.isArray()) {
                return ObjectInputFilter.Status.UNDECIDED;
            }

            String name = clazz.getName();
            if (name.equals("com.example.dto.StateSnapshot")
                    || name.equals("java.lang.String")
                    || name.equals("java.util.HashMap")
                    || name.equals("java.util.ArrayList")) {
                return ObjectInputFilter.Status.ALLOWED;
            }

            return ObjectInputFilter.Status.REJECTED;
        };

        ois.setObjectInputFilter(filter);
        return ois.readObject();
    }
}

这里要注意,白名单应该尽量窄,不建议写成允许整个 java.util.* 或业务大包名。很多 gadget 就藏在看起来正常的类里。

3. 自定义 ObjectInputStream

老版本 JDK 不方便使用 ObjectInputFilter 时,可以重写 resolveClass() 做类名校验:

import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.util.Set;

public class WhitelistObjectInputStream extends ObjectInputStream {
    private static final Set<String> ALLOWED = Set.of(
            "com.example.dto.StateSnapshot",
            "java.lang.String",
            "java.util.HashMap",
            "java.util.ArrayList"
    );

    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 attempt", className);
        }
        return super.resolveClass(desc);
    }
}

使用方式:

public Object restoreWithWhitelist(byte[] data) throws Exception {
    try (WhitelistObjectInputStream ois =
                 new WhitelistObjectInputStream(new ByteArrayInputStream(data))) {
        return ois.readObject();
    }
}

常见误区

  • 只判断魔数不够。 Java 序列化流通常以 AC ED 00 05 开头,但拦截这个特征只能发现部分流量,不能作为修复方案。
  • Base64 不是防护。 很多接口只是把序列化数据 Base64 后放到参数里,本质仍然完全可控。
  • 黑名单很脆弱。 禁掉几个常见类名无法覆盖变种链,尤其是在依赖复杂的大型系统中。
  • 内网接口也要处理。 反序列化入口经常出现在后台、运维、同步服务里,一旦 SSRF、网关绕过或低权限账号可访问,风险会被放大。
  • 升级依赖只是缓解。 去掉已知 gadget 有帮助,但只要入口还在,未来换依赖或新增组件仍可能重新引入风险。

加固检查清单

  1. 全局搜索 ObjectInputStream、readObject、deserialize 等关键调用。
  2. 标记所有外部可控输入到反序列化 API 的数据流。
  3. 移除不必要的 Java 原生序列化接口。
  4. 必须保留时,增加严格类型白名单。
  5. 为反序列化入口增加认证、授权、签名校验和长度限制。
  6. 升级高风险依赖,清理无用历史组件。
  7. 在测试环境加入反序列化探测用例,防止后续代码回退。

总结

Java 反序列化漏洞的核心不在某一条具体 gadget,而在“外部可控对象流被直接恢复”这个设计问题。审计时先确认入口和数据流,再看依赖与运行环境,比盲目套工具更可靠。修复上也建议优先改协议和数据格式;如果短期无法改造,至少用白名单过滤器把可反序列化类型收紧到最小范围。

这类问题在老系统里很容易被当成内部实现细节忽略,但实际影响通常比较大。只要接口还暴露在可达路径上,就应该按高危入口处理。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
可以补一个比较实用的排查点:不要只搜 `new ObjectInputStream`,很多入口是被框架或二次封装藏起来的,审计时我一般按“反序列化源头 + 可控 classpath + 过滤策略”三条线并行看。 一个简单的静态搜索清单:
grep -RIn --include="*.java" \
  -E "ObjectInputStream|readObject\(|readUnshared\(|XMLDecoder|Yaml\.load|fromXML|readValue\(|deserialize\(" src/
另外建议把这些类也纳入关注: - `java.beans.XMLDecoder` - `org.springframework.remoting.*` - `org.apache.commons.lang3.SerializationUtils` - `org.apache.commons.codec.binary.Base64` 后接 `ObjectInputStream` - `com.thoughtworks.xstream.XStream` - `SnakeYAML` 的 `load` - Jackson 开启 default typing 的场景 如果入口已经定位到 `ObjectInputStream#readObject()`,下一步我会先判断“攻击者是否能控制完整字节流”,比如:
// 典型高风险:请求体、Redis、MQ、Cookie、Session、文件上传内容直接进入 readObject
ObjectInputStream ois = new ObjectInputStream(request.getInputStream());
Object obj = ois.readObject();
相比之下,如果只是读取本地固定资源文件,风险会低一些,但也要看文件是否可被上传覆盖、路径是否可控、是否存在缓存投毒链路。 排查 gadget 时可以从依赖入手,先看是否存在常见高风险组件:
mvn dependency:tree | grep -Ei "commons-collections|commons-beanutils|groovy|spring-core|rome|xstream|jackson|snakeyaml|c3p0|hibernate"
Gradle 项目类似:
./gradlew dependencies | grep -Ei "commons-collections|commons-beanutils|groovy|spring-core|rome|xstream|jackson|snakeyaml|c3p0|hibernate"
有个细节容易漏:不是看到依赖就一定能打,重点要确认 gadget 所需类是否都在运行时 classpath,并且 JDK 版本是否匹配。例如 JDK 8u121 以后对部分链影响比较明显,JDK 9+ 模块化也会影响一些反射调用。 如果是修复建议,不建议只靠黑名单。能改协议的话,优先不要用 Java 原生序列化;短期兜底可以加白名单过滤。JDK 9+ 可以用 `ObjectInputFilter`:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.dto.*;java.base/*;!*"
);

ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(filter);

Object obj = ois.readObject();
JDK 8u121+ 也可以全局配置:
-Djdk.serialFilter=com.example.dto.*;java.base/*;!*
白名单要尽量收窄到业务 DTO,不要直接放 `java.util.*`,因为很多 gadget 链就是靠集合类承载调用链。 动态验证时,可以在本地打点确认真实调用栈。比如临时给 `ObjectInputStream#readObject` 加断点,或者用 Arthas 看调用来源:
trace java.io.ObjectInputStream readObject '#cost > 1'
再配合:
stack java.io.ObjectInputStream readObject
这样能比较快区分是接口请求触发、定时任务读取缓存,还是中间件内部反序列化。 最后一点,审计报告里最好把“入口可控性”和“可利用链条件”分开写: 入口存在不等于可 RCE;但如果输入流外部可控、无过滤、运行时依赖存在可用 gadget,那风险等级就可以直接拉高。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。