1. 序列化与反序列化:先搞懂这组基础概念
1.1 一句话理解:对象是如何被“存”和“取”的
聊反序列化漏洞之前,得先把这个名字拆开。什么叫“反序列化”?听上去很拗口,其实你用手机刷短视频的时候,这一套机制已经在背后工作了大半天。
想象一个场景:你在购物软件里加了几件商品到购物车,然后把软件彻底关掉。第二天再打开,购物车里的商品居然还在。这个过程中发生了什么?程序运行的时候,购物车是一个对象,里面有商品列表、数量、价格这些属性。但对象是活在内存里的,内存一断电就全没了。要让购物车“存活”到第二天,得把这堆对象状态转换成一种能够长期保存的格式——比如写进磁盘文件、存进数据库、或者塞进缓存系统。这个过程叫序列化(Serialization)。
等软件再次打开,系统把保存下来的数据重新读出来,按照原来的结构拼装成一个新的对象,恢复到用户看到的样子,这个过程就叫反序列化(Deserialization)。
用一个更直白的类比:序列化就是把你生活中的行李打包成一个标准尺寸的快递箱,上面贴好标签;反序列化就是快递到达目的地后,你拆开箱子,把里面的东西一件件放回它们应该在的位置。
技术定义上,序列化就是把内存中的对象状态转换为可存储或可传输的字节序列;反序列化则是反向过程,把字节序列还原成内存中的对象。这个机制本身是中立且高效的,问题恰恰出在“拆箱”的那一刻。
1.2 为什么需要序列化:三个典型应用场景
反序列化之所以无处不在,是因为软件开发中很多场景都绕不开“对象落地”这个需求。我自己在项目里遇到最多的,是下面三个场景。
第一个场景:跨网络传输。前后端交互时,前端传来的是JSON字符串,后端框架帮你把JSON自动还原成对象,这个过程就是反序列化。微服务之间调用接口,A服务把一个对象序列化成字节流发给B服务,B服务再反序列化还原,本质也是同一回事。
第二个场景:状态持久化。比如你玩单机游戏存档,角色等级、背包物品、任务进度会被序列化后存入本地文件;或者一个工作流引擎需要把某个长时间运行的任务状态保存到数据库,等条件满足时再恢复执行。
第三个场景:缓存与消息队列。Redis里存的不只是普通字符串,很多时候存的就是序列化后的对象;消息队列里投递的消息体,也可能是对象的序列化结果。消费端拿到消息后做反序列化,再交给业务代码处理。
这三个场景听起来都很正常,问题在于:只要存在数据的序列化和反序列化,就存在“攻击者控制传入数据”的可能。一旦序列化数据可以被攻击者构造和篡改,反序列化过程就可能脱离开发者预期,变成一段被操控的执行入口。
1.3 主流语言的实现差异:不只是 Java 的事
很多人一听到“反序列化漏洞”就想到Java,这确实有道理——Java生态里反序列化用得最广,历史上爆出的漏洞也最多。但这个概念不是Java专属,每种主流语言都有自己的序列化机制,也就都有对应的攻击面。
我整理了一张表,横向看一下各语言的实现差异:
| 语言 | 常见序列化机制 | 典型使用场景 | 漏洞曝光度 |
|---|---|---|---|
| Java | ObjectOutputStream/ObjectInputStream、JSON、XML | 分布式系统、RPC框架、缓存落地 | 极高,长期是重灾区 |
| PHP | serialize()/unserialize() | Session存储、cookie、缓存 | 较高,常见于框架级漏洞 |
| Python | pickle/marshal | 进程间通信、缓存、模型持久化 | 中等,但利用难度往往更低 |
| Ruby | Marshal/JSON | 分布式任务、Session | 中等 |
| .NET | BinaryFormatter/DataContractSerializer | 桌面应用、微服务 | 较高,多见于Windows生态 |
| Go | encoding/json/gob | 微服务通讯 | 相对较低,原因后面会说 |
这里先留一个钩子:Go语言也有完整的序列化机制,为什么反序列化漏洞曝光率明显低于Java?答案不复杂——Go的反射机制没有Java那么“动态”,很多在Java里能完成的骚操作(比如动态加载任意类并调用任意方法)在Go里很别扭,攻击面自然窄很多。但这不是说Go就绝对安全,“理解了原理再看语言”才是正道。
2. 反序列化漏洞的成因:坏味道就出在“取”这一步
2.1 核心根源:把输入的字节流当成了可信代码
反序列化漏洞的根源可以浓缩成一句话:反序列化过程中,代码把外部输入的数据当作了“可执行的逻辑”来处理。
正常数据流里,序列化字节流应该只包含“对象的状态”——也就是有哪些属性、各自什么值。但某些序列化格式在设计时,除了能保存状态,还能携带“类的元信息”和“触发的行为”。Java序列化就很有代表性,它的字节流里不光有类名、字段名、字段值,还包含一个关键设计:反序列化时会自动调用类里的readObject或readResolve方法。
这意味着什么?我来打个比方。你签收快递时,正常情况下流程是“打开箱子→核对清单→把物品归位”。但如果某个快递公司有个不作死的规定:“签收时,快递员必须先按下箱子上的红色按钮,不管你是谁”。而这个按钮的功能不一定。有些箱子按下会播放音乐(无害),有些箱子按下会开门(危险),最麻烦的是,这枚按钮的具体功能是发件人决定的。攻击者只需要精心构造一个“带按钮的箱子”,也就是一份恶意序列化字节流,就能在“签收”这个合法流程中执行按钮对应的危险动作。
反序列化漏洞的本质,就是这个“签收环节的自动按钮触发机制”被利用了。程序明确写着“我只想取回购物车对象”,但反序列化器却主动执行了字节流里记录的行为。开发者完全没有想过输入方可能是个恶意攻击者。
2.2 Java反序列化的完整调用链
Java的反序列化流程值得细看,因为这是理解整类漏洞的基石。我们把调用链一步步拆开,看会发生什么:
- 入口函数调用:业务代码里主动调用了
ObjectInputStream.readObject()方法,触发反序列化流程。 - 读取头部信息:反序列化器读取流开头的魔术字节
AC ED 00 05(Java序列化协议固定头),确认这是一个标准Java序列化流。 - 解析类描述符:读取
TC_OBJECT标记和类描述符,其中包含类名、serialVersionUID、字段数量、字段类型等信息。反序列化器根据类名进行类加载。 - 创建对象实例:通过反射调用构造函数创建对象。注意,这一步走的是最底层的内存分配机制,普通构造函数并不一定会执行。
- 填充字段数据:从字节流中按顺序读取每个字段的名称和值,对应填入对象。
- 回调钩子方法:如果类里定义了
readObject、readResolve、validateObject等方法,框架会按约定自动调用。这些方法的代码,等于“反序列化过程中的自定义逻辑”。 - 返回对象:完成整个还原过程,把对象交给业务代码。
整个链条里,第6步是攻击价值的核心。如果一个类的readObject方法内部写了危险操作——比如根据某个字段值去执行系统命令、根据某个字段值去发起网络请求——攻击者就能通过在字节流中精心构造字段值,让危险动作在自己指定的指令下执行。
更可怕的是,Java反序列化天然支持对象图引用。字节流中可以用TC_REFERENCE标记表示“这个位置引用前面已经反序列化的某个对象”,配合readObject的递归机制,攻击者可以构造出极复杂的嵌套结构,让多个类的钩子方法按特定顺序依次被触发。这串被精心设计的对象链,就是后面要说的POP链与Gadget链。
2.3 反射机制:为什么这里如此危险
反射是Java非常强大的特性,它允许程序在运行时动态获取类的信息、动态创建对象、动态调用方法。正常情况下,框架大量使用反射来实现依赖注入、ORM映射、动态代理。但反射是一把双刃剑——反序列化也大量依赖反射。
危险组合就此形成:
- 反序列化器有“能力”动态加载任意类
- 反射有“能力”调用任意类的任意方法
- 字节流中有“方法名和方法参数”作为指令
三者叠加,等于攻击者拿到了一个远程的“类方法遥控器”。只要项目依赖的某个库中存在一个带有危险方法且满足readObject触发条件的类,攻击者就能把这个类变成武器。
为什么Go语言没那么容易出这类问题?因为Go的encoding/json虽然也通过反射填充数据,但填充的只是字段值,它不会在反序列化时自动调用你自定义的、名字特定的钩子方法,也没有“序列化数据里写什么类就加载什么类”的开放机制。攻击者能控制的东西被压缩到了字段值的维度,攻击链的复杂度大幅上升。
3. 几种典型坏味道:一眼识别危险的反序列化写法
3.1 Java ObjectInputStream 直接使用
先看一段典型的高危代码写法:
// 危险示例:直接对网络输入流进行反序列化 public void unsafeDeserialize(ServletInputStream inputStream) { try { ObjectInputStream ois = new ObjectInputStream(inputStream); Object obj = ois.readObject(); // 业务逻辑继续处理 obj } catch (Exception e) { // 忽略异常 } }这段代码有两个致命点。第一,ObjectInputStream直接接收外部传入的原始字节流,没有做任何校验、过滤、隔离。第二,readObject()返回的是Object,代码随后直接用强转或反射去处理这个对象。攻击者只要把构造好的恶意字节流灌进接口,就能触发整个反序列化链路。
我见过的很多案例,漏洞根因倒不是开发者故意写危险代码,而是框架把“接收请求体→反序列化→绑定参数→执行业务”这一连串操作做得太透明,开发者根本没有感知到这里面藏着一个反序列化环节。等安全通告出来,一查依赖树,某个老版本组件在第三方库深处默默做了全套反序列化操作。
识别这类风险的思路很简单:全局搜一下ObjectInputStream、readObject、XMLDecoder这几个关键词,出现的位置都要逐一确认输入是否可以由外部控制。
3.2 自定义 readObject 的隐患
比直接调用ObjectInputStream更隐蔽的,是某个类自己定义了readObject方法,而且实现里有问题。
public class UserProfile implements Serializable { private static final long serialVersionUID = 1L; private String username; private String avatarUrl; private String callbackUrl; // 自定义反序列化逻辑 private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 反序列化后自动执行某个操作 if (this.callbackUrl != null) { // 假设这里会发起一个HTTP请求 HttpClient.sendCallback(this.callbackUrl); } } }这段代码的坏味道一目了然:readObject里写了业务逻辑,而且业务逻辑还会根据字节流里的字段值发起外部请求。攻击者只需要构造callbackUrl为恶意地址,反序列化还没结束,恶意请求就已经发出去了。
需要澄清一点:并不是“类里有readObject就等于有漏洞”,而是“readObject里的动作可以由外部输入驱动,且这个动作的边界是危险的”。如果只是做字段合法性校验,那反而是加分项。这里的关键区分是:反序列化钩子方法里的操作,目的一定不能是“执行”而应是“校验”。
3.3 PHP 与 Python 的同类问题
PHP的反序列化入口是unserialize(),配合对象的魔术方法,比如__wakeup()、__destruct()、__toString(),攻击链可以串得很长。PHP里最经典的几个框架级反序列化漏洞,本质都是:用户可控数据进入了unserialize(),然后通过魔术方法触发了危险类的方法。
Python这边则是pickle模块。pickle的协议本质上是一种“序列化后的指令码”,它不光能还原对象状态,甚至能直接执行一段字节码指令。MIT的官方文档里明确写着“pickle模块不能用于不可信数据”——这个警告不是危言耸听,而是字面意义上的进程级风险。
# Python危险示例:直接从请求中反序列化pickle数据 import pickle import base64 def process_data(encoded): raw = base64.b64decode(encoded) obj = pickle.loads(raw) # 危险!!! return obj这里为什么危险到极点?因为pickle.loads不仅能创建对象,它还能执行被序列化进去的任意代码。攻击者只需要严格控制一个pickle数据输出,服务器就完全沦陷了。和Java相比,Python这一头的利用门槛反而更低,payload短、编写直观、性质是纯指令执行。
PHP、Python、Ruby的原理和Java有差异,但压迫感是相近的:反序列化入口一旦开放给不可信输入,就等于给外部提供了一个代码执行通道。
4. 影响范围:从“异常崩溃”到“远程代码执行”
4.1 反序列化漏洞能造成哪些后果
很多人把反序列化漏洞等同于“直接用漏洞实现远程代码执行”,这个认知还是浅了一层。反序列化漏洞能不能打出最高伤害,取决于目标环境里有哪些可用的“玩具”(也就是可触发的类),也取决于攻击者的目的。它可能造成的后果从轻到重排列如下:
| 危害等级 | 后果 | 说明 |
|---|---|---|
| 低 | 拒绝服务 | 构造超长对象图、循环引用,导致反序列化过程卡死或OOM |
| 中 | 绕过认证/越权 | 篡改序列化后的认证对象,修改角色、权限字段 |
| 中高 | 数据篡改/任意文件读写 | 触发类读写文件的危险方法,覆盖配置文件 |
| 高 | 远程代码执行 | 找到完整的Gadget链,在目标机器上执行系统命令 |
| 高危 | 内网横向渗透 | 以目标服务为跳板,继续攻击内网其他系统 |
实际防御评估时,即使当前环境没有现成的完整Gadget链,也不能把漏洞等级评定为“低”。因为攻击面是动态的——今天没有的利用链,明天升级一个依赖库可能就有了。所以对反序列化漏洞的评估,我建议以“是否存在不可信数据进入反序列化入口”作为一票否决项,而不是“当前能否弹shell”来判断严重性。
4.2 POP链与Gadget:别把概念搞混
聊反序列化漏洞时,Gadget和POP链这两个概念经常被混在一起,但它们是两个层次的东西:
- Gadget:单个满足特定条件的类或方法。比如某个类里有
readObject,里面调用了某个能执行命令的方法,这个类就是一个Gadget。单个Gadget往往不够用,还需要拼接。 - POP链(Property-Oriented Programming):一串Gadget有序串起来,形成一个达到完整攻击效果的调用链。它面向的是“属性”和“反序列化钩子函数”组成的操作序列,而不是汇编指令层面的ROP链。
用工程类比来理解:Gadget是零件,POP链是装配完成的机器。攻击者拿到一个库,先在里面翻找可利用的零件,再想办法按照正确的顺序组装起来。这也是为什么反序列化漏洞“看似一个洞、实操看环境”——漏洞入口可能到处都有,但能在目标环境里拼出完整POP链,需要依赖条件匹配。
对写代码的开发者而言,理解Gadget概念有一个实际意义:你依赖的第三方库里某个类被列为了CVE的Gadget,不意味着你的业务代码“写了漏洞”,但意味着你的依赖树给攻击者提供了弹药。这就是为什么供应链治理和依赖升级,在反序列化防护里权重极高。
4.3 真实环境中如何评估风险等级
在真实项目中评估反序列化漏洞,我没有一上来就找POP链,而是先回答三个问题:
第一,这个反序列化入口暴露在哪里?是内网微服务之间调用才用到,还是公网HTTP接口直接可触达?暴露面大小直接决定攻击者能否实际发送恶意字节流。
第二,入口有没有前置校验?比如有没有做签名校验、类白名单、流量层面的特征过滤。有校验和无校验是天壤之别。
第三,目标环境依赖树里有多少候选Gadget?我一般会扫一遍依赖清单,重点查那些常出Gadget的库,比如各类JSON处理库、集合类工具库、RPC框架的底层依赖,确认版本是否有已知漏洞公告。
综合这三个维度,就可以把评级落在“高/中/低”的对应区间。实际做安全评估时,我遇到过入口在公网、没有任何校验、依赖树里还有多个历史Gadget库的情况,这种场景基本可以直接判定为紧急修复项——不需要等攻击者真的打出一个完整利用链,风险优先级就已经板上钉钉了。
5. 修复与防御实践:五个动作堵住风险
5.1 方案一:白名单校验输入类
最可靠且实现成本可控的防御动作,是加反序列化类白名单。Java的ObjectInputStream支持重写resolveClass方法,在里面做校验:
public class SafeObjectInputStream extends ObjectInputStream { private static final Set<String> ALLOWED_CLASSES = new HashSet<>(Arrays.asList( "com.example.dto.UserProfile", "com.example.dto.OrderInfo", "java.util.ArrayList", "java.util.HashMap" )); @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); // 注意:只校验精确类名是不够的,数组类、基础类型也要覆盖 if (!ALLOWED_CLASSES.contains(className)) { throw new InvalidClassException("Unexpected deserialized class", className); } return super.resolveClass(desc); } }白名单方案为什么首选?因为它把“程序可以加载哪些类”缩到一个极小的范围内,攻击者哪怕手握一堆Gadget,只要类根本不被允许加载,链条就断在第一步。黑名单方案类似“策划封锁了某几个危险行为”,但Java生态类库庞大,你永远不知道哪个角落还有漏网的Gadget,黑名单本质上是打地鼠。
需要注意,白名单本身也有边界:如果你反序列化的DTO里嵌套了复杂对象,比如ArrayList<UserProfile>,那白名单里需要包含java.util.ArrayList、数组相关类型等。我一贯的做法是,在测试环境用日志把所有反序列化出现的类名打出来,跑一轮完整业务回归,再根据真实使用的类集合收敛白名单,避免上线后才发现漏掉某个内部类的尴尬。
5.2 方案二:用更安全的序列化格式
如果技术改造空间允许,尽量把反序列化入口从“原生序列化协议”迁移到跨语言的安全序列化格式上。
- JSON + 数据绑定:比如
Jackson、Gson、Fastjson(需修复版本)。JSON本质是纯数据,没有代码加载能力,只要不开启多态反序列化特性,攻击者无法指定“加载哪个类”。 - Protobuf / Thrift / Avro:使用IDL定义数据结构,反序列化时严格按照schema解析,未知字段直接丢弃。这类格式天然免疫“任意类加载”的问题。
- 手动安全解码:收到字节流后,先做一层轻量变换(如Base64解包),再落入固定结构体的解析逻辑。
很多历史项目不是不想迁,是改造压力大——关联了缓存、消息、RPC等多个环节。我的建议是:优先迁移的永远是暴露在公网的入口,内部链路可以分批处理。迁移完成后,老的反序列化入口要彻底下线,而不是留着兼容“可能还在用的旧数据”。
5.3 方案三:签名、加固与升级
还有一些常规但重要的事,做齐了能显著抬高攻击门槛:
第一,序列化数据加签名。服务端序列化数据时,追加一段HMAC签名(用密钥对原始字节流计算),反序列化前先验证签名再处理。这样做可以让伪造字节流的攻击者无从下手,因为不知道密钥就无法生成合法签名。这个方案尤其适合服务端生成、客户端携带回传的场景。
第二,依赖库持续升级。反序列化漏洞的一大半来源于第三方库的历史Gadget。我见过太多系统用的还是三年前的老版本,里面躺着一排已修复的漏洞类。定期做依赖版本审计,关注安全公告,凡是涉及反序列化的组件,有安全更新就不要拖延。
第三,最小权限运行。即便攻击者真的打通了利用链,如果服务是以普通用户权限运行的,能造成的破坏依然可控。反过来,大量服务以root或Administrator跑,等于给攻击者送上最高权限通道。运行服务的系统账号权限,该收敛就收敛。
6. 常见问题与排查思路:实战中的经验手册
6.1 快速识别你的系统是否受影响
有人会问:“我怎么知道自己的项目有没有反序列化漏洞?”我给一套快速识别的自查顺序:
- 搜索反序列化入口:在代码里全局搜
ObjectInputStream、readObject、unserialize、pickle.loads、BinaryFormatter等关键字,逐一确认它们的数据来源。 - 追踪数据流路径:从可控入口(HTTP参数、消息队列、文件上传)出发,看能不能一路流到反序列化位置。数据流的每一环,攻击者能否影响?
- 检查校验层:反序列化前有没有做类白名单校验?有没有数据签名校验?有没有输出端Web应用防火墙之类的流量层过滤?
- 分析依赖清单:拉取完整的依赖树,检查涉及序列化/反序列化组件的版本,看看有没有与已知漏洞公告绑定的CVE。
如果上述四个问题里有任何一个答案是“是”或“不清楚”,就要进入下一步人工核验。这里我非常建议直接动手画一张简单的数据流图——不需要专门的工具,白板或者笔记软件都行——每一个“外部输入点”都标出来,再看它能不能到这些危险收敛点。
6.2 典型场景排查记录
举一个实际排查过的案例,背景是某内部系统(我们称它为模拟项目X)的接口偶发超时,后来发现日志里出现了大量异常,定位到反序列化环节。
排查过程的几个关键步骤:
场景A:接口偶发超时,日志显示大量StreamCorruptedException。第一反应是客户端写入的字节流格式与预期不符。检查后发现该接口近期接了新的前端版本,新前端发送的数据格式变了,但后端还是按老版本协议反序列化。这个问题本质是序列化协议版本管理失控,处理方式是统一数据格式并加了版本号字段。
场景B:同样是反序列化异常,但报错是ClassNotFoundException。排查下来,是某个模块升级后类名变了,但缓存里还躺着旧类名序列化数据。解决方式并不只是清缓存,还要增加一个兜底逻辑:反序列化遇到未知类时丢弃并记录告警,而不是让整个请求被异常打断。
场景C:最惊险的一次,是在调试日志里发现某个内部接口收到了一段异常长的反序列化请求体。检查后确认接口没有任何鉴权或者前置校验。虽然依赖树里当时没找到完整的已知Gadget链,但这类风险不能赌“没有链”。后续修复就是两个动作:接口加鉴权 + 接入类白名单校验,绝不让不可信数据裸奔进反序列化器。
三个场景的经验沉淀下来,总结成一句话:反序列化问题不必然是安全漏洞,但反序列化异常里极大概率藏着安全风险线索。日志里的异常信息,往往是发现漏洞入口和利用尝试的第一现场。
6.3 一条自查清单和防范心得
最后给一份可以在项目里直接使用的自查清单:
| 检查项 | 状态 | 备注 |
|---|---|---|
| 代码中所有反序列化入口已识别并登记 | 未开始 | 搜索关键字逐一确认 |
| 每个入口的数据来源已确认可控或不可信 | 待评估 | 不可信输入必须有防护 |
| 反序列化前执行了类白名单校验 | 未开始 | 首选方案,成本可控 |
| 序列化数据在传输前加了签名校验 | 可选 | 服务端生成数据场景推荐 |
| 已排查第三方依赖中的历史Gadget库版本 | 待执行 | 升级到已修复版本 |
| 服务运行账号为非特权账号 | 待确认 | 最基本的纵深防御 |
| 反序列化异常有完整的日志记录 | 未开始 | 攻击尝试往往藏在日志里 |
这些动作里,最容易产生“虚假安全感”的,是以为升级了某个序列化库的版本就万事大吉。实际上反序列化攻击的核心是“恶意输入数据进入不可信的反序列化逻辑”,版本升级只是堵住已知漏洞,入口防护才是治本。白名单校验与数据签名这两件事,不论框架怎么升级、依赖怎么更换,都能持续发挥防护价值。
最后说几句实操体会
反序列化漏洞研究到现在,行业里出的文章、工具、利用链已经非常多,但我的经验是:学习这类安全问题,最容易踩的坑不是“看不懂利用链”,而是过度聚焦攻击细节、忽略了防御者视角的基本功。新手建议先花时间把Java序列化的字节流规格真正读一遍,或者把PHP魔术方法的触发条件列成表,这比背诵十个CVE编号更能建立直觉。
真正上手项目时,我的习惯是先做“入口普查”,再谈加固方案——因为很多系统根本不知道自己有多少个反序列化入口。等你把代码里所有readObject、unserialize位置标完、画出数据流图,风险大概心里就有数了。这篇文章是第一期的内容,我把注意力放在了“原理认知”这个地基上。下一期我打算挑一条具体的利用链,从字节流结构开始一步步走完整个触发过程,把Gadget拼接的细节捋清楚。如果你在排查过程中遇到了什么反序列化的疑难场景,欢迎带着现场现象和交流讨论,这类问题最怕纸上谈兵,实际信息越多,分析越有价值。