ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

PHP反序列化漏洞实战:绕过__wakeup()的四种核心手法解析

PHP反序列化漏洞实战:绕过__wakeup()的四种核心手法解析 1. 项目概述为什么我们要研究绕过__wakeup()在PHP安全研究特别是CTF竞赛和渗透测试的实战中反序列化漏洞一直是一个“宝藏”级别的攻击面。它允许攻击者将一串精心构造的序列化字符串还原成内存中的对象从而触发一系列魔术方法最终可能实现远程代码执行RCE。然而开发者们也并非毫无防备__wakeup()方法就是一道常见的“安全门”。这个方法会在对象反序列化完成之后、其他操作执行之前被自动调用。开发者常常在这里面放置一些“消毒”逻辑比如重置关键属性、记录日志或者干脆直接die()或exit()来终止整个反序列化流程让攻击者的精心构造瞬间化为乌有。这就引出了我们今天要深入探讨的核心问题如何绕过这道__wakeup()安全门这不仅仅是理论上的炫技更是实战中能否将漏洞利用从“可能”变为“成功”的关键一步。从古老的CVE-2016-7124到利用PHP垃圾回收GC机制特性的fast-destruct再到一些基于特殊类如ArrayObject的“骚操作”每一种绕过手法都对应着特定的PHP版本、代码上下文和环境配置。理解它们意味着你能在更复杂的黑盒或白盒测试场景中找到那条隐藏的利用路径。这篇文章我将结合自己多年在代码审计和CTF出题、解题中的经验手把手带你拆解这几种主流绕过手法的原理、适用场景和实战构造技巧让你在面对__wakeup()这堵墙时手里能多几把开锁的钥匙。2. 核心原理PHP反序列化与魔术方法的执行顺序在开始“骚操作”之前我们必须先打好地基彻底理解PHP反序列化过程中对象是如何被“复活”的以及各个魔术方法Magic Methods的登场顺序。这是所有绕过技巧的理论基础。当你调用unserialize($data)时PHP引擎会做以下几件事语法解析首先它会解析你提供的字符串$data检查其格式是否符合PHP序列化字符串的规范例如O:4:“Test”:1:{...}。对象创建与属性填充根据解析结果PHP会在内存中创建指定类的对象并按照字符串中的描述逐一设置对象的属性值。注意此时对象的构造函数__construct()并不会被调用因为反序列化的本质是“重建”一个已有的对象状态而非“新建”一个对象。调用__wakeup()在对象的所有属性都被成功设置之后如果该类定义了__wakeup()方法PHP会立即调用它。这是反序列化流程中开发者代码第一个获得控制权的地方。赋值与生命周期unserialize()函数执行完毕通常会返回这个新创建的对象。如果这个返回值被赋值给了一个变量如$obj unserialize($data)那么这个对象的生命周期就与这个变量绑定直到该变量被unset()、被重新赋值或者脚本执行结束。调用__destruct()在对象的生命周期结束时即被销毁时如果定义了__destruct()方法它就会被调用。这里存在一个关键的执行顺序__wakeup()在__destruct()之前执行。在正常的、完整的反序列化流程中攻击者试图在__destruct()中执行的恶意代码比如system($this-cmd)会先被__wakeup()中的安全措施比如$this-cmd null; die();给扼杀掉。注意__sleep()方法是序列化时serialize()调用的用于指定哪些属性需要被序列化它与反序列化流程中的绕过无关但有时在构造利用链时会被用到。因此我们所有绕过手法的核心目标就是想方设法让__destruct()在__wakeup()生效之前或者绕开__wakeup()的干扰得以执行。下面我们就进入正题。3. 经典手法CVE-2016-7124 属性数量不一致绕过这是最古老、也最广为人知的一种绕过方式对应一个具体的CVE漏洞。它的原理简单粗暴但受限于PHP版本。3.1 漏洞原理与影响范围在受影响的PHP版本中当反序列化字符串中声明的对象属性数量即O:4:“ClassName”:X:中的X大于该对象实际拥有的属性数量时__wakeup()方法的执行会被跳过。影响版本PHP 5.x 5.6.25PHP 7.x 7.0.10这个漏洞的根源在于PHP内核在解析序列化字符串和处理对象唤醒逻辑时存在缺陷。在修复版本中PHP会严格校验属性数量如果不匹配反序列化会失败并产生一个警告__wakeup()自然也不会执行。3.2 实战案例拆解攻防世界unserialize3我们来看一个教科书般的例子来自攻防世界CTF的题目unserialize3。假设源码如下精简版class xctf { public $flag 111; public function __wakeup() { exit(bad requests!); } } $data $_GET[data]; if (isset($data)) { unserialize($data); }我们的目标是触发反序列化但又要绕过__wakeup()中的exit()。步骤1生成正常payload首先我们序列化一个正常的xctf对象。$a new xctf(); echo serialize($a); // 输出: O:4:“xctf”:1:{s:4:“flag”;s:3:“111”;}这个payload中1表示这个对象有1个属性。步骤2应用CVE-2016-7124绕过根据漏洞原理我们手动修改序列化字符串将属性数量1改为一个更大的数字比如2。原始: O:4:“xctf”:1:{s:4:“flag”;s:3:“111”;} 修改后: O:4:“xctf”:2:{s:4:“flag”;s:3:“111”;}我们将dataO:4:“xctf”:2:{s:4:“flag”;s:3:“111”;}提交给服务器。在受影响版本的PHP上__wakeup()被跳过反序列化成功对象被创建尽管属性数量不对但PHP会尝试处理脚本也不会因为exit()而终止。如果题目逻辑中__destruct()或后续有其他利用点我们就可以继续了。3.3 注意事项与局限性版本限制严格这个漏洞只在特定旧版本中有效。如今的生产环境PHP 7.4 8.0基本都已修复。但在一些遗留系统、特定容器环境或CTF故意设置的旧版本靶场中仍可能遇到。属性数量修改的数字必须大于真实属性数量但也不宜过大。通常只需真实数量 1即可。无错误利用即使绕过了__wakeup()由于属性数量不匹配PHP可能会产生E_NOTICE级别的错误。但在很多配置下error_reporting不包含E_NOTICE或使用了错误抑制运算符这并不影响后续代码执行。实操心得遇到CTF题目如果提示PHP版本较老或者代码中明显有一个“死亡wakeup”首先就应该尝试这个经典的属性数量修改法。这是成本最低的测试。4. 进阶技巧利用引用提前改变属性状态当CVE-2016-7124因为版本问题无法使用时我们需要更通用的技巧。利用PHP的引用就是一个非常巧妙的思路。它不依赖于PHP版本漏洞而是利用语言特性来“欺骗”__wakeup()中的逻辑。4.1 核心思想内存地址绑定PHP中使用可以将两个变量指向同一个内存地址即建立引用关系。对其中任意一个变量的修改都会直接影响另一个。$a original; $b $a; // $b 是 $a 的引用 $b modified; echo $a; // 输出: modified在反序列化场景中我们可以构造一个payload让某个在__wakeup()中被“消毒”的属性与另一个在__destruct()中被检查的属性指向同一个值。这样即使在__wakeup()中修改了其中一个由于它们共享内存另一个也会同步变化从而可能使__destruct()中的检查逻辑失效。4.2 实战案例解析考虑以下代码class Vulnerable { public $key; public $wakeup; public function __destruct() { // 我们希望进入这个分支 if (!isset($this-wakeup) || $this-wakeup false) { system($this-key); // 危险操作 } } public function __wakeup() { // 安全措施确保wakeup标志为true $this-wakeup true; } } $data $_POST[data]; unserialize($data);正常逻辑下__wakeup()会强制将$wakeup设为true导致__destruct()中的条件判断失败无法执行system。我们的攻击目标是让__destruct()中的$this-wakeup为false或未设置。步骤构造引用关系我们编写一个脚本在序列化之前就建立好引用关系class Vulnerable { public $key; public $wakeup; } $obj new Vulnerable(); $obj-key id; // 要执行的命令 // 建立引用$obj-wakeup 和 $obj-key 指向同一个内存地址 $obj-wakeup $obj-key; echo serialize($obj);输出payload类似于O:9:“Vulnerable”:2:{s:3:“key”;s:2:“id”;s:6:“wakeup”;R:2;}注意s:6:“wakeup”;R:2;。这里的R:2表示一个引用Reference指向该序列化字符串中第2个存储单元的值。在这个例子中第2个存储单元就是s:2:“id”。发生了什么我们提交这个payload。反序列化开始PHP创建对象并设置属性。$key被设置为字符串id$wakeup被设置为对$key值的引用。__wakeup()被调用执行$this-wakeup true;。因为$wakeup是$key的引用所以这行代码实际上做了两件事将$wakeup这个引用变量重新绑定到了布尔值true上引用可以被重新赋值。关键点$key的值仍然是字符串id它没有被改变因为PHP在重新赋值引用时是让引用变量指向了新值而不是修改原共享的内存。__wakeup()执行完毕。随后或最终__destruct()被调用。在__destruct()中检查$this-wakeup。此时$wakeup已经是true。但是检查的条件是!isset($this-wakeup) || $this-wakeup false。isset($this-wakeup)是true$this-wakeup false是false因为它是true所以整个条件为false分支不进去……等等我们好像失败了等一下这里有个常见的误解上面这个例子实际上并不能成功绕过因为它演示的是引用被重新赋值的情况。成功的利用需要更精细的构造通常依赖于__wakeup()中的逻辑是“修改引用指向的值”而不是“给引用重新赋值”。例如如果__wakeup()中是$this-wakeup $someOtherVar;或者对引用指向的内容进行修改如$this-wakeup-value true;当wakeup是对象引用时情况会不同。一个更典型的利用场景是__wakeup()清空或重置了属性A但我们在__destruct()里检查的是属性B。而我们通过引用让属性B的值始终与属性A的“原始状态”绑定。这样__wakeup()对A的修改不会影响到B所看到的值。重要提示引用绕过的构造需要紧密结合目标代码的上下文。你必须清晰理解__wakeup()对哪个变量做了什么操作以及__destruct()检查的又是哪个变量、如何检查。盲目套用R:引用格式可能无效。在CTF中这常需要结合代码审计来精心构造对象关系。5. 高阶魔法Fast-Destruct快速销毁技巧这是目前CTF中非常流行且强大的一种绕过手法它不依赖特定PHP版本漏洞而是利用PHP反序列化过程中对象生命周期和垃圾回收机制的一个特性。5.1 原理对象生命周期的差异回顾一下原理部分提到的对象生命周期情况A$obj unserialize($data);反序列化结果被赋值给变量。对象生命周期较长在脚本结束或该变量被销毁时才调用__destruct()。情况Bunserialize($data);反序列化结果没有被赋值给任何变量。那么这个刚刚被反序列化创建出来的对象立即就失去了任何引用成为“垃圾”。PHP的垃圾回收机制GC可能会注意是“可能”取决于时机和配置立即标记并回收它从而立即触发其__destruct()方法。Fast-Destruct的核心就是制造情况B并促使GC立即工作让__destruct()在unserialize()函数执行完毕、__wakeup()调用之后但在整个反序列化过程引发的其他可能中断如__wakeup()中的die()生效之前被抢先执行。听起来有点绕。关键在于unserialize()函数内部执行时如果创建了一个临时对象并且这个对象在函数返回前就变成了垃圾那么它的析构函数有可能在unserialize()这个函数调用栈结束前就被调用。而__wakeup()虽然先被调用但如果__destruct()紧跟着在同一个函数执行过程中被调用那么__wakeup()中设置的“死亡”指令如die()可能会因为__destruct()中的代码比如写文件、触发其他对象链而被部分执行或产生副作用有时甚至能“抢在”die()完全终止脚本之前完成攻击。更常见且稳定的利用方式是破坏序列化字符串的格式导致unserialize()在解析中途出错并提前返回。在出错返回时它已经部分创建的对象会因为没有完整构建而被遗弃从而触发快速销毁。而此时__wakeup()可能因为解析错误而根本没有被调用5.2 如何触发Fast-Destruct破坏序列化字符串PHP的unserialize()函数在解析字符串时是“尽力而为”的。如果遇到格式错误它会尝试恢复到错误发生前的状态并返回false或部分结果。我们可以利用这一点故意制造一个“部分有效”的payload。两种常见手法手动闭合花括号} 这是最常用的方法。你有一个正常的、多层嵌套的序列化字符串比如O:5:“Outer”:1:{s:4:“inner”;O:4:“Inner”:1:{s:3:“cmd”;s:6:“whoami”;}}如果你在中间某个地方提前闭合花括号例如O:5:“Outer”:1:{s:4:“inner”;O:4:“Inner”:1:{s:3:“cmd”;s:6:“whoami”;}(缺少了最外层的}) 或者更激进地只保留最外层结构O:5:“Outer”:1:{s:4:“inner”;O:4:“Inner”:1:{s:3:“cmd”;s:6:“whoami”;(完全去掉结尾的}}) 当PHP解析到字符串末尾发现格式不匹配时会抛出警告并尝试处理已解析的部分。对于已成功解析的Inner对象它可能被创建但又因为解析错误而被立即丢弃从而触发其__destruct()。修改或添加无效字符 在序列化字符串的末尾或引用R、r标识符后面添加额外的数字、字母或分号也可能引发解析错误达到类似效果。例如在引用R:2;后面多加一个数字R:2;1。5.3 实战案例DASCTF X GFCTF 2022 EasyPOP题目源码简化包含一个fine类其__wakeup()会清空$cmd并die()。我们的目标是让fine对象的__destruct()或在利用链中最终触发命令执行的方法先执行。通过常规手段构造一个复杂的对象利用链POP链得到一个标准的序列化payload。假设最终payload很长。我们将其末尾的一个}删除使其格式错误。提交这个错误的payload后unserialize()解析失败但在失败前部分对象包括关键的fine对象可能已被实例化。由于解析错误导致函数提前返回这些对象没有被正确赋值给任何变量成为垃圾从而立即触发__destruct()。而__wakeup()可能因为对象未完全初始化或解析流程中断而没有被调用或者__destruct()中的恶意操作如文件写入、触发其他魔术方法在die()生效前瞬间完成了。注意事项成功率Fast-destruct 不是100%稳定的它依赖于PHP版本、GC的具体实现和当前运行状态。但在大多数现代PHP版本7.x, 8.x的CTF/Demo环境中通过破坏序列化字符串来触发的方式相对可靠。错误抑制题目代码中常常使用unserialize(...)来抑制错误信息这正好为我们利用解析错误提供了便利不会让脚本因为一个Warning而完全停止。本地测试在构造payload时务必在与目标相近的PHP环境下进行测试因为不同版本对畸形序列化字符串的处理可能有细微差别。6. 特殊Payload构造C类型序列化与内置类包装这是一种较为小众但非常有趣的绕过方式主要针对那些对反序列化字符串格式做了过滤例如用正则过滤以O:或a:开头的场景。6.1 C类型序列化简介PHP除了常见的表示对象O和数组a的序列化类型还有一种C类型用于表示“自定义对象的序列化数据”通常与Serializable接口相关。但一些PHP内置的类如ArrayObject、ArrayIterator等在序列化时也会产生以C:开头的字符串。一个关键特性是当unserialize()一个C:开头的字符串时它会调用对应类的unserialize方法而不是标准的对象属性填充流程。在这个unserialize方法内部可能会再去反序列化其包裹的数据。这就为我们提供了一层“包装”。6.2 利用ArrayObject进行包装假设我们有如下代码它用正则表达式阻止了以O:或a:开头的标准payloadclass Dangerous { public $cmd; public function __wakeup() { die(“Blocked!”); } public function __destruct() { system($this-cmd); } } $data $_GET[data]; if (!preg_match(“/^[Oa]:\d:/i”, $data)) { unserialize($data); }直接序列化Dangerous对象得到O:...会被过滤。我们可以利用ArrayObject来包装它。构造步骤class Dangerous { public $cmd ‘whoami’; } $danger new Dangerous(); // 创建一个数组将我们的危险对象放入其中 $array array(‘evil’ $danger); // 用ArrayObject包装这个数组 $wrapper new ArrayObject($array); // 序列化这个包装器 echo serialize($wrapper);输出结果类似于C:11:“ArrayObject”:77:{x:i:0;a:1:{s:4:“evil”;O:8:“Dangerous”:1:{s:3:“cmd”;s:6:“whoami”;}};m:a:0:{}}看最终的payload以C:11:“ArrayObject”开头绕过了正则过滤执行流程unserialize()遇到C:识别出这是ArrayObject类的自定义序列化数据。它调用ArrayObject::unserialize()方法。在这个方法内部PHP会去解析{}内的数据。这部分数据包含了序列化后的$array而$array里面又包含了序列化后的Dangerous对象O:8:“Dangerous”...。ArrayObject::unserialize()在重建自身状态时会再次调用unserialize()来还原它内部数组的元素。这时内部的O:8:“Dangerous”...字符串被正常反序列化从而创建出Dangerous对象。由于这个Dangerous对象是在ArrayObject::unserialize()的执行过程中被反序列化的其__wakeup()会被调用如果存在且未被绕过然后在其生命周期结束时可能是包装器析构时或更晚调用__destruct()。关键在于这层包装改变了反序列化的入口点可能绕过一些浅层的字符串过滤。但它并不能直接绕过__wakeup()。内部的Dangerous对象的__wakeup()依然会被执行。所以这种手法通常用于绕过输入过滤而不是直接绕过__wakeup()逻辑。要结合前面提到的fast-destruct或CVE漏洞你需要将绕过__wakeup()的payload比如属性数量错误的payload作为ArrayObject内部包裹的数据。6.3 可用类与版本限制除了ArrayObject还有其他一些内置类的serialize方法会生成C:格式例如ArrayIterator、SplObjectStorage等。但需要注意的是并非所有PHP版本下这些类序列化后都是C:开头。在PHP 7.4及以上版本中许多类的序列化格式发生了变化。因此在使用此技巧前必须在目标PHP版本下验证序列化结果。实操心得当题目出现对O:、a:等类型的过滤时C:包装是一个重要的绕过思路。首先在本地用目标PHP版本测试serialize(new ArrayObject([‘test’‘hello’]))的输出确认是C:开头。然后将你的核心利用链payload作为数组元素进行包装。同时要确保你的核心payload本身已经解决了__wakeup()的问题如果需要的话。7. 实战问题排查与技巧实录理论讲完了但在实际操作中你会遇到各种各样的问题。下面我分享一些常见的坑和排查技巧。7.1 常见问题速查表问题现象可能原因排查思路与解决方案Payload提交后无任何输出或直接返回空白/错误页。1.__wakeup()中的die()/exit()生效了。2. Payload格式错误导致unserialize()返回false。3. PHP配置错误如display_errorsOff。1. 首先检查是否应用了正确的绕过手法版本匹配吗fast-destruct尝试了吗。2. 使用echo serialize($test);生成标准payload再手动修改。确保引号、冒号、分号、花括号配对。3. 尝试在payload后添加}或删除}来触发fast-destruct。4. 在本地或测试环境开启错误显示ini_set(‘display_errors’, 1);。绕过__wakeup()成功了但__destruct()里的代码没执行。1. 对象生命周期未结束比如被赋值给了全局变量。2.__destruct()中的代码逻辑条件不满足。3. 利用链断裂POP chain broken。1. 确认是否触发了fast-destruct。如果payload被赋值如$a unserialize(...)对象会存活到最后。尝试用格式错误使其不被赋值。2. 仔细审计__destruct()的代码。你的对象属性是否设置正确例如if ($this-password $this-name)你的payload里这两个属性值相等吗3. 如果__destruct()是POP链的起点检查链上的每个魔术方法__toString,__get,__call等触发条件是否满足属性连接是否正确。引用R:绕过没有效果。1. 引用关系构造错误。2.__wakeup()中的操作不是修改引用值而是给变量重新赋值。3. PHP版本或序列化处理引用方式有差异。1. 使用var_dump(serialize($obj));仔细查看生成的R指向的位置是否正确。2. 重新分析__wakeup()逻辑。如果它是$this-prop new SafeClass();这种重新赋值引用绕过可能无效。需要寻找其他属性间的引用关系。3. 在目标PHP版本下复现生成payload。使用C:包装后内部的__wakeup()还是被执行了。C:包装只是改变了反序列化的入口内部对象的标准反序列化流程不变。C:包装法主要用于绕过格式过滤不是用于绕过__wakeup()。你需要确保内部包裹的payload本身就能绕过__wakeup()例如利用CVE-2016-7124修改属性数量。在本地成功但打远程靶场失败。1. PHP版本差异。2. 扩展差异如某些类不存在。3. 魔术引号magic_quotes_gpc或特殊字符过滤addslashes。1.最重要确定远程PHP版本。通过报错信息、phpinfo页面等。2. 检查你的payload是否使用了特定扩展下的类如GMPSplFileObject。3. 如果payload中的引号被转义尝试使用十六进制s:4:“test”;可写为S:4:“\74\65\73\74”;或Unicode转义来绕过。但unserialize()本身不支持这些通常需要其他编码技巧或二次解析。7.2 独家避坑技巧从“死亡”信息反推如果__wakeup()里有die(“something”)而你看到了这个输出恭喜这至少证明你的payload语法基本正确反序列化过程走到了__wakeup()。你的绕过手法需要加强或调整。分步测试POP链对于复杂的利用链不要试图一次性构造出最终payload。先单独测试每个环节序列化一个对象看能否触发__destruct再测试两个对象的关联如$a-b $c看__toString或__get能否按预期触发。像搭积木一样确保每一环都稳固。利用__destruct进行调试如果条件允许可以在__destruct里先写一些无害的操作比如file_put_contents(‘/tmp/debug.log’, ‘destruct called\n’, FILE_APPEND);来验证绕过是否成功以及对象是否被创建。注意属性修饰符private和protected属性在序列化时属性名会被加上\0前缀\0类名\0属性名或\0*\0属性名。在手动构造或修改payload时长度字段必须包含这些不可见字符。例如一个private $cmd;在Test类中序列化后可能是s:10:“\0Test\0cmd”;。这里的长度10是字符\0 T e s t \0 c m d的总数10个。如果你在Burp Suite里直接改需要将\0替换为%00URL编码并重新计算长度。这是一个非常常见的错误点。Fast-Destruct的多种触发方式除了删}还可以尝试在数字或字符串长度后加脏字符如s:3:“abc”X;、在不该有的地方插入冒号分号等。多备几种变形payload挨个尝试。
返回列表