
最近在整理手头的漏洞测试笔记把反序列化漏洞这一整块从头到尾重新过了一遍。要说Web安全里哪个漏洞类型最“反直觉”反序列化排第二没人敢排第一——它不像SQL注入那样直接拼接语句也不像XSS那样往页面里塞脚本而是利用“对象还原”这个看似无害的过程把一段精心构造的序列化数据变成攻击者的“遥控器”。这篇博文以PHP反序列化漏洞为主线把原理、魔术方法、利用链、靶场实操、防御方案一次讲透。不管你是刚接触安全测试的研发同学还是想系统梳理这个知识点的安全工程师都可以拿这篇文章当一份“可抄作业”的参考。1. 反序列化漏洞的前世今生1.1 序列化到底在干什么先从一个最基础的问题说起序列化是什么简单来说就是把对象、数组这类内存中的复杂数据结构转换成一段可以保存到文件、写入数据库、或者通过网络传输的字符串或二进制数据。反过来把这串数据重新还原成原来的对象或数组就是反序列化。我用一个生活化的例子解释你有一台完整的乐高城堡想把它寄给朋友。你会怎么做把城堡拆成一袋一袋带编号的零件附上一份图纸。对方收到后按照图纸重新拼装得到一座一模一样的城堡。序列化就是“拆零件画图纸”反序列化就是“按图纸拼装”。整个过程本身没有任何恶意它是分布式系统、缓存、会话保持等场景下的基础能力。PHP里对应的两个函数是serialize()和unserialize()。几乎所有PHP开发都用过它们常见的场景有把用户对象、数组存入$_SESSIONPHP自动帮你序列化到session文件把复杂数据结构缓存到Redis、Memcached取出时再反序列化在接口之间传递对象数据把对象写入文件做持久化除了PHPJava有ObjectInputStream、Python有pickle、Ruby有Marshal思想都一样。问题就出在这个“按图纸拼装”的环节——如果图纸是别人给的而且对方在图纸里夹带了私货拼装出来的可能就不是一座城堡而是一台绞肉机。1.2 漏洞的本质还原对象为什么会出事反序列化漏洞的核心不在于“还原对象”这个行为本身有多危险而在于反序列化过程中代码会触发对象里定义的一些特殊方法。这些方法可能包含敏感操作比如写入文件、执行命令、发起请求。攻击者无法直接调用服务器上的方法但可以通过精心构造的序列化数据控制对象的属性值从而间接控制这些敏感操作的行为。打个比方服务器上有个“员工”你没法命令他做任何事但如果你能伪造一张工牌序列化数据让他误以为你是老板授权的他就会按照工牌上的指令干活。工牌上的字段是攻击者填的员工本身是服务器上现成的整个链条里没有一处“非法”行为但组合起来就是一次完整的攻击。这就是反序列化漏洞和其他漏洞最大的区别它攻击的是“数据到代码”的转换边界。信任边界一旦跨越利用链就可以被构造出来。很多时候目标代码看起来完全正常只是把输入传给了unserialize()攻击者就拿到了一个“彩票号码”。1.3 真实世界里谁在用反序列化反序列化漏洞在真实世界的覆盖面远超很多人的想象。PHP生态里unserialize()用得最密集的地方就是session处理和缓存系统。很多老框架的session处理器直接对会话数据做unserialize()如果会话ID可控或者能往session文件里写入内容就可能触发漏洞。Java生态中的反序列化更是重灾区。WebLogic、WebSphere、JBoss这些重量级中间件历史上都爆出过大量反序列化RCE漏洞。攻击者用ysoserial工具生成一段payload往一个接受序列化数据的接口一扔就能直接命令执行。这类漏洞的影响范围之大几乎可以视作“只要暴露在公网的Java应用都有可能中招”。Python的pickle模块也有同样的问题在机器学习模型分发、任务队列等场景中反复出现。是的这不是一个只在实验室里存在的漏洞类型。我最近审计的一个内部系统就是把用户在前端勾选的筛选条件封装成对象序列化后存在Cookie里取出来直接反序列化。这等于把权限控制的钥匙交到了用户手里场景极其典型。2. PHP反序列化漏洞的原理拆解2.1 看懂序列化数据的“语法”要手工构造payload第一步就是看懂PHP序列化后的字符串格式。很多初学者在这一步就卡住了其实规则非常简单。我拿一个常见的用户类举例class User { public $name admin; public $age 20; public $is_admin false; } echo serialize(new User());输出结果是O:4:User:3:{s:4:name;s:5:admin;s:3:age;i:20;s:8:is_admin;b:0;}逐个拆解一下段落含义O:4:User这是一个对象Object类名长度为4类名是User:3该对象有3个属性{...}花括号内是属性名和属性值的成对结构s:4:name;属性名为字符串长度4值为names:5:admin;属性值为字符串长度5值为admini:20;属性值为整数20b:0;属性值为布尔值false除了这几个还会见到N;表示nulla:2:{...}表示数组d:3.14;表示浮点数。核心规则就一句话每个值前面都要标注“类型长度”字符串必须算对长度对象必须写对类名和属性个数。这里有个容易被忽略的细节属性个数如果和实际不一致在某些PHP版本下会导致反序列化失败或者触发绕过。后面会专门讲这个坑。2.2 魔术方法谁在帮你执行代码PHP反序列化漏洞的“触发器”是一批以双下划线开头的方法官方叫“魔术方法”。它们不需要手动调用而是由PHP在特定时机自动执行。我最常打交道的几个方法名触发时机__construct()对象被new时触发__destruct()对象被销毁时触发__wakeup()对象被反序列化时触发__toString()对象被当作字符串使用如echo、拼接时触发__call()调用不存在的对象方法时触发__get()访问不可访问的对象属性时触发__isset()对不可访问属性调用isset()时触发__sleep()对象被序列化时触发攻击者最看重的是__destruct()和__wakeup()因为前者在对象生命周期结束时自动执行后者在反序列化一完成就立刻执行都不需要等着代码继续运行到某个特定的输出语句。举个例子如果目标类是这样class Logger { public $log_file; public $log_content; function __destruct() { file_put_contents($this-log_file, $this-log_content); } }这段代码的本意是在对象销毁时把日志写到文件里。攻击者如果控制$log_file和$log_content就可以在反序列化时把内容写进任意文件。比如$log_file设为shell.php$log_content设为?php ... ?一个webshell就落地了。这种“参数代入敏感函数”的利用方式是反序列化漏洞最常见的一种形态。这里补充一个曾经的经典绕过CVE-2016-7124。当序列化字符串中声明的属性个数大于对象实际的属性个数时PHP会跳过__wakeup()的执行。这在某些PHP版本下是个绕过检查的利器尤其是目标代码在__wakeup()里做了安全限制时。2.3 利用链设计把可控点串起来只靠单个类的魔术方法还不够很多时候目标系统里没有一个类能直接执行命令。这时候需要“利用链”出场。在PHP漏洞研究中通常叫POP链Property-Oriented Programming属性导向编程。POP链的思想是找到一系列已有的类方法把它们按顺序串起来前一个方法的输出作为后一个方法的输入最终到达一个危险函数。这个思路和二进制漏洞里的ROP链Return-Oriented Programming非常相似——不是自己写shellcode而是用程序里已有的“小工具”拼凑出攻击逻辑。一个典型的POP链长这样class A { public $cmd; function __destruct() { system($this-cmd); } } class B { public $data; function __toString() { return $this-data-run(); } } class C { public $code; function run() { eval($this-code); } } $payload unserialize($_POST[data]); echo $payload; // 触发 __toString如果我直接传一段A对象的序列化数据当$payload被销毁时A::__destruct()会执行system命令。但很多场景下反序列化后的对象会先被echo或者拼接进字符串这时可以利用B::__toString()跳转到C::run()再在C::run()里执行eval。整个链条是由“现有代码里的方法”拼出来的攻击者只负责提供触发点和属性值。构造POP链的难点在于你必须先搞清楚目标代码里有哪些类、哪些方法、哪些危险操作。这也是为什么反序列化漏洞显得“难”它不是套一个模板就能打通的。我在实际测试中通常会对目标框架的源码做一次全面的类关系梳理把每个魔术方法标出来再沿着“属性A → 方法B → 属性C → 方法D”的调用路径反推payload。这个过程很费耐心但一旦串起来成就感也是其他漏洞没法比的。2.4 不止unserializephar反序列化很多开发知道不能对用户输入调unserialize()于是把入口堵死了。但是有一类利用方式绕过了解析入口它叫phar反序列化。PHP里的phar文件本质是一种打包格式类似Java的jar。但phar文件有一个隐藏行为只要用file_exists()、file_get_contents()、is_file()等文件操作函数去访问一个phar://协议路径PHP就会自动反序列化phar文件中序列化保存的meta-data。这个特性意味着即使代码里完全没有unserialize()只要存在一个文件操作函数而且攻击者能上传或控制一个.phar文件或者能伪造任意文件内容就能触发反序列化。很多框架里都存在“上传头像”“导入文件”“读取附件”之类的功能这些都可能变成phar反序列化的入口。我在一次测试里就遇到过这样的情况目标系统的头像上传接口只校验了图片后缀和MIME类型但文件内容完全没限制。上传一个精心构造的phar文件然后在另一个存在file_exists()的接口传入phar://uploads/xxx.png路径直接触发反序列化最后打通了一条完整的RCE链路。所以如果你在做代码审计看到一个“普通文件操作函数”也不要轻易放过最好全局搜一下有没有phar://协议过滤。3. 使用Pikachu靶场实操反序列化漏洞3.1 搭建靶场与模块导读原理说了一大堆不动手等于白看。我最常用的PHP漏洞练习靶场是Pikachu它是一个开源的Web漏洞平台集合了SQL注入、XSS、CSRF、RCE、反序列化等十几个漏洞训练模块。部署方式很简单把源码放到PHP集成环境本地用phpstudy或者XAMPP都行的网站目录下导入一下数据库访问首页就能看到所有模块列表。进入靶场后找到“反序列化漏洞”模块。这个模块的页面会提供一个输入框后台接收到数据后直接交给unserialize()处理。我们这次的目标就是通过这个输入框让服务器执行一个phpinfo()来验证利用成功。这里先做个安全声明以下所有操作必须在你自己的靶场环境或者获得明确授权的测试环境中进行。反序列化漏洞利用属于高危操作不要在未授权的系统上尝试否则后果自负。3.2 手工构造第一个payload我先来看一下Pikachu这个模块的简化版后端逻辑。为了方便理解我把它拆成核心片段class S { public $test no; function __construct() { echo This is class Sbr; if (isset($this-test)) { eval($this-test); } } } $html $_POST[o]; unserialize($html);关键点有两个一是unserialize()处理用户传入的$_POST[o]二是类S的构造函数里有一个eval($this-test)。当我们提交的序列化数据被反序列化成一个S对象时__construct()会被触发$this-test的值就会被eval执行。接下来手工构造序列化字符串。我需要一个名为S的对象包含一个test属性值设为phpinfo();。按照上一节的语法规则字符串长这样O:1:S:1:{s:4:test;s:10:phpinfo();;}一段一段验证一下O:1:S类名S长度1:11个属性s:4:test属性名tests:10:phpinfo();属性值字符串长度为10在靶场里提交这段数据页面会先输出“This is class S”然后执行phpinfo()出现一整张PHP信息表。到这里第一次反序列化漏洞利用就算成功了。3.3 从反序列化到命令执行完整利用链演示phpinfo()只是证明“能执行代码”的最小示例接下来可以往前推一步执行系统命令。把test属性的值改成system(id);对应的序列化字符串是O:1:S:1:{s:4:test;s:13:system(id);;}提交后页面会直接输出当前运行PHP的用户身份信息可能是www-data、nobody或者其他低权限用户。这一步证明我们已经从“代码执行”升级到了“命令执行”。再往深走一步命令执行在真实攻击中通常会被用来读取敏感文件、获取数据库配置、写入后门文件、反弹Shell等等。但我建议你在靶场里走到system(id)和system(ls)这个程度就够了重点是把利用方式理解透而不是把靶场变成一棵圣诞树。整个实操过程我总结成五个步骤分析目标后端代码找出反序列化入口哪里调用了unserialize()找出受影响类中的魔术方法确定可利用的敏感操作在本地或者脑内模拟一个同结构类用serialize()辅助生成payload根据目标代码的实际逻辑调整属性值让它走到危险函数提交payload验证效果这里有一个比较实用的心得写payload的时候我习惯先在本地建一个和目标一模一样类结构的PHP脚本用serialize()生成初始字符串再手动改字符串里的属性值。因为手工数长度很烦容易出错有个基准字符串对照能少踩很多坑。4. 其他语言的反序列化漏洞速览4.1 JavaObjectInputStream的“潘多拉魔盒”Java反序列化漏洞在各类漏洞排行里长期占有一席之地。它的入口通常是ObjectInputStream.readObject()。Java序列化数据是一段二进制格式没有PHP那么直观攻击者一般不会手工构造而是用工具生成。Java反序列化利用的核心是各种Gadget链。最有名的就是CommonsCollections系列ysoserial工具集成了大量现成链一条命令就能生成payloadjava -jar ysoserial.jar CommonsCollections1 command-here生成的payload是一个二进制对象流提交给目标的反序列化接口就能执行命令。Java反序列化之所以危险除了Java本身生态庞大更关键的是依赖库里的链特别多。一个应用可能在代码层面完全没有漏洞但只要classpath里有某个版本过旧的Apache Commons Collections、Spring、C3P0等jar包攻击者就能借力打力。这类漏洞在WebLogic、JBoss、Jenkins等中间件上反复被利用不少企业为此焦头烂额。从防御角度看Java反序列化的治理比PHP更复杂因为开箱即用的类库太多。最保险的做法是对于有反序列化需求的接口明确禁止接收未知来源的数据流对于历史遗留系统至少要做到升级相关依赖库到安全版本同时在接入层做类白名单过滤。4.2 Pythonpickle与危险的数据还原Python的pickle模块也是个典型的危险反序列化点。pickle序列化出来的数据本来就是一串“操作码”由pickle虚拟机逐条执行从设计上就允许在反序列化时执行任意代码。这不是漏洞是特性但这特性一旦碰上不可信输入就是灾难。import pickle import os class Exploit: def __reduce__(self): return (os.system, (id,)) payload pickle.dumps(Exploit()) pickle.loads(payload) # 反序列化时执行 os.system(id)__reduce__方法允许定义对象被pickle还原时的行为攻击者只需要构造一个返回(函数, 参数)元组的__reduce__就能在目标机器上执行任意函数。Python安全社区早就把“不要对不可信数据用pickle”写进了铁律但在机器学习模型分发、Redis缓存pickle、多进程任务队列等场景里还是能看到类似的风险。5. 防御与修复从开发侧和安全侧双向发力5.1 第一原则不要反序列化不可信数据反序列化漏洞的防御第一原则永远是“没有反序列化就没有漏洞”。只要业务逻辑允许尽量别对用户可控或外部传入的数据调用unserialize()、readObject()、pickle.loads()。很多场景下序列化只是手段而不是目的。比如把查询参数传给前端展示完全可以用JSON字符串替代对象序列化。把对象存Redis如果只关心其中的几个字段不如拆成简单的字符串或者hash结构。用JSON传输的好处是数据格式严格、内容直观而且不会触发目标语言里的魔术方法和Gadget链。JSON、XML、YAML这些都是成熟的交换格式除非有强类型和跨语言对象还原的需求否则优先选它们。对于session处理如果胡乱把用户输入塞进session再反序列化风险极大。现在主流PHP框架的session处理器都不再直接用unserialize()了而是使用安全的自定义格式。所以如果还看到老代码里对$_SESSION直接做对象存取一定要警惕。5.2 缓解措施白名单、签名、替代格式如果业务确实绕不开反序列化那么至少要做几道缓解措施。第一白名单校验。PHP的unserialize()在7.0以上版本支持第二个参数$allowed_classes可以限制只允许反序列化指定的类$data unserialize($userInput, [allowed_classes [User, Order]]);这样可以拦截掉攻击者构造的、不在白名单内的恶意类。白名单粒度越细越好能精确到业务实际用到的几个类就是最佳状态。第二序列化数据加签名。在写入序列化数据时用HMAC对数据做摘要读取时先验签再反序列化。攻击者只要拿不到密钥就无法伪造一个合法的序列化数据。这个方法不需要改造业务逻辑适用面广成本低$sig hash_hmac(sha256, $serialized, $secretKey); $safeData $sig . . . $serialized; // 读取时先验签 [$sig, $data] explode(., $input, 2); if (!hash_equals(hash_hmac(sha256, $data, $secretKey), $sig)) { throw new Exception(数据被篡改); } unserialize($data);第三隔离运行环境。实在无法避免危险反序列化起码把相关服务放到权限受限的容器里Web用户用低权限账号运行限制文件系统写入范围这样即使被攻破攻击者能做的也很有限。5.3 安全测试中的审计思路做代码审计或者渗透测试时找反序列化漏洞有一组固定的搜索模式直接按关键词排查就行。在PHP项目里全局搜unserialize(然后看每个调用点对应的参数是从哪里来的。凡是参数来自$_GET、$_POST、$_COOKIE、请求头、数据库字段、外部接口响应都标记为可疑点。接着针对每个可疑点做一次“数据流分析”参数经过哪些处理是否做了校验校验是否可靠有的代码虽然前后端都做了校验但校验逻辑只检查了序列化字符串是不是以某些字符开头这种黑名单式校验很容易被绕过。在Java项目里搜ObjectInputStream、readObject()、XMLDecoder等关键字。还有一类容易被忽略的入口是JMX、RMI、JNDI这些远程调用组件它们本质上也在反序列化远程数据历史上被利用得很惨。在Python项目里搜pickle.loads、cPickle、joblib.load同时注意yaml.load()在处理带!!python/object标签的输入时也可能变成反序列化漏洞点。6. 常见问题与排查技巧实录6.1 反序列化后没有任何魔术方法触发这是新手最常遇到的情况。payload提交了页面也不报错但啥也没发生。问题通常出在类名对不上。PHP反序列化时如果目标类不存在会自动创建一个__PHP_Incomplete_Class对象而不是你期望的那个类魔术方法自然不会触发。排查方法先确认代码里unserialize()之后有没有类的定义或autoload引入再确认序列化字符串里写的类名和真实类名完全一致包括大小写和命名空间。另外确认属性个数是否匹配如果属性个数和实际类定义的属性数量不一致可能导致反序列化失败。我调试的时候习惯在环境里加一行错误返回把unserialize()的返回值打出来一眼就能看出是不是生成了不完整的类对象。6.2 payload构造时最常见的格式错误手工构造payload最烦的就是长度计算错误。一个system(id);到底是几个字符经常有人数错。解决办法有三个优先级从高到低微调本地代码来辅助生成先写一个同名同结构的类用serialize()生成能保证格式和长度绝对正确写个简单的长度计算函数自己数一遍输出到日志里更可靠实在要手工写建议把一个字符串拆开看比如s:13:system(id);写成s:13:system(id);这样逐段核对另外要注意转义问题。在PHP单引号字符串里写payload时内部的双引号要转义在JSON里传payload时双引号又要转义。不同环境下的转义规则不一样经常导致payload在传输过程被破坏。6.3 魔术方法与PHP版本的那点事PHP版本对反序列化行为的影响很大尤其是__wakeup()绕过。CVE-2016-7124的绕过逻辑是当序列化字符串中声明的属性个数大于实际属性个数时PHP会跳过__wakeup()。例如O:7:Hacker:2:{s:4:cmd;s:2:id;}实际只有一个属性cmd但声明为2个属性。在老版本的PHP比如5.6、7.0的某些版本中反序列化时不会触发__wakeup()但对新版本PHP这种行为可能直接引发Notice错误或者反序列化失败。所以构造绕过payload前先确认目标PHP的具体版本不要凭经验硬套。还有一个和版本相关的点phpinfo()和eval这类危险函数在PHP 7.x和PHP 8.x下参数解析规则没有太大变化但某些被禁用的函数被disable_functions限制在不同版本下的绕过难度完全不同。测试前用phpinfo()看一下目标环境配置是个好习惯。写在最后的一点体会反序列化漏洞这章我反复看了好几遍才敢说自己“真懂了”。最初我也只会用现成工具打一个已知的POP链一旦目标代码稍微变个结构就卡住。后来我花时间把序列化格式、魔术方法触发条件、对象生命周期这些基础内容一点点啃透再回头看各种利用链脑子里就是不自觉的“串”的状态。所以如果你也卡在某个技术上别急着问“怎么构造payload”先反问自己一句“我是不是真的知道反序列化那一瞬间代码里发生了什么”最后再分享一个小技巧每次分析完一个反序列化漏洞我都会把目标系统的类结构、触发点、payload整理成一张表格存档。时间久了这些表格就是我最宝贵的漏洞模式库。以后再遇到类似场景直接翻历史记录十几分钟就能定位到可用的利用链。这个习惯帮我省了大量重复劳动也让我在写代码审计报告时能给出更扎实的整改建议。