前阵子跟一个做中间件的朋友聊起安全评审,他提到一个很典型的场景:他们团队把一个老系统纳入内网安全扫描,结果清单上赫然写着“存在Java反序列化远程代码执行风险”。翻代码一看,确实有个接口直接拿着客户端传来的字节流去调readObject()。这种问题在Java技术栈里太常见了——Java原生序列化用起来简单,但它的安全模型非常古老,一旦出现在不可信边界,基本等于给攻击者递了一把钥匙。
这篇文章,我想把Java序列化从“是什么”讲到“怎么防”:先拆解序列化和反序列化的底层机制,再聊攻击者那条臭名昭著的行为链是怎么串起来的,最后给出我实际用过的几种加固方案,包括自定义过滤、JDK官方过滤器、以及架构层面的迁移思路。无论你是后端开发、中间件开发者还是安全工程师,这轮内容应该都能用得上。
1. 先把机制讲透:序列化与反序列化到底干了什么
1.1 序列化的定位:把内存里的对象变成字节,但不只是“变字节”
Java序列化的本意很简单:让对象可以脱离JVM存活。你把它写入文件,它就是一段字节;你把它发到另一台机器,它也是一段字节。另一个JVM拿到这段字节后,可以把对象“恢复”出来。
从实现上看,Java原生序列化做的事情比表面复杂得多。一个实现了Serializable接口的类,不用写任何额外代码,JVM就会自动把这几类信息写进字节流:
- 类的描述信息:类名、serialVersionUID、字段名、字段类型、是否自定义了
writeObject/readObject方法。 - 实例字段的值。
- 对象之间的引用关系,也就是对象图。比如一个
Order对象里挂着List<Item>,序列化时会把整棵对象树都写进去,包括A和B互相引用这种环状结构。
这里有个容易被忽略的点:Serializable只是个标记接口,它没有方法。真正的工作全部由ObjectOutputStream和ObjectInputStream完成。读的时候,ObjectInputStream会解析字节流里的类描述,用反射去加载类、创建实例、填充字段。
另外就是serialVersionUID。它相当于类的“版本指纹”。如果你不显式声明,JVM会根据类名、字段、方法等信息自动算一个hash。问题在于:类一旦加了字段、删了字段、改了方法签名,这个自动算出来的UID就会变。客户端用老版本序列化,服务端用新版本反序列化,两边UID对不上,直接抛InvalidClassException。
所以实践上我一般建议:所有实现Serializable的类,都显式声明serialVersionUID,别把版本控制交给JVM的自动计算。这虽然看起来是个“工程规范”,但和安全也有关系——攻击者可以通过分析UID变化来猜测服务端的类结构,有时候这会是攻击链的第一步。
1.2 反序列化的“隐式执行”才是问题核心
为什么反序列化比序列化危险得多?关键就在readObject()的语义。
很多人对反序列化的理解是“把字节流还原成对象”,好像它就是个赋值操作。但真正发生的是:JVM读取字节流中的类名,解析并加载这个类,然后创建一个实例。在创建实例的过程中,类里一些“特殊方法”会被自动调用,包括:
readObject(ObjectInputStream):自定义反序列化逻辑。readResolve():替换返回对象。readObjectNoData():在字节流里没有当前类数据时被调用。
这些方法不是业务代码显式调用的,而是反序列化机制自动触发的。这意味着什么?攻击者不需要知道你业务里有什么方法,他只需要往字节流里塞一个“指定类名”的类描述,让readObject()去加载它。只要这个类存在于JVM或加载路径上,并且它的某个特殊方法里包含了可被利用的逻辑,攻击者就赢了一半。
用一个生活类比:你收到一个快递,拆开箱子时里面每个小盒子都会自动弹开,弹开的动作又会触发下一个机关。反序列化就是这样——你不是在“读一封信”,你是在“拆一堆带机关的组合包裹”。
更要命的是,Java里的ObjectInputStream在解析时还会处理动态代理类。代理类在反序列化时可以指定一个InvocationHandler,而代理对象的方法调用会被转发给这个handler去执行。这种灵活性是Java反射体系的特点,也确实让攻击链的构造变得异常灵活。
1.3 高风险场景盘点:哪些地方最容易中招
我整理了一个高风险场景表,基本覆盖了Java生态里最常被扫出反序列化漏洞的入口:
| 场景 | 风险来源 | 常见路径 |
|---|---|---|
| RMI远程调用 | RMI协议的消息体就是Java原生序列化字节流 | 注册中心、远程接口 |
| HTTP会话持久化 | 会话对象需要存库或跨进程共享,常走原生序列化 | 身份会话、购物车状态 |
| 消息队列消息体 | 部分中间件直接把Java对象作为消息体序列化 | 队列消费者入口 |
| 分布式缓存存储复杂对象 | 序列化后写入缓存,再从缓存反序列化回业务对象 | 热点数据、临时状态 |
| 自定义RPC协议 | 团队内部图省事,直接ObjectOutputStream收发 | 内网端口服务 |
每个场景的共性都是同一个:不可信数据直接进入了readObject()。HTTP会话那个尤其要提,因为很多开发者觉得“会话数据是服务端自己写的,总能信吧”。但只要攻击者能拿到会话数据的存储位置,或者能伪造一个会话序列化字节流,同样的风险就存在。所以不能只盯着“来自外网的请求”,更要看“反序列化这个动作的输入是否完全可控”。
2. 攻击者的利用思路:一条真实但危险的“行为链”
2.1 打个比方:快递炸弹与触发开关
安全圈里有个术语叫gadget chain,直译是“小工具链”。我理解的攻击过程是这样的:攻击者不会真的往你的字节流里塞一个他写的恶意类——因为你的JVM根本不会加载一个不存在的类。他做的是另一件事:扫描你的运行环境里已经存在的类,找出那些内部逻辑可以被拼装成“自动执行”序列的组件。
还是用快递类比。攻击者不需要自己制造炸弹,他只需要知道你的仓库里有哪些“自带弹簧的盒子”“带火石的盒子”“里面装了易燃物的小瓶”。他做的只是把这些现成的零件,按一定顺序装进一个大快递箱,然后寄给你。你收货时拆一层,一个零件触发另一个零件,最后一环触发了Runtime.exec()这类能执行系统命令的类。
这个链条的关键叫“触发点”。在Java反序列化漏洞里,最常见的触发点就是readObject()或readResolve()这类隐藏钩子。某处钩子里如果调用了某个可以被篡改属性的方法,而那个方法又继续调用另一个对象的危险方法,链条就被逐级点燃了。
我在正文里不愿意贴完整payload,一是安全合规问题,二是这种东西看了也没啥帮助。你真正需要理解的是它的组织逻辑,而不是复制一串字节流。理解了逻辑,防御才有方向。
2.2 触发条件拆解:三个缺一不可
我复盘过很多次漏洞利用场景,发现反序列化攻击要成立,基本要同时满足三个条件:
- 存在反序列化入口:应用直接或间接对不可信输入调用了
ObjectInputStream.readObject()、readUnshared()等操作。 - 运行环境包含可利用的类:JDK本身、某集合工具库、某JSON处理库、某模板引擎,只要有一个类的逻辑能被拼进链里,就有机会。
- 缺少有效的过滤或白名单机制:应用没有对可反序列化的类做限制,也没有对字节流深度、引用数量做约束。
这三个条件缺一不可。如果入口被封死,再好的链子也派不上用场;如果环境里没有可利用的类,链子就组装不起来;如果加了白名单,就算链子拼好了,第一步就会被拦下。
所以很多团队问“要不要把某个依赖去掉”,我的回答通常很直接:先看你的入口在哪里。依赖瘦身是对的,但如果你有个端口直接对外接受反序列化数据,去依赖治标不治本。
2.3 为什么黑名单式防御永远不够
有些人会想:既然攻击者用的是某些已知危险类,那我写个黑名单,把这些类全部拒掉不就行了?
这个思路的问题在于:黑名单是静态的,攻击是动态的。Java生态环境里类库巨大,一个人畜无害的类,换一种组合方式就可能变成攻击链里的一环。今天你拉黑了几个出名类,明天攻击者换一条新链路,绕过你这些规则。而且Java 8/11/17的JDK自带类库本身就很大,总能在里面找到新的“零件”。
我的态度很明确:反序列化过滤这事,默认拒绝一切,只放行明确可信的类,才谈得上安全。黑名单是应急手段,不是长期防线。
3. 方案一:尽量别让Java原生序列化暴露在边界
3.1 数据交换格式怎么选:先讲横向对比
如果你现在还有机会做技术选型,或者老系统改造的窗口比较长,我强烈建议你把边界上的Java原生序列化替换成“无执行逻辑”的格式。所谓“无执行逻辑”,就是这类格式在被解析时只会产生纯数据对象,不会自动触发类方法、类加载器这类动态能力。
我一般这样对比:
| 格式 | 类型信息 | 跨语言 | 解析时是否自动触发类逻辑 | 适用场景 |
|---|---|---|---|---|
| Java原生序列化 | 强(直接携带类名) | 差 | 是 | 仅限可信内部链路,且有过滤 |
| JSON | 弱(字段名+值) | 好 | 否 | 绝大多数接口、数据存储 |
| XML | 中 | 好 | 取决于解析器,需防外部实体 | 配置、文档类交换 |
| 结构化二进制协议 | 强(通过IDL描述) | 好 | 否 | 高性能、高并发RPC |
这里不是要把Java原生序列化说得一无是处。它确实快、简单、类型信息完整,很适合短距离可信链路。但“可信”这两个字在分布式系统里越来越难保证。一台内网机器被攻破后,攻击者横向移动时最喜欢找的就是这种没人设防的“可信链路”。所以我的建议是:边界处一律不用原生序列化,内部链路能不用也尽量不用,实在要用就加过滤。
3.2 迁移时不能踩的坑:兼容性才是大难题
从原生序列化迁移到JSON或结构化二进制协议,技术实现本身不难,难的是兼容老数据、老客户端。我见过不少团队在这步翻车,所以列几个重点:
- 区分传输对象和领域对象:别把领域对象直接序列化暴露出去。先定义一个独立的DTO层,只保留需要传输的字段。这样将来改协议时,领域模型不会跟着震动。
- 双通道兼容策略:老客户端还在用Java序列化,新客户端用新格式,新旧并存一段时间。反序列化入口先尝试解析新格式,解析失败再落到老逻辑。等老客户端全部升级完,把老逻辑删掉。
- 字段版本管理:文本格式更容易被人看懂,但也容易被忽略版本。我建议在消息体里显式加一个
version字段,后续加字段时默认值设为老版本,避免新老两端出现字段缺失。 - 类型强约束:JSON反序列化时,很多库会把
{ "id": 1 }自动转成Integer,但目标字段是Long,于是产生类型错误。这种问题在迁移期特别常见,提前在DTO上用明确的类型注解,能省很多排查精力。
3.3 传输层安全也要补上,不只在序列化层使劲
序列化格式换掉之后,不要觉得安全就完事了。字节流在网络上传输时仍然可能被窃听、被篡改。所以传输层必须叠加三件套:
- 签名:对消息体做签名,接收方校验签名,防止中间人篡改。
- 加密:敏感数据一定加密,防止被读取。
- 压缩:不是安全问题,但能减少带宽,我在大消息场景里一般会加。
这些都是独立于序列化格式的方案,叠加起来不会互相冲突。很多团队只盯着“反序列化漏洞”这一个点,结果数据被拖走也没发现,因为日志里全是正常格式。安全从来不是一个单点,而是多层防线。
4. 方案二:必须用原生序列化时,给读入过程装闸机
4.1 过滤思路:先想清楚你允许哪些类
有些场景确实绕不开原生序列化。比如你的老系统维护了五六年,客户端遍布各地,你要让所有客户端一夜之间全升级成新协议,不现实。又或者某个中间件内部就是靠Java序列化传数据的,你替换不了。
这时候要做的是:给ObjectInputStream装一道“安检门”。安检门的核心不是“哪些类危险”,而是“哪些类是我们业务真的会反序列化的”。
我接手的项目里,安全的业务系统通常只会反序列化自己模块的DTO、一些JDK基础类型(比如String、ArrayList、HashMap)。除此之外,任何类都不应该出现在字节流里。如果某个类真的需要被反序列化,它应该出现在白名单里,并且经过代码评审。
4.2 自定义ObjectInputStream:在resolveClass那一层拦下来
最直接的做法是写一个SafeObjectInputStream,重写resolveClass()方法。为什么选这个方法?因为ObjectInputStream在解析字节流里的类描述时,最先调用的就是resolveClass。在这里做校验,可以在类加载之前把它拦住。
一个可以落地的示例:
package com.example.security; import java.io.IOException; import java.io.InputStream; import java.io.InvalidClassException; import java.io.ObjectInputStream; import java.io.ObjectStreamClass; import java.util.List; /** * 白名单过滤的 ObjectInputStream * 只允许指定的基础包和业务DTO包被反序列化 */ public class SafeObjectInputStream extends ObjectInputStream { private static final List<String> ALLOWED_PREFIXES = List.of( "java.lang.", "java.util.", "java.time.", "com.example.dto." ); public SafeObjectInputStream(InputStream in) throws IOException { super(in); } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); boolean allowed = ALLOWED_PREFIXES.stream() .anyMatch(className::startsWith); if (!allowed) { throw new InvalidClassException( "反序列化被拒绝,类不在白名单中: " + className); } return super.resolveClass(desc); } }这个类的逻辑不复杂:读取字节流里的类名,检查它是不是以java.lang.、java.util.等前缀开头;不是就抛异常。注意这里用前缀匹配,是相对宽松的规则。如果你对安全性要求更严,可以改成精确的类名白名单集合:
private static final Set<String> ALLOWED_CLASSES = Set.of( "java.lang.String", "java.util.ArrayList", "java.util.HashMap", "com.example.dto.OrderDTO" );两种写法各有取舍。前缀匹配省事,但要小心java.lang.reflect这类子包里的类——虽然它们也以java.lang开头,但不一定是你想放行的。精确匹配更安全,但维护成本高一些,每新增一个DTO都要记得加。
4.3 使用JDK官方过滤器:ObjectInputFilter是更优解
自定义resolveClass是“手工挡”,而JDK从9开始提供了官方的过滤器机制,对应JEP 290。好消息是这个功能被回溯到了8u121,也就是说很多存量JDK 8服务也能用。JDK 17又通过JEP 415对一批常用于攻击的内部类加了默认限制,明显降低了基础风险。
官方过滤器叫ObjectInputFilter,可以理解为给反序列化过程加一个全局/流级别的过滤回调。它的能力比单纯拦类名更全面,还能限制深度、引用数、数组长度、字节数——这些指标能防住“不加载类但撑爆内存”的拒绝服务攻击。
一种配置方式是JVM参数,全局生效:
-Djdk.serialFilter="java.base/*;com.example.dto.*;maxdepth=20;maxrefs=5000;maxbytes=1048576;!*"这段pattern的含义是:放行java.base模块下所有类,放行com.example.dto包下所有类,最大深度20层、最大引用5000个、最大字节1MB,其他一切类全部拒绝。
另一种方式是在代码里编程设置:
import java.io.ObjectInputFilter; public class SerialFilterInitializer { public static void install() { ObjectInputFilter filter = info -> { if (info.serialClass() != null) { String className = info.serialClass().getName(); if (className.startsWith("java.lang.") || className.startsWith("java.util.") || className.startsWith("com.example.dto.")) { return ObjectInputFilter.Status.ALLOWED; } return ObjectInputFilter.Status.REJECTED; } // serialClass() 为 null 时,代表当前校验的是深度/引用数/字节数等指标 return ObjectInputFilter.Status.UNDECIDED; }; ObjectInputFilter.Config.setSerialFilter(filter); } }注意lambda里的细节:先判断serialClass()是不是null。如果不为null,说明正在解析一个具体的类,这时执行类名白名单;如果为null,说明是在检查深度或资源指标,这种情况下一般返回UNDECIDED,让内置规则继续判断。
关于maxdepth、maxrefs、maxbytes这几个参数,我简单解释一下为什么值得设:
maxdepth:限制对象图的嵌套深度,防止攻击者构造超深对象树导致栈溢出。maxrefs:限制反序列化过程中可出现的引用数量,防止构建大量相互引用对象耗尽JVM。maxbytes:限制输入流的字节数,防止在入口处就发生超大流量消耗。maxarray(如果支持):限制数组长度,防止new int[Integer.MAX_VALUE]这类瞬间申请巨量内存的玩法。
这些参数本质是给反序列化过程设置一个“资源预算”,即使攻击类被放行,他也造不成实质性破坏,因为数据还没解析完就已经超预算了。
4.4 实战过滤器规则:一份可直接抄的模板
如果你用的是JDK 17或更高版本,我建议不要只依赖JVM参数,而是在代码里统一维护一份过滤器配置,方便动态调整。下面是一个偏稳妥的过滤器模板:
public class SerialFilterFactory { /** * 返回一个通用的全局过滤器。 * 规则:放行JDK基础类型 + 本业务DTO,其他全部拒绝。 */ public static ObjectInputFilter createDefaultFilter() { return ObjectInputFilter.Config.createFilter( "java.lang.*;java.util.*;java.time.*;" + "com.example.dto.*;" + "maxdepth=20;maxrefs=5000;maxbytes=1048576;" + "!*" ); } }createFilter能直接把pattern字符串编译成过滤器对象,比自己写lambda省事很多。如果你的业务相对简单,这份模板可以直接推到测试环境验证。
我特别提醒一个容易踩的坑:过滤器pattern的执行顺序是“第一个匹配生效”,不是“最后一个匹配生效”。所以一定要把!*放在最后,作为兜底拒绝项。否则如果把!*放在前面,后面所有放行规则都不会生效。
4.5 过滤器设计别只看RCE,还得防“内存DoS”
很多团队做完类名白名单就觉得万事大吉了,这是不完整的。反序列化攻击不只有远程代码执行这一种形态,还有一种很常见的后果是内存耗尽。攻击者不需要执行任何命令,他只需要构造一个深度巨大、引用巨多的对象图,让JVM在反序列化时疯狂申请内存,几次请求就能把服务拖垮。
这种情况靠类名白名单拦不住,因为攻击者可能只用了你的DTO类,但把集合嵌套了成千上万层。所以前面说的maxdepth、maxrefs、maxbytes绝对不是可选项,而是必选项。
我经历过一个线上事故,就是有人拿着一个只有几百字节的序列化数据,外层是合法DTO,内部却挂了极深的ArrayList嵌套,直接把老年代撑到触发GC停顿。从那以后,我对过滤器的资源限制参数特别敏感。
5. 安全运维:排查、止血与常态防御
5.1 怎么第一时间确认自己是否暴露
如果你接手的是一个老系统,想知道自己有没有反序列化风险,最快的办法是直接搜代码:
- 搜
ObjectInputStream、readObject()、readUnshared()。 - 搜
ObjectOutputStream、writeObject(),看看写出的数据流向哪里。 - 搜
Serializable的类,重点盯那些会在网络传输或存储边界出现的。 - 搜RMI相关配置,比如
Registry、Remote等。
搜索时不仅要看“谁调用了readObject”,还要反推:输入的字节流是从哪来的。如果来源是用户上传的文件、外部请求体、消息队列的消息体,那这个入口就疑似暴露。
如果是线上系统,我建议在网关或服务入口先做流量观察,看看反序列化相关的请求特征。不过别等攻击发生了才做这步,平时就要把入口梳理出来。
5.2 排查与加固:常见问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
上线后直接报ClassNotFoundException | 白名单漏掉了业务依赖的类 | 在本地用同样的过滤器跑一遍回归测试,把缺失类加入白名单 |
| 过滤器设置了但不生效 | JDK版本不支持,或系统属性被覆盖 | 确认JDK 8u121+/9+;检查代码里是否有重复设置 |
| 反序列化中途卡死、内存飙高 | 缺少深度/字节数限制 | 补充maxdepth、maxrefs、maxbytes |
| 老客户端连不上新服务 | 新过滤器把老客户端字节流里的类拦了 | 增加过渡白名单,或改为双通道兼容 |
| 线上只日志报错,影响范围难确认 | 缺少反序列化来源信息 | 在ObjectInputStream外层记录来源IP、类名、时间 |
5.3 应急止血:五步处理法
如果真的收到反序列化相关告警,或者安全扫出问题,我建议按顺序执行以下动作:
- 切流量/断入口:第一时间在网络层切断疑似入口的访问,别让攻击者继续投递payload。
- 启用全局拒绝:用
ObjectInputFilter.Config.setSerialFilter或JVM参数先“只允许白名单、默认拒绝”,哪怕误伤业务也先保住可用性,再逐步恢复。 - 升级依赖与JDK:把可被利用的依赖升到已修复版本,JDK升到有过滤机制支持的版本。
- 拉取日志核对调用链:查反序列化耗时异常、异常堆栈里出现的类名,确认攻击到底打没打通,以及有没有横向移动痕迹。
- 架构整改:把边界上的原生序列化格式替换成安全格式,给入口加上签名校验或身份认证。
这五步里,第一步和第二步是止血,第三步到第五步才是根治。别在止血阶段就开始“优化过滤器策略”,那是应急结束后做的事。
6. 个人经验与几条实在建议
6.1 过滤器设计怎么兼顾可用性:从一次误伤说起
有一回我给某个内部服务配置了白名单过滤器,自测时一切正常,结果一上线就跑出来一堆InvalidClassException。排查了半天,发现是业务代码里一个老接口用了java.util.BitSet,而它不在我的白名单里。那东西本身没风险,但因为它不在白名单里,整个反序列化就断了。
这个案例给我的教训是:白名单过滤上线前,必须做一次覆盖核心链路的全量回归,把真实反序列化会用到的类全部跑一遍,别只测几个主流程。另外可以在过滤器的拒绝分支里把被拒类名打印成WARN日志,上线初期观察一段时间,确认没有“误杀”后再收紧日志级别。
6.2 别把“内网”当安全区
我见过太多团队拿“我们服务只在内网部署”当理由,拒绝做反序列化加固。但真实的攻防情况是:内网的边界远没有你想的那么结实。一个钓鱼邮件、一个开发机跳板,攻击者就能摸进内网,然后横向扫描各种RMI端口、消息队列端口、自定义RPC端口,反序列化漏洞是他们最喜欢碰的“内网横向移动”入口。
所以就算你百分百确定服务不对公网开放,也建议加上过滤器。成本很低,收益却很高。
6.3 最后一条小习惯:新接口一律禁止直接收ObjectInputStream
我在团队里定过一条编码规范:凡是新增接口,一律不允许直接接收ObjectInputStream或byte[]形式的Java序列化数据;必须使用JSON或结构化二进制协议。这条规范执行几年下来,新系统几乎没再出现过反序列化类漏洞。老系统的历史包袱可以慢慢还,但新债不能再添——这个原则很朴素,却比任何一次“事后加固”都管用。
每次看到有人问“为什么我的系统被扫出反序列化漏洞”,我心里都在想同一个问题:你确定要收一个来路不明的字节流吗?如果答案是“我没办法,历史包袱太重”,那就按第4节、第5节的方法先兜底,再逐步往第3节的方向迁移。Java序列化本身不是原罪,真正危险的是我们在不该信任数据的地方选择了信任。先把信任边界划清楚,安全这件事就成了一半。