发布于6小时前6小时 最近在看一个老 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 反序列化风险通常需要同时满足几个条件: 存在反序列化入口,例如 ObjectInputStream#readObject、RMI、JMX、HTTP Invoker、一些消息队列消费端。 反序列化数据攻击者可控,至少能影响字节流内容。 classpath 中存在可触发危险行为的 gadget 链。 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 有帮助,但只要入口还在,未来换依赖或新增组件仍可能重新引入风险。 加固检查清单 全局搜索 ObjectInputStream、readObject、deserialize 等关键调用。 标记所有外部可控输入到反序列化 API 的数据流。 移除不必要的 Java 原生序列化接口。 必须保留时,增加严格类型白名单。 为反序列化入口增加认证、授权、签名校验和长度限制。 升级高风险依赖,清理无用历史组件。 在测试环境加入反序列化探测用例,防止后续代码回退。 总结 Java 反序列化漏洞的核心不在某一条具体 gadget,而在“外部可控对象流被直接恢复”这个设计问题。审计时先确认入口和数据流,再看依赖与运行环境,比盲目套工具更可靠。修复上也建议优先改协议和数据格式;如果短期无法改造,至少用白名单过滤器把可反序列化类型收紧到最小范围。 这类问题在老系统里很容易被当成内部实现细节忽略,但实际影响通常比较大。只要接口还暴露在可达路径上,就应该按高危入口处理。 网络请求、日志与边界流量分析示意
6小时前6小时 可以补一个比较实用的排查点:不要只搜 `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,那风险等级就可以直接拉高。
创建帐户或登录后发表意见