ThinkPHP 6.0.8反序列化漏洞:POP链构造与实战利用详解
1. 项目概述从一次内部安全审计说起去年在一次针对公司内部老旧系统的渗透测试中我遇到了一个运行着ThinkPHP 6.0.8的Web应用。常规的路径扫描、SQL注入测试都无功而返但一个不起眼的日志文件引起了我的注意——其中记录了一些序列化数据的片段。这立刻让我联想到了反序列化漏洞的可能性。经过一番深入挖掘最终成功构造了一条完整的POP链实现了远程代码执行。整个过程就像在玩一个精密的逻辑拼图而ThinkPHP框架本身的结构恰好为攻击者提供了丰富的“拼图块”。今天我就把这个从漏洞原理到实战利用的完整链条拆解清楚重点放在POP链的构造思路与那些容易被忽略的利用技巧上。无论你是安全研究员、渗透测试工程师还是正在使用ThinkPHP的开发者理解这套机制都至关重要。对于研究者这是一次绝佳的学习案例对于开发者则是敲响一记安全警钟告诉你为什么不能随意反序列化不可信的数据。2. 漏洞原理与ThinkPHP反序列化入口点剖析2.1 反序列化漏洞的核心魔术方法与对象注入要理解这个漏洞首先得抛开“反序列化”这个略显抽象的词。你可以把它想象成“对象的复活术”。PHP的serialize()函数能把一个对象实例包括其属性值变成一串可存储或传输的字符串序列化。unserialize()则相反它读取这串字符串并根据其中的类名、属性值在内存中“复活”出一个一模一样的对象。漏洞的关键在于“复活”过程中PHP会自动调用对象的一些特殊方法我们称之为“魔术方法”Magic Methods。其中__wakeup()和__destruct()是两条最常见的“自动执行链”起点。__wakeup()在对象被反序列化后立即调用而__destruct()则在对象被销毁时比如脚本执行结束调用。攻击者的目标就是精心构造一个序列化字符串让程序在反序列化它时触发一系列连锁的魔术方法调用最终执行任意代码。ThinkPHP 6.0.8的漏洞之所以典型是因为它在框架层面提供了多个潜在的、可供利用的类我们称之为“Gadget”并且存在一些特定的代码写法使得这些Gadget能够被连接成一条从入口点到危险函数如file_put_contents、system的完整路径这就是所谓的“POP链”Property-Oriented Programming面向属性编程。它不是一种新的编程范式而是一种利用思路通过控制对象的属性来操控程序执行流。2.2 ThinkPHP 6.0.8中的关键入口类在ThinkPHP 6.0.8中一个非常经典的入口点是think\process\pipes\Windows类。我们来看它的__destruct方法public function __destruct() { $this-close(); $this-removeFiles(); }当这个类的对象被销毁时它会自动调用$this-removeFiles()方法。我们跟进这个方法private function removeFiles() { foreach ($this-files as $filename) { if (file_exists($filename)) { unlink($filename); } } $this-files []; }这里存在一个潜在的危险操作unlink($filename)。它试图删除$this-files数组中的每一个文件。如果$filename可以被我们控制我们或许能进行一些“文件删除”攻击但这离代码执行还远。真正的价值在于这里存在一个foreach循环它遍历$this-files。如果$this-files不是一个数组而是一个其他类型的对象呢在PHP中如果一个对象被当作数组进行foreach遍历PHP会尝试调用该对象的__call()方法如果方法不存在或者实现Iterator接口的相关方法。这为我们从“文件操作”跳转到“对象方法调用”提供了第一个跳板。但在这个链中更常见的利用方式是寻找另一个类它的__toString或__call方法能被间接触发并最终导向更危险的函数。注意寻找入口点的核心思路是全局搜索__destruct()和__wakeup()方法并分析其中是否存在可控的参数或可以触发其他对象方法的代码逻辑如$this-xxx-yyy()、call_user_func等。ThinkPHP框架类库庞大这类入口点往往不止一个。3. POP链的构造连接Gadget的艺术3.1 从Windows到Model的桥梁仅仅有入口点不够我们需要找到一连串的类和方法像多米诺骨牌一样接连倒下。在ThinkPHP 6.0.8的经典链中下一个关键的Gadget是think\model\concern\Conversion特质Trait中的__toString方法。__toString方法在一个对象被当作字符串处理时例如echo $obj、$obj . ‘string’会自动调用。那么如何让一个Windows对象去触发另一个对象的__toString呢这需要一点巧思。我们回头看Windows::removeFiles()它用file_exists($filename)检查文件。file_exists()的参数理论上应该是一个字符串路径。但是如果我们传入一个对象呢PHP在将对象传递给file_exists这类期望字符串参数的函数时会尝试将该对象转换为字符串即触发其__toString()方法。因此构造链的第一步就清晰了创建一个think\process\pipes\Windows对象并将其files属性设置为一个数组数组的第一个元素是我们精心构造的另一个对象我们称之为$model这个对象拥有有用的__toString方法。当Windows对象被反序列化并销毁时调用removeFiles()。removeFiles()中的file_exists($filename)尝试将$model对象转为字符串触发$model-__toString()。3.2__toString中的宝藏toJson与toArray现在焦点转移到这个$model对象上。在ThinkPHP中think\Model类及其相关特质Conversion的__toString方法通常是这样实现的public function __toString() { return $this-toJson(); } // 接着调用toJson public function toJson($options JSON_UNESCAPED_UNICODE) { return json_encode($this-toArray(), $options); }toArray()方法是整个POP链的核心爆发点。它的作用是将模型实例及其关联数据转换为数组。在转换过程中为了获取关联模型的数据它会动态调用模型的“获取器”Getter方法。在ThinkPHP中获取器的方法名遵循getAttr或getXxxAttr的规则。关键在于toArray()方法在访问属性时可能会触发__get()魔术方法。而__get()方法内部往往存在着类似$this-$name()这样的动态方法调用。如果$name这个属性名可以被我们控制我们就可能调用任意无参数方法。更具体地在ThinkPHP 6.0.8的某个版本中存在这样的代码路径在toArray()- 访问某个属性 - 触发__get()- 调用getAttr()- 进而调用getData()。而在getData()方法中可能存在从$this-data或$this-relation数组中取值的操作。如果我们将$this-relation设置为一个包含其他对象的数组并在访问时触发这些对象的__toString或__call就能将执行流进一步传递下去。3.3 最终触发Request类的input方法经过几轮在模型层的数据访问和魔术方法跳转一个常见的最终目标是think\Request类。这个类有一个input方法用于过滤输入数据。在其实现中可能会调用call_user_func或array_walk_recursive等函数并且其参数部分来源于对象属性。例如假设我们能让执行流到达Request::input()并且该方法内部有这样一段简化逻辑public function input($data, $name, $default null, $filter ) { // ... 获取$data中$name对应的值$value ... if ($filter) { $filter $this-getFilter($filter); if (is_callable($filter)) { $value call_user_func($filter, $value); } } return $value; }如果我们可以控制$filter变量将其设置为一个字符串如system并且控制$value为要执行的命令那么当is_callable($filter)判断通过因为system是PHP内置函数call_user_func(system, whoami)就会被执行从而实现远程命令执行。构造链的难点就在于如何通过控制Model对象的$relation、$append、$data等属性让程序在层层调用后最终将一个我们可控的Request对象其filter属性被我们设置为危险函数名传递到call_user_func的调用点上。4. 完整POP链构造实战与利用代码解析4.1 链式调用流程总览让我们把上面的碎片拼凑起来形成一条在特定条件下可用的完整POP链请注意具体细节可能因ThinkPHP 6.0.8的小版本或具体代码略有不同但原理相通入口点think\process\pipes\Windows::__destruct()-removeFiles()。第一次跳转removeFiles()中的file_exists($this-files[0])$this-files[0]是一个think\Model子类对象触发其__toString()。模型转换Model::__toString()-toJson()-toArray()。属性访问与魔术方法在toArray()过程中访问某个属性例如通过$append属性列表触发Model::__get()。内部调用链__get()-getAttr()-getData()。在getData()中尝试获取$this-relation[‘xxx’]的值。关键控制点我们将$this-relation[‘xxx’]设置为一个think\Request对象。当访问这个关系时为了将其转换为数组元素可能会调用Request对象的某个方法或访问其属性这为我们操控执行流进入Request类提供了机会。最终触发在Request类的方法中例如某个用于过滤的方法找到一处call_user_func或类似动态函数调用且其参数函数名和参数值来自于我们可控的Request对象属性如$filter,$data。代码执行call_user_func($this-filter, $this-data)被执行其中$this-filter ‘system’$this-data ‘whoami’从而执行系统命令。4.2 构造Payload的详细步骤与代码下面是一个高度简化的Payload构造示例用于演示思路。实际利用需要根据目标环境的精确代码版本进行调整并且存在诸多限制如PHP版本、扩展、类是否存在等。?php namespace think\process\pipes; class Windows { private $files []; public function __construct() { // 这里放置我们构造的Model对象 $this-files [new \think\Model()]; // 实际需要是Model的子类或特定类 } } // 实际构造中我们需要精心设置Model子类的$relation, $append, $data等属性 // 以及一个精心构造的Request对象并将其关联到Model的某个关系中。 // 最终序列化Windows对象。 $payload serialize(new Windows()); echo urlencode($payload); // 通常需要URL编码后发送更具体的、可用的PoC概念验证代码会复杂得多。它需要利用PHP的反射Reflection或自定义类来设置对象的私有、保护属性。精确找到Request类中可利用的call_user_func调用点及其对应的属性名。处理PHP反序列化时的字符逃逸问题如果存在。考虑目标服务器上的PHP禁用函数如system,exec被禁用可能需要寻找替代的代码执行方式如使用file_put_contents写Webshell。实操心得在真实环境中我强烈建议使用现成的、经过验证的漏洞利用工具如PHPGGC这类“通用反序列化链”生成工具来生成针对ThinkPHP 6.0.8的Payload。手动构造不仅耗时而且极易出错。理解原理是为了更好地防御和进行代码审计而在渗透测试中效率是关键。使用工具生成后可以再反序列化分析其结构这是学习链构造的最佳方式。4.3 利用技巧绕过限制与增强稳定性处理属性可见性POP链中经常需要操控私有private或保护protected属性。在构造序列化字符串时我们需要按照PHP序列化的格式规则来手动编写。对于私有属性格式为\0类名\0属性名对于保护属性格式为\0*\0属性名。在序列化字符串中\0是空字符。在生成Payload时必须正确处理这些不可见字符。利用__wakeup的绕过如果入口类的__wakeup方法中有一些重置属性或安全检查的代码可能会中断我们的链。有时可以通过操纵序列化字符串中对象属性的数量来绕过__wakeup的执行CVE-2016-7124但仅适用于PHP特定版本。更通用的方法是寻找不依赖__wakeup的链或者寻找__wakeup本身也可被利用的类。寻找替代的终点如果call_user_func被过滤或目标环境禁用命令执行函数需要寻找其他终点。例如file_put_contents($filename, $data)可以写入Webshell。unlink($filename)可以删除关键文件造成拒绝服务。ThinkPHP的Cache驱动也许能通过缓存注入PHP代码。 关键思路是在POP链的末端找到一个我们可控参数的文件操作或代码包含函数。结合其他漏洞反序列化漏洞的触发点可能很隐蔽。它可能需要通过框架的某个特定路由、一个接收序列化参数的API接口、或者一个使用了unserialize的缓存操作才能触发。信息收集阶段要关注应用使用的组件、框架版本以及是否存在接收phar://协议包装的文件上传点Phar反序列化是另一个常见的触发方式。5. 漏洞防御与安全开发建议理解了攻击原理防御就更有针对性。防御反序列化漏洞的核心原则是不要反序列化不可信的数据。5.1 针对开发者的防御措施升级框架ThinkPHP官方早已在后续版本中修复了这些已知的反序列化链。最直接有效的方法是升级到最新的安全版本。对于ThinkPHP 6.x应升级至6.0.x的最新子版本或更高的7.x版本。严格检查输入如果业务确实需要使用unserialize()必须确保反序列化的数据来源完全可信。例如只反序列化来自内部安全存储如数据库特定加密字段、可信的会话存储的数据并且在前进行完整性验证如数字签名。使用白名单机制在调用unserialize()时使用allowed_classes参数限制允许反序列化的类。这是PHP 7.0提供的功能能极大限制攻击者的可用Gadget范围。// 只允许反序列化白名单中的类其他类将被实例化为 __PHP_Incomplete_Class 对象 $data unserialize($serializedData, [allowed_classes [MySafeClass1, MySafeClass2]]);如果业务只允许反序列化少数几个自定义类这将是非常强大的防护。用json_decode替代对于简单的数据存储和传输优先使用json_encode()/json_decode()。JSON格式不支持对象类型只能表示基础数据类型和数组从根本上避免了对象注入。代码审计与危险函数排查在项目代码中全局搜索unserialize(审查每一处调用上下文。同时检查那些可能自动触发反序列化的场景如phar://协议的文件操作Phar元数据会被反序列化。5.2 针对安全运维的加固建议配置PHP安全设置在php.ini中可以设置unserialize_callback_func为一个自定义函数当反序列化未定义类时调用用于记录日志或中断执行。虽然不能完全阻止但能增加攻击难度和提供告警。禁用危险函数在php.ini的disable_functions列表中禁用不必要的系统命令执行函数如system,exec,passthru,shell_exec,proc_open等。即使攻击者构造了POP链也无法执行系统命令迫使其转向其他危害相对较小的利用方式如文件读取。部署Web应用防火墙成熟的WAF通常具备检测畸形序列化字符串和已知POP链模式的能力可以在网络层拦截攻击尝试。最小化依赖定期审查composer.json移除不必要的第三方包。每个额外的依赖都可能引入新的、未知的Gadget类。6. 实战排查与常见问题在真实环境中利用或审计此类漏洞时你可能会遇到以下问题6.1 反序列化Payload执行了但没效果检查PHP版本某些POP链对PHP版本敏感。例如PHP 7.4对属性类型有更严格的限制旧链可能失效。检查类是否存在确保目标服务器上包含了POP链中所有用到的类。如果使用了非核心类如某些第三方库的类而目标环境未安装该库链会中断。检查魔术方法的影响如果链中某个类的__wakeup()方法重置了关键属性会导致后续利用失败。需要分析__wakeup的逻辑或寻找绕过方法。查看错误日志开启PHP的错误日志display_errors关闭log_errors开启查看是否有关于未定义类、属性访问错误或函数调用错误的记录。这能提供宝贵的调试信息。6.2 如何寻找新的POP链静态代码分析使用代码审计工具或IDE的全局搜索功能寻找所有包含__destruct(),__wakeup(),__toString(),__call(),__get(),__set()的类。危险函数调用点call_user_func(_array),array_walk(_recursive),usort,uasort等以及eval(),include/require参数可控时文件操作函数参数可控时。动态调试在可控环境如Docker容器中搭建目标框架通过Xdebug等工具单步调试观察反序列化后的执行流验证链的连通性。研究现有链分析PHPGGC等工具中已收录的链。理解一条成熟链的构造思路是发现新链的最好教材。关注链中“跳板”方法的共性。6.3 在渗透测试中如何发现反序列化漏洞点黑盒测试参数模糊测试对所有POST、GET、Cookie、Header参数尝试提交序列化数据以O:开头。观察应用响应是否有差异是否报错如__PHP_Incomplete_Class。协议流探测测试phar://协议。如果应用有文件上传功能尝试上传一个包含恶意序列化元数据的Phar文件需特定方法生成然后通过phar://协议包含该文件可能触发反序列化。缓存与会话检查缓存系统如Redis、Memcached的存储内容是否可能包含序列化数据。某些框架的会话处理器也会使用序列化。白盒/灰盒测试代码审计直接搜索unserialize(关键字。组件识别通过报错信息、composer.lock文件或特定路由识别框架和组件版本查找该版本已知的公开漏洞。手动构造一条可靠的POP链是一项需要耐心和深厚代码理解能力的工作。对于大多数以结果为导向的安全测试我更倾向于先使用成熟的工具生成Payload进行验证。但这绝不意味着原理不重要。正是对__destruct如何触发__toString__get如何导向call_user_func的每一步的清晰理解才能让我在工具失效时有能力自己去挖掘新的路径或者更精准地判断一个系统是否存在风险。ThinkPHP 6.0.8的这个案例几乎涵盖了POP链构造的所有经典元素是一个绝佳的学习样本。