☰
深入理解反序列化漏洞:从原理到实战排查与修复
2026/10/17 15:16:15 网站建设 项目流程

不用把微信公众号里的长截图搬过来。反序列化这个坑,几乎每个做Web开发和安全测试的人都听说过,但真正能把反序列化安全漏洞从头到尾讲清楚的人不多。它不像SQL注入那样直白,也不像XSS那样容易看到弹窗,它藏在一段看似没什么异常的二进制数据或字符串里,却能直接让服务器执行攻击者想要的代码。这篇文章我打算把这条链路彻底打通:它是什么、为什么危险、攻击面在哪里、实战中怎么测、最后怎么修。

文章适读人群比较广:后端开发、运维、安全测试、CTF选手都能从里面拿走自己需要的东西。开发能知道该在哪些地方躲开坑,安全测试能拿到一套可落地的测试思路,运维能明白为什么光靠WAF拦不住这类攻击。我尽量用大白话讲原理,再给实际操作步骤,不带水分。

1. 反序列化漏洞到底是什么:从一次打包拆包说起

1.1 什么是序列化和反序列化

要理解漏洞,先得理解它所在的机制。序列化就是把内存里的对象转换成一段可以存储或传输的字节流,反序列化就是把这个字节流重新变回对象。你可以把它想象成寄快递:序列化就是把你要寄的东西装进箱子、填好快递单;反序列化就是收件人拆开箱子、把东西取出来恢复原样。寄快递本身没毛病,几乎所有正经电商平台都在干这事。

在Web应用里,序列化数据的用途非常广泛。Session要存对象,得序列化后落盘或写进Redis;RPC跨服务调用,对象得序列化成字节流在网络上跑;缓存系统里存复杂对象,也得先序列化;还有不少框架把对象序列化后塞进Cookie、隐藏表单字段、请求参数里,方便下次请求直接恢复状态。这么说吧,只要一个系统需要跨请求、跨进程保存“对象”,它就有可能用上序列化。

序列化有各种格式。同一门语言里可能有好几种序列化方案,跨语言场景下又有一堆通用格式。但安全问题的核心不在于“格式长什么样”,而在于“反序列化时到底发生了什么”。

1.2 为什么反序列化比普通参数解析更危险

普通参数解析,比如从表单里取一个字符串,它不会凭空创建出某个类的实例。反序列化不一样,它本身就是“按照字节流里的描述,把对象创建出来”的过程。问题就来了:字节流里不仅能描述对象的属性值,很多时候还能描述对象的类名,甚至触发对象生命周期里的特定方法。

你想想,如果你能拿一串数据让对方服务器反序列化,这些数据可能包含了一个“类名”,而目标环境里恰好有某个类,一旦实例化就会执行一些危险操作,比如发起网络请求、读写文件、执行命令。服务器等于照着你的图纸盖了一栋房子,盖完还自己住了进去。这就是反序列化漏洞的恐怖之处——它经常能从一次普通的数据传输直接变成远程代码执行,也就是RCE。

传统注入攻击往往需要攻击者猜出后端代码逻辑,反序列化则更像是给了一套“半自动拼装积木”,目标环境里有现成的积木块(各种类库),攻击者只需要找到能拼在一起的那几块就行。这个特性让反序列化漏洞的利用门槛出奇地低,尤其是当你手里握着社区沉淀出来的利用链时。

1.3 一个容易混淆的边界:反序列化漏洞与命令注入的区别

很多人会把反序列化漏洞和命令注入混在一起,觉得都是“执行了攻击者给的命令”,但两者差别很大。

对比项命令注入反序列化漏洞
直接入口系统命令拼接,通常是字符串参数序列化字节流/字符串中的类描述与属性
触发方式命令解释器解析拼接结果反序列化机制自动创建对象、触发方法
利用前提存在拼接点且未过滤存在反序列化操作且未校验类
典型危害命令执行、文件读取RCE、拒绝服务、任意文件操作、SSRF
防御重点输入过滤、参数化、白名单避免反序列化不可信数据、类白名单加固

简单说,命令注入是你往已经画好的执行路径里塞参数,反序列化是你能重新定义“该执行哪条路径”,而且路径的选择权往往比你想象的大得多。这也就是为什么反序列化漏洞一旦被坐实,基本就是严重漏洞。

2. 攻击面与触发链路:反序列化漏洞到底藏在哪些环节

2.1 业务里的常见入口点:从Cookie到RPC再到消息队列

反序列化漏洞不会自己跑到你面前,它需要有一个“反序列化操作”作为前提。我盘点一下实际渗透测试里容易碰到反序列化的位置,这些点开发自己也可以当成自查清单。

第一类是HTTP传输层。很多Java应用会把对象序列化后做Base64编码,放进Cookie、URL参数或者隐藏字段里。典型的是登录后的“记住我”功能、用户偏好设置、搜索条件的回显场景。只要服务端拿到这段字符串会反序列化还原对象,这就是一个潜在入口。

第二类是会话存储。Session内容序列化后写入文件、Redis、数据库,是很多框架的默认做法。如果攻击者能控制Session里存的对象,或者能篡改Redis里的Session数据,等应用重新反序列化Session的时候,就会触发问题。

第三类是RPC和分布式调用。跨语言RPC框架、服务注册中心、负载均衡的健康检查接口,常常用Java原生序列化、XML序列化、Hessian这类协议传对象。这类接口因为“看起来不是给人用的”,经常没有做鉴权,实际是最容易被打的点。

第四类是消息队列。一些MQ系统在消费消息时会把消息体反序列化成对象。如果消息队列没有鉴权,或者消息由外部系统直接投递,攻击者就能把恶意对象伪装成一条普通消息,消费者一接就中招。

第五类是文件上传与导入。导入导出用了Java对象流、PHP的session文件、Python pickle等格式,本质上也是上传一个序列化对象,比传图片危险得多。

2.2 语言特性与危险函数:PHP、Java、Python的触发机制差异

不同语言对反序列化的实现差别很大,但核心都是“哪一步会执行代码”。

语言常见反序列化入口函数触发点实战关注点
PHPunserialize()__wakeup、__destruct、__toString反序列化字符串特征明显,利用链多在框架公共库
JavaObjectInputStream.readObject()readObject、readResolve、readExternal需找Gadget链,依赖目标ClassPath
Pythonpickle.loads()reduce、setstate利用链构造简单,但入口相对少
.NETBinaryFormatter.Deserialize()、Json.NET等OnDeserialized、ISerializable程序集依赖、版本敏感

拿PHP举例,unserialize()拿到字符串后会根据字符串里写的类名去创建对象,创建完对象后如果类里定义了__wakeup方法,就会自动调用。对象被销毁时还会调用__destruct。你不需要直接调用危险函数,只要目标类里有危险动作落在这些魔术方法里,配合参数里的属性值,一起穿成链。

Java的ObjectInputStream.readObject()在反序列化时会调用对象的readObject方法,很多类的readObject内部方法会一步步还原属性,期间可能调用readObject里写死的逻辑,包括读写文件、调用某些对象方法。攻击者要做的就是把一个类对象里的属性值“精心填好”,让readObject在还原过程中滑向危险操作。

Python的pickle更直接,pickle协议里有一种操作码,会在反序列化时调用指定函数,比如eval、exec、os.system。构造一个pickle对象,往里面塞一个__reduce__,目标服务器loads一执行,命令就跑了。因此Python侧的反序列化利用链构造相对简单,也更依赖开发人员主动避开pickle。

2.3 从“反序列化”到“代码执行”:攻击链是怎么串起来的

光有一个反序列化入口还不够,攻击者需要把“创建对象”这件事,变成“执行命令”。这个过程在安全圈叫Gadget链,你可以理解成一套“多米诺骨牌”。

第一块骨牌是入口触发点,比如readObject或unserialize的魔术方法。第二块骨牌是某个类库里的通用方法,这些方法可能会调用对象的其他方法,比如Bean类里常见的set方法、toString方法、hashCode方法。第三块骨牌是能执行危险操作的类,比如能调用系统命令的ProcessBuilder,能远程加载类或发起网络请求的URL类,能写文件的高阶类。

攻击者要做的是把这些类按顺序排列,让第一块的属性值触发第二块,第二块再触发第三块,最终落到危险操作上。整个链条里的类必须都存在目标服务的ClassPath或已加载类里,这也是为什么同一个利用链在不同版本、不同依赖环境下经常失效。

2.4 为什么这个漏洞经常能绕过WAF

WAF对反序列化攻击的拦截一直很头疼。原因有几个:序列化数据本身可能是二进制、可能是Base64、可能是压缩后的字节流,WAF要完整解码并还原语义才能识别。就算解码了,里面是类名、方法名、参数值混在一起,不像SQL注入有明确的危险关键字,很多攻击链中出现的类名从安全性看非常中性,比如“HashMap”“PriorityQueue”“TiedMapEntry”,WAF如果对这些常见类名直接拦截,误报率会高到没法用。

更麻烦的是,同一个利用链可以加冗余字节、改编码、调整属性顺序,序列化格式仍然有效。WAF的规则库很难覆盖这种多变形式。所以WAF能挡住一部分低水平的扫描,但真遇到有经验的测试方基本形同虚设,自己代码层面不解决,就永远堵不住。

3. 实战测试指南:从识别数据到稳定验证

3.1 测试之前的三个前置条件:授权、隔离、记录

这句话我必须放在最前面:任何反序列化漏洞测试,都必须在自己有明确授权的目标上进行。所谓授权不光是“公司让我测这个系统”,最好是书面的测试范围确认,里面写明允许做哪些测试、不允许做哪些。反序列化利用一旦触发,可能直接影响业务进程、数据库连接甚至整台机器,比普通注入测试的破坏性大得多。

隔离环境也很重要。有条件的话先在本地搭一个与目标环境相同依赖版本的系统,把利用链跑通,再决定要不要在真实环境做温和验证。真实环境的验证尽量选择不破坏数据的方案,比如通过一个单独加日志的软链确认对象创建成功,而不是直接执行drop database之类的操作。

测试全程要记录。抓包原文、修改后的字节、发送时刻、目标返回、关键阶段截图,都要留档。这既是对目标系统负责,也是对自己负责。反序列化测试经常涉及跨多个请求的利用链,没有记录,排查起来非常痛苦。

3.2 第一步,找到不可信的序列化数据输入

摸清系统功能后,优先收集所有看起来“像序列化数据”的输入。怎么识别?最笨但最有效的方法是看字符串特征。

序列化类型特征示例常见的可见形态
Java原生序列化Base64以rO0AB开头,十六进制是ACED0005Cookie、参数、RPC消息体
PHP序列化以O:数字:"类名"或a:数字:{开头Cookie、POST参数、Session
Python pickleBase64以gASV开头,或人眼可见英文乱码Redis值、消息内容
.NET BinaryFormatterBase64以AAEAAAD开头视图状态、参数

看到这类特征,先不要急着改数据。先确认服务端是否会真的反序列化。可以观察不同输入下返回是否有差异,比如把一个合法序列化字符串随便改坏一个字节,正常解析的接口会出现解析异常、报错页或日志,完全不解析的接口则无所谓。这一步确认操作存在后,才能继续往下走。

3.3 第二步,验证反序列化是否真实发生的静态判断

如果能拿到目标应用的部分代码或依赖清单,就直接从静态层面找证据。搜索反序列化操作的关键函数名:Java里是ObjectInputStream、readObject、XMLDecoder、Hessian、Kryo;PHP里是无处不在的unserialize;Python里是pickle、cPickle、marshal。再看这些操作的输入来源,如果输入直接来自用户可控的数据,比如HTTP参数、请求头、上传文件内容,基本就可以确定存在可控反序列化点。

没有源码也没关系。观察报错信息是个好办法。很多框架在反序列化失败时会返回带类名、栈追踪的详细错误页,或者把异常信息写入响应头。再配合行为差异:向同一个入口发送格式正确和格式损坏的序列化数据,如果服务端处理时间、响应内容、后续业务走向有明显变化,说明反序列化确实被触发。

3.4 第三步,动态验证:用最小代价确认可控风险

到了动态验证阶段,我的建议是循序渐进,先做破坏性触发,再做时间延迟验证,最后才考虑构造完整利用链。

破坏性触发最简单:把合法序列化对象里的类名改成不存在的类,或者把属性类型写错,然后看目标是否报错。报错等于告诉你“它认真解析了你的数据”。有些框架还会把错误类名直接打印出来,这样连环境里有什么类都能猜个大概。

时间延迟验证适合黑盒场景。某些利用链在反序列化时会执行一些耗时操作,比如发起一个到指定端口的网络连接,连接超时时间就是可控的时间窗。你可以构造一个只包含网络请求的软链,让目标在反序列化时去连接你自己的监听端口,通过“端口有没有收到连接”和“响应是否变慢”来确认代码确实被执行了。这个方法不会直接影响业务数据,是比较温和的验证手段。

动态验证时还要注意,不要一上来就把目标往完全利用方向推。真实业务环境里,很多反序列化点会在解析后马上进行二次校验,数据格式一变,后面一步直接返回错误,造成你判断错误。所以每次改数据前想清楚“你希望这次改动产生什么可观察的变化”,记录好再发送。

3.5 第四步,构建测试利用链时要注意的坑

如果你的目标是证明漏洞可以利用到RCE,构建完整利用链时最容易踩坑的是这几个方面。

第一是版本兼容。利用链里用到的类和目标环境的类库版本必须匹配。同一个类在不同版本里方法签名可能有变化,一旦不匹配,反序列化会在中途抛异常,链子就断了。第二是类加载路径。Java的ClassLoader不是全量加载所有类的,有些类只在某种部署方式下存在。本地复现环境尽量贴近目标中间件版本,否则就是在办公室里做实验、野外考证。第三是编码层处理。反序列化数据经过多层传输时可能被重新编码、压缩或URL解码,任何一层没考虑到位,目标拿到的字节都和你构造的不一样。

业界有个实操经验:构造利用链前,先用工具查看目标依赖清单和版本号;构造链后,先在本地同版本环境完整跑通;上线验证时,再加一层“是否成功创建对象的日志/延迟”观察点。这套流程虽然慢,但能把失败率压到最低。

4. 工具与案例复盘:一次真实的授权测试记录

4.1 常用工具链的使用心得

反序列化测试离不开工具,但工具不是万能的。Java侧常用的ysoserial,PHP侧有PHPGGC,这两者本质是“利用链生成器”,目标依赖和生成器内置的链版本对不上,生成器会罢工。Python侧更多是自己写pickle结构,很多脚本框架都能轻松生成。使用工具前,建议先去理解一个简单的链是怎么组成的,再去用工具,不然生成一段乱码数据发过去,目标没反应,你根本不知道是该调版本、换链还是找入口。

还有一类思路是“半自动扫描”:把一段序列化数据里的类名替换成目标常见依赖里的类名,再发往多个入口,通过响应差异批量识别潜在反序列化点。这类扫描要注意控制发包频率,别把自己的扫描器打成年久失修的漏洞扫描器,更不要给目标造成过大压力。

4.2 案例一:一个Java后台登录接口的测试复盘

某次授权测试,目标是一个内部管理系统,登录接口有个“记住我”功能,返回的Cookie是一长串Base64,结尾带着明显的等号。拿到手先解码,十六进制开头就是AC ED 00 05,典型的Java原生序列化头。再往后翻,能看到几个类名,里面居然有个业务自研的UserPreference类,序列化数据里存的是用户界面偏好设置。

我先是把Base64字符串做了URL解码,确认应用没有额外编码。然后修改类里的一个字符串属性,把长度改短一位,发送后服务端返回了序列化解析失败的错误页,类名和包名全都暴露了。这个反馈说明应用确实无脑反序列化了Cookie内容。

接下来我没急着打RCE。先从目标行为日志、报错信息里收集依赖版本,确定是一个老版本Spring框架加上一些公共工具库。本地搭了一个相同依赖的模拟环境,把公开利用链逐个试,最终确认一条经由某个公共集合类的链能够在目标环境稳定触发。真实环境里我只做了低风险的“目标向我的监听端口发起连接”验证,确认命令执行能力存在后立刻收手,整理报告。整个流程的核心是:先证明反序列化触发,再证明代码执行能力,控制验证破坏性。

4.3 案例二:一个PHP框架session存储的测试复盘

另一个项目是个PHP商城系统,程序把整个对象塞进Session后存到了Redis。正常情况下攻击者接触不到Redis,但这个系统有个公开的“购物车预览”接口,会把一个序列化字符串通过POST参数传过来,再unserialize还原成购物车对象。

我先从接口拿到序列化字符串,开头就是O:8:"CartItem":3:{...}。两边参数夹着类名和值,我可以任意改属性值,只要长度字段跟着改对就行。试了一下,把商品价格改成-1后下单成功,签约一个业务逻辑漏洞,但这不是我要找的反序列化。

随后我把类名改成了业务里另一个类LoggerHelper,并从报错里看到unserialize报出“类不存在”的错误。再观察整个框架的自动加载机制,发现有Composer依赖的一批公共类库。于是把本地环境搭建到和目标相同的框架版本,利用公共类库里的魔法方法构造了一条相对完整的利用链,最终验证到能够执行系统命令。这个案例告诉我:PHP侧的关键不是找危险函数本身,而是找目标环境里所有类中有没有把危险操作放进魔术方法里的对象。

4.4 工具不能替代思考:几个容易被忽视的细节

用工具链跑通不代表万事大吉,真实测试里这几个细节经常让人翻车。第一,目标可能在反序列化前对输入做Base64编码的二次包装,比如先Gzip压缩再Base64,你构造的原始数据直接发过去,服务端解不出来,表现是“无反应”,而不是“报错”。第二,序列化之后的目标数据可能经过签名校验,部分Cookie会带上HMAC签名,改数据前需要确认签名机制是否可绕过,不可绕过就换入口。第三,某些框架会对反序列化类做短名单过滤,检测到危险类名会直接丢弃,这类场景反而说明了防御已经部分生效,测试报告里要写清楚绕过路径而不是默认漏洞不存在。

5. 修复与加固:把洞补上比证明漏洞更难

5.1 替换序列化方案:能不用就别用

反序列化漏洞最根本的修复办法,是不要对不可信数据使用支持对象动态创建的序列化方案。很多业务场景根本不需要“对象序列化”,只需要传数据。比如用户偏好设置,完全可以用JSON字符串保存,JSON反序列化只会生成普通的数据结构,不会自动创建任意类、触发任意方法,危险面一下子小了很多。

跨语言RPC场景,优先选跨语言且数据结构固定的序列化协议,这类协议通常只描述数据字段,不携带“类行为”,接收方拿到后还得自己映射到已知的数据模型。即便要做对象映射,也不会自动调用对象的生命周期方法。这里不是否定所有对象序列化,而是强调:能用JSON、协议缓冲这类不支持任意类绑定的方案,就别用Java原生的ObjectOutputStream或PHP的unserialize。

5.2 白名单类校验:多一道严格的安检

如果业务确实绕不开反序列化,那就必须做好类白名单。Java侧可以通过ObjectInputStream的子类重写resolveClass方法,在反序列化时对Class做校验,只允许特定类列表里的类被加载。PHP侧可以在unserialize时传allowed_classes参数,或者在反序列化前对类名做严格的正则校验。

白名单的取名要足够精确。最好是一个显式列表,而不是前缀匹配或包名匹配。因为某些包下面的类非常多,其中很有可能有可被利用危险类。白名单本身也会被绕过,常见绕过方式是攻击者找一个白名单里类作为入口,再通过这个类内部的逻辑去间接操作危险类。所以白名单不是万能,但可以挡住大部分通用利用链。

5.3 运行时防护:RASP与主机加固

动态防护层也有价值。RASP方案在应用运行时监听反序列化过程中的关键行为,比如readObject执行时创建了外部网络连接、执行了系统命令、写了可执行文件,这些行为一旦出现就立即阻断并告警。相比WAF,RASP更接近“感受行为而非看字符串”,所以对未知利用链的检出能力更强。部署RASP会带来一些性能开销,生产环境要做好充分压测。

主机侧加固同样重要。降低应用运行账号权限,不让它用root跑;临时目录、上传目录要设置不可执行权限;数据库账号只给最小权限,禁止DBA级别的账号出现在业务配置文件里。反序列化一旦执行代码,能做的操作范围取决于运行用户的权限,主机加固能显著压制漏洞的实际危害。

5.4 代码审计与开发规范的落地建议

修复反序列化漏洞最好的时机是写代码的时候。建议团队把“禁止反序列化不可信输入”写成编码规范,代码评审时把unserialize、ObjectInputStream、pickle.loads作为高风险关键字重点审。自己开发的内部框架,尽量封装统一的序列化入口,内部强制走白名单与签名机制,而不是让每个业务自己造轮子。

安全测试前置也很重要。反序列化不像普通参数校验缺陷,通常压测和功能测试发现不了,它只能在安全测试阶段通过专门的数据构造被触发。把安全测试放进发布流水线,至少在变更涉及RPC、缓存、会话、文件导入导出时,强制跑一轮专门的序列化安全用例。这个成本不大,但能把漏洞挡在生产之前。

6. 常见问题速查与踩坑心得

6.1 反序列化测试常见问题速查表

现象可能原因排查建议
数据里有序列化特征但目标无反应入口不消费数据,或数据被二次编码确认业务逻辑是否真的读出该参数,追踪完整传输链
本地利用链能跑通,远程无反应目标依赖版本、JDK版本不一致采集目标环境版本指纹,本地原样复现
报错显示类不存在目标没有该类或类名写错收集报错信息里的类名、包名,对照依赖清单
改属性后解析报错,但无法形成利用链目标缺少可利用的公共类库收集所有依赖版本,换链或找业务自定义类
WAF拦截了请求特征被规则库识别尝试编码变形、拆分散数据,优先找WAF覆盖不到的接口
反序列化成功但执行效果没出来链内某个方法被目标版本修改对比类反编译结果,修正方法签名

6.2 几条特别想说的实操心得

第一,先确认业务是不是真的需要反序列化。很多系统用了Java的原生序列化,只是因为框架默认这么做了,开发者并没有真正意识到这里承担的风险。只要业务逻辑允许,换成JSON或MessagePack,事情会简单得多。第二,序列化数据必须带上签名和有效期。哪怕是内部RPC调用,也不要裸奔传对象。加上HMAC签名后,至少能挡住“恶意篡改”这条路,攻击者想构造数据也缺少密钥。第三,日志处理要小心。不要把完整序列化数据直接打进日志,反序列化对象里可能包含密钥、内部类名、敏感业务字段;日志平台一旦被拖走,相当于给攻击者送了一份攻击地图。第四,测试时不要一上来就想打RCE。先证明“我能篡改数据且应用会解析”,这已经是很严重的问题了,完整利用链只是压死骆驼的最后一根稻草。

还有一个容易忽略的点:现代JDK对远程类加载、JNDI部分接口做了默认限制,老利用链放到新JDK上经常失效,但这不代表漏洞不存在。新一代利用链更多利用本地已有类库,不依赖远程加载,JDK版本再新,只要类库没升级,风险依然存在。所以测试报告里一定要写清楚目标真实的JDK版本、中间件版本、依赖版本,否则开发按旧套路去修,很可能修了等于没修。

最后再分享一个我常用的习惯。在本地搭建模拟项目X的时候,我会刻意把反序列化相关的依赖版本做成和目标一模一样的镜像。很多问题不在思路,在版本。版本对上之后,那些网上流传的利用链大概率能跑通;版本对不上,就是瞎折腾。反序列化测试本质上是一场“类库考古”,摸清目标用了什么、版本多少、哪些类可能在链上,比盲目发攻击载荷有用得多。

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

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

立即咨询