☰
Java反序列化漏洞原理与防御:从readObject到RCE的完整拆解
2026/10/2 3:45:53 网站建设 项目流程

先别急着往下看,我先问你一句:你上次在代码里写ObjectInputStream ois = new ObjectInputStream(input); ois.readObject();的时候,有没有想过这个“读数据”的动作,到底会读出来什么?我见过不少刚接触反序列化安全的开发,第一反应都是“这不就是把字节流恢复成对象嘛,和 JSON.parse 差不多吧”。等自己跑通一次利用链,看到弹出的计算器、看到的命令执行回显,才意识到问题有多严重。这篇内容不端着讲理论,就围绕 readObject() 这条线,把它背后真正会发生的事、攻击者怎么利用它、我们又该怎么防,一层层拆开说明白。

我尽量用做项目、做安全测试时踩过坑的经验来写,适合正在写 Java 接口的研发、维护过中间件的运维,以及刚开始学安全的测试同学。先把一个观念立住:反序列化不是“读取数据”,它是在“执行模型”。字节流里藏着的不仅是字段值,还有类名、方法调用的触发点。你允许一次 readObject(),等于把一台机器的门打开,让一段你完全不知道会干什么的逻辑跑起来。

1. 先看清楚:readObject() 并不只是“读数据”

1.1 Java 原生的序列化机制长什么样

Java 对象能够被序列化,核心靠两件事:一个是对象需要实现Serializable接口,另一个是使用ObjectOutputStream.writeObject()把一个对象的状态转换成字节流。反过来,ObjectInputStream.readObject()从字节流里恢复对象。这个过程看起来是对称的,但很多人忽略了一个关键点:序列化字节流里不仅保存对象的字段值,还保存对象的类描述信息。

也就是说,当我有一个User对象,里面有个name字段,为“张三”,序列化后那个文件里包含“类名:com.example.model.User”以及字段名、字段值。反序列化时,JVM 会先读取类描述,再尝试加载这个类,然后通过反射创建实例、给字段赋值。

这段描述看起来没毛病,但问题恰恰出在一个细节上:真正开发中,没人会只序列化简单的 POJO。像HashMap、ArrayList、PriorityQueue这些常用集合类,它们全部都实现了Serializable,而且里面装的元素也可以是任意可序列化对象。这套组合拳下来,反序列化的实际工作量和风险边界,早就超出“读几个字段”了。

1.2 readObject() 是一个能“搞事情”的钩子

先看一个最基本的常识:一个类如果不写readObject(),反序列化时执行的是默认逻辑;但如果类里定义了readObject()方法,反序列化时就会调用它。这不是 Java 魔法,而是刻意设计成这样的一个扩展点,目的原本是让开发者能在恢复对象时做额外校验或状态修复。

麻烦的是,JDK 自带的很多核心类,也重写了readObject()。它们重写的理由都很正当:HashMap需要重新计算 hash 桶、PriorityQueue需要恢复堆结构、TreeMap需要重新构建红黑树。可问题来了,这些恢复内部结构的过程,往往需要调用对象的方法,比如hashCode()、equals()、compareTo()。

我打个比方你就懂了。你以为你在读一本字典,翻到哪一页就记哪一页的内容;但实际上的动作是,每翻一页,字典都会根据你翻到的那一页,自动去执行旁边贴着的一张小纸条上的指令。如果一个攻击者能往字节流里塞进精心构造的“小纸条”,那么一次再看普通的 readObject(),就会沿着类的内部逻辑调用链,一路执行到危险操作。

2. 把“读数据”变成“跑命令”:反序列化攻击的原理拆解

2.1 先记住这几个黑话:gadget、gadget chain、sink

搞反序列化利用,绕不开这几个词:

  • gadget:指某个类里可以被利用的方法片段,比如一个transform()、一个setValue()、一个hashCode(),它不是专门为了攻击而存在的,而是正常业务里就有,但被恶意编排后能一步步靠近危险动作。
  • gadget chain(利用链):把多个 gadget 串起来,从readObject()的入口一路调用到最终的危险方法。这个过程很像搭积木,每个类的方法都只是做了很小的一步,但串着串着,就从“读字段”走到了“执行命令”。
  • sink:链条的终点,通常是类似Runtime.exec()、ProcessBuilder.start()、Method.invoke()这种能够真正执行系统命令或反射调用的方法。

理解这几个词之后,你再去看网上各种“反序列化漏洞分析”,就不会一头雾水了。说的无非就是:入口点在哪、中间用哪些类把调用接起来、最后让哪个方法兜底执行命令。

2.2 经典 CommonsCollections 链路是怎么串起来的

在 Java 反序列化漏洞历史里,Apache Commons Collections的出镜率相当高。原因也很简单:它是一套用途极广的集合工具库,很多 Java Web 项目直接或间接依赖它。这库里又有几个类,天生串起来就适合当“积木块”。

核心是Transformer接口,它有个代表性实现叫InvokerTransformer,功能是通过反射调用方法。你可以把它理解成“万能代理”:给它一个对象、一个方法名、一组参数,它就帮你反射调用。这本身是设计出来的功能,合法业务里确实有场景需要动态调方法,但攻击者看它,眼里的东西就不一样了。

另一个关键实现是ChainedTransformer,它会把一组Transformer按顺序全部执行一遍,前一个的输出作为后一个的输入。当攻击者把手里的 Transformer 串成下面这样的顺序:

  1. 先返回Runtime.class;
  2. 反射调用getRuntime()拿到Runtime实例;
  3. 反射调用exec()方法传入系统命令。

这三个Transformer用ChainedTransformer包起来之后,只要让链条被触发一次,命令就直接执行了。

那怎么让它触发?这就需要找“入口”。CommonsCollections 系列有多个入口变种,比如经典的AnnotationInvocationHandler入口(JDK 内部类,曾经在readObject()里会遍历 Map 的 entry 并调用setValue()),比如TiedMapEntry+LazyMap的入口(在反序列化时触发hashCode()进而触地 Map 的get()方法),比如PriorityQueue+TransformingComparator的入口。

我用 CC6 这条链给你描述一下完整的调用路径,方便你理解为啥一次读操作会变成命令执行:

  • HashMap.readObject()恢复键值对时,会对每个 key 调hashCode();
  • key 是一个TiedMapEntry对象,它的hashCode()会调用内部Map的get();
  • 内部的LazyMap在get()找不到缓存时,会调factory.transform();
  • factory是一个ChainedTransformer,于是上面的三步 Transformer 依次执行,最后Runtime.exec()被调用。

看到没,整条链的每一步都是正常方法调用,没有任何一个类叫“黑客工具类”。这就是反序列化利用的可怕之处:全是正常组件,组合起来却不干正事。

2.3 不止 CommonsCollections:真实世界里的重灾区

有人可能觉得,那我不用 commons-collections 就行了?没那么简单。这些年 Java 生态里被反序列化问题折腾过的场景相当多:

  • Apache Shiro:它的“记住我”功能会把用户身份序列化后加密放进 Cookie。如果密钥泄露,攻击者就能构造恶意序列化数据,服务端反序列化后直接 RCE。这里的问题甚至不需要额外引入什么库,因为 Shiro 自身为了“记住我”功能就内置了序列化逻辑。
  • WebLogic:它的 T3 协议和 IIOP 协议都涉及反序列化,历史上出过大量绕过补丁的案例。有些漏洞利用最关键的点就是“找到新的 gadget 绕过黑名单”。
  • Fastjson:虽然它主打 JSON,但JSON.parseObject()在开启autoType的情况下,可以指定反序列化类的@type字段。攻击者利用这个特性,配合特定类库里的 getter/setter 逻辑,也能走到命令执行。它不完全等同于原生 Java 反序列化,但风险一样真实。
  • Spring:早期某些版本在处理 HTTP Invoker 等远程调用协议时,同样存在反序列化风险。

所以你会发现,反序列化漏洞从来不只是某个库的 bug,而是“入口 + 可用的 gadget 类”两个条件同时满足时产生的结果。很多系统,入口没办法完全消灭,那就只能在打底层做拦截和隔离。

3. 亲手看一次“爆”出来的过程:靶场与本地复现

3.1 用 Pikachu 靶场直观感受漏洞现象

不少安全教学靶场里都内置了反序列化漏洞模块,其中 Pikachu 靶场(一个开源的教学环境)算很好上手的。它的反序列化模块会提供一个带漏洞的接口:你把一个经过 Base64 编码的序列化对象提交给服务端,服务端直接拿ObjectInputStream去读它,然后把反序列化出来的对象属性展示在页面上。

操作方式一般是这样的:先用工具(比如 ysoserial)生成一个执行指定命令的序列化 payload,然后用 Base64 编码,作为参数提交到靶场接口。如果命令是whoami,回到页面上就能看到系统用户名;如果是ipconfig或ifconfig,就能看到服务器网络信息。

我第一次在靶场里跑通时,印象最深的就是那种“页面看上去一切正常,但我传过去的东西真的让它执行了系统命令”的割裂感。靶场的设计目的就是让你了解漏洞的触发机制和现象,我只建议在本地或隔离网络里玩这个环境,不要把它部署到公网或者拿它去打别人。

3.2 在本地写一个最小实验,看 readObject 如何成为入口

如果你想自己做一个最小实验,不需要跑到靶场里,本地写几行代码就够了。思路很简单:把利用链生成的.ser文件放在本地,然后用一个普通的ObjectInputStream.readObject()去读它。

第一步,准备 ysoserial 工具生成 payload。比如我这里以CommonsCollections6链为例,让它执行 Mac 上弹出计算器的命令:

java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections6 "open /System/Applications/Calculator.app" > calc.ser

第二步,写一个看起来毫无攻击性的 Java 程序,它就干一件事:读取并反序列化这个文件。

import java.io.FileInputStream; import java.io.ObjectInputStream; public class ReadObjectDemo { public static void main(String[] args) throws Exception { try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream("calc.ser"))) { Object obj = ois.readObject(); System.out.println("反序列化完成,对象类型: " + obj.getClass()); } } }

第三步,运行它,你就会看到/System/Applications/Calculator.app被启动了。而你的代码里,从头到尾没有任何调用Runtime.exec()的地方,只是“读了一下文件”。这种体验远比看一百篇分析文章都直观。

需要注意:这个实验依赖commons-collections3.1 或 3.2.1 版本。如果你的项目已经升级到 3.2.2+,这条常见的链可能失效,因为 3.2.2 对InvokerTransformer等危险类做了修复,不再允许它们反序列化。这也是为什么网上同一个 payload 有的环境能打、有的环境打不了的根本原因。

3.3 为什么 payload 看起来像“乱码”

对反序列化 payload 有印象的人都知道,它看起来不是可读的文本,而是一串奇怪的二进制。你应该也见过某些 WAF 规则里会出现rO0AB开头的字符串,那就是 Java 序列化对象经 Base64 编码后的样子。

Java 序列化文件的头部固定是魔法数字AC ED 00 05,转成 Base64 以后通常以rO0AB开头。所以安全设备要拦截 Java 反序列化攻击,最简单粗暴的方式之一,就是在报文里找这段特征。攻击者为了绕过这个特征也会有各种对抗方式,比如分段提交、篡改头部、格式变种等等。但从防御角度,你至少要知道:如果你的接口需要接收这种特征的数据,且又直接调用了 readObject(),那基本等于把攻击者的路铺好了。

4. 防御落地方案:五条能直接执行的硬规则

4.1 规则一:能不用原生反序列化,就别用

这条规则听起来最简单,但做起来最难,因为很多历史遗留代码就是靠 Java 序列化在走数据交换。比如早期有些系统会把对象直接写进 Redis、Memcached,或者服务之间通信直接走 Java 序列化。

我的建议是,如果是新项目,数据交换格式一律优先走 JSON、Protobuf、Avro 这类结构化格式,谁也别折腾 Java 序列化。其中 JSON 方案里还要注意,不要轻易开启autoType和enableDefaultTyping,否则等于又开了一个类似的洞。

如果是老项目,面临迁移成本,那至少要把有反序列化入口的位置盘点出来,单独做过滤和监控,不要让它们成为被绕过后的中招点。

4.2 规则二:必须反序列化时,做类白名单过滤

现实是有些场景真的绕不开反序列化,比如某些中间件协议本身就这么设计的。这时最有效的基础防线就是:在resolveClass()阶段检查类名,只有出现在白名单里的类才允许被加载。

写一个自定义的ObjectInputStream是业内成熟方案,业内一般叫 lookahead ObjectInputStream:

import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.util.Arrays; import java.util.HashSet; import java.util.Set; public class WhitelistObjectInputStream extends ObjectInputStream { private final Set<String> whitelist = new HashSet<>(Arrays.asList( "com.example.model.UserInfo", "com.example.model.Product", "java.lang.String", "java.util.ArrayList", "java.util.HashMap", "java.util.Date" )); public WhitelistObjectInputStream(InputStream in) throws IOException { super(in); } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); if (!whitelist.contains(className)) { throw new InvalidClassException("不在白名单中的类", className); } return super.resolveClass(desc); } }

可以看到,整个判断逻辑就是一张表的事。但操作上有一个很实际的坑:白名单不能随便拍脑袋列。你需要先梳理业务里的序列化对象到底有哪些,列全了再固化。我在实际项目中给一个老系统配白名单时,就出现过因为漏了某个业务对象类,导致线上接口大面积报错的情况。建议先开启一段时间的日志记录模式,把真实出现的类名都收集起来,再收敛成白名单。

4.3 规则三:用 JEP 290 / ObjectInputFilter 做底层拦截

Java 9 开始官方提供了ObjectInputFilter机制,可以直接给 ObjectInputStream 设置过滤规则。Java 8 的高版本也通过 JEP 290 回移提供了支持,所以现在大多数线上 JDK 8 都可以直接用。

配置方式有两种。一种是在代码里设置:

ObjectInputStream ois = new ObjectInputStream(input); ois.setObjectInputFilter(info -> { if (info.serialClass() != null && !info.serialClass().getName().startsWith("com.example.model.")) { return ObjectInputFilter.Status.REJECTED; } return ObjectInputFilter.Status.ALLOWED; });

另一种是全局 JVM 参数:

-Djdk.serialFilter=com.example.model.*;java.lang.*;java.util.*;!*

全局参数的意思就是:只允许com.example.model.*、java.lang.*、java.util.*这些前缀的类反序列化,其他一律拒绝。这个方案的优点是生产环境不用改代码,出问题回滚也容易,可以把参数先加上,观察一段时间。

不过要注意一个版本细节:jdk.serialFilter是 JDK 8u121 才开始引入的,如果你的线上 JDK 比这个还老,需要先升级。另外,JDK 9 以后filterFactory的实现也有了变化,有条件建议直接用较新版本做统一收敛。

4.4 规则四:从依赖侧排查并升级风险组件

反序列化利用的前提之一是“目标环境里有对应的 gadget 类”。所以依赖管理是你最可控的一环,做法也直接:

  • 升级commons-collections到 3.2.2 或 4.4+,这两个版本对多个存在反序列化隐患的类增加了防御逻辑;
  • 全面排查项目里是否出现过InvokerTransformer、InstantiateTransformer、TransformingComparator等危险类,用 Maven 或 Gradle 的依赖树命令一眼就能看出来;
mvn dependency:tree | grep commons-collections # 或者 gradle dependencies --configuration compileClasspath | grep commons-collections

但这里有个非常隐蔽的坑:不少“安全修复”根本轮不到你项目的 pom.xml。我接手过一个系统,应用自己的依赖检查过全是安全的,但用的中间件包里内置了一个旧版 commons-collections,且它暴露的 RMI 接口允许反序列化,最终漏洞评估结果还是没救回来。所以在做审计时,不光看应用依赖,还要把中间件安装目录下的 lib 包也翻一遍。比如 WebLogic 的wlserver目录里,就可能有大量存在历史漏洞的类库。

4.5 规则五:纵深防御,别把宝押在一层防线上

就算前面的措施都做了,还是应该保持“假设会被绕过”的心态来兜底防御。我在安全治理上比较偏好的做法是分层叠加:

  • 入口层:在 HTTP 层拦截包含rO0AB开头(即 Base64 后的 Java 序列化特征)的请求参数,对内部接口、后台接口尤其严格;
  • 运行时层:在应用部署时挂 RASP(Runtime Application Self-Protection),它能在 JVM 底层检测到例如Runtime.exec()被非预期链路调用的情况并直接阻断,这类产品对反序列化链路的检测覆盖率远高于传统 WAF;
  • 系统层:不要用 root 权限运行中间件和 Java 进程,单独建低权限账号,限制进程能访问的文件目录;在安全组和防火墙层面限制服务器主动外联,因为反序列化攻击执行命令后常需要“回连”攻击者的机器来上下载工具,断了外联能极大提高攻击者拿第二阶段的成本;
  • 监控层:对ObjectInputStream的调用、Runtime.exec的发起者、进程的网络连接记录日志,做到出了问题能看清整个链路。

这几层单独拆开看都不算难,组合在一起之后,攻击者要过五关斩六将才能把一次反序列化攻击完整跑通。安全本来就是成本与风险之间的权衡,没必要一步到位上超重方案,但底线上要有个“即使第一道被绕,后面还有关卡”的格局。

5. 自查清单与踩坑经验

5.1 三分钟快速自查清单

我把日常排查反序列化风险的点整理成一张表,你在项目上线前或者做代码审计时直接照着过一遍:

检查项怎么查判断标准
是否存在 ObjectInputStream 反序列化入口全局搜索new ObjectInputStream有且能接外部输入,立即标记高危
入口是否接收用户可控数据查看请求参数、文件上传、消息队列等来源不可信输入直达 readObject 为高危
是否启用 JEP 290 过滤查看 JVM 参数或代码里ObjectInputFilter未配置则建议优先补齐
依赖里是否有危险 gadget 类mvn dependency:tree搜索 commons-collections 等版本低于安全线则升级
中间件自带依赖是否安全查看中间件 lib 目录版本存在旧组件要及时升级或换版
是否用 JSON/Protobuf 替代序列化数据交换查看接口协议定义新系统应直接规避原生序列化
是否监控反序列化触发点看日志或 RASP 策略无监控意味着盲区

注意,这只是一份快速自查的清单,不是完整安全评估。真正要确认漏洞是否可利用,还是要在测试环境执行实际的攻击链路验证。

5.2 我在实际项目中踩过的几个坑

先说第一个坑:升级了 pom,但打出来的 jar 里还是旧版依赖。有一次我给一个服务升级 commons-collections,明明 pom 里版本号已经改成 3.2.2,但用工具扫描最终构建出来的 fat jar 时还是发现旧的类文件。最后查下来,是打包插件把某个上层依赖解压后再把旧 jar 塞了回去。这种问题只有以最终产物为准去查,不要只看 pom 声明。

第二个坑:ObjectInputFilter 上线即事故。我在前面讲了白名单过滤的方针,但实践中最容易翻车的就是过滤规则配置过严。有一次我把一个老接口加上jdk.serialFilter全局参数,白名单只写了模型类,结果接口直接大面积报错,因为某个服务间调用会在序列化数据里带上自定义异常类型,那个类没进白名单,所有请求直接凉了。后来学乖了,凡是新加过滤策略,先在预发环境跑两周,记录被拦截的类名,再决定是加白名单还是淘汰相关功能。

第三个坑:漏掉了“二次反序列化”场景。有些系统虽然对外接口是 JSON,但内部为了性能可能把某些对象序列化后存进 Redis,取出时再反序列化。这类入口隐蔽,不做全链路梳理很难发现。我的建议是,把“凡是能传入字节流的地方”都当成攻击面,而不是只看用户请求直接打到的那个方法。

第四个坑:过于依赖黑名单。有段时间很多方案热衷于维护一套“危险类黑名单”,把已知的利用链类都禁掉。问题是攻击者可以找新的 gadget,或者用 JDK 内部类构造新链条,黑名单永远慢半拍。相比之下,白名单模式天然能拦住未知链条,对不在体系内的类一概拒绝,体验上虽然前期痛苦但远更可靠。

5.3 真遇到攻击时,应急可以从哪里入手

万一系统还是被打了,别慌,也别直接重启机器把所有证据丢了。先做几个基础动作:

  • 从应用日志里找反序列化入口的请求记录,尤其是带有异常InvalidClassException、ClassNotFoundException且伴随着异常类名的记录;
  • 从系统进程看有没有异常的java子进程、反弹 Shell 或者下载器进程;
  • 查看服务器网络连接,找是否有到陌生 IP 的外联;
  • 关注临时目录里新增的可执行文件、jar 包、脚本文件;
  • 找到利用链中涉及的类名后,反查是哪个接口把这条数据放进来,封堵入口。

这一套动作做完,基本上能把攻击路径摸清楚。核心原则是:先切断攻击者的后续动作(外联、命令执行),再保留现场,最后重建环境时带着“为什么会被打穿”的答案去补方案。

反序列化这个问题,我自己从最开始“居然还有这种事”到后面能在一堆业务代码里快速定位风险入口,靠的就是亲手复现、亲手看链、亲手配置防御。说实话,等你自己在本地把那条弹计算器的 payload 跑起来之后,你再看到代码里的readObject(),心里那根弦自然就绷紧了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询