ThinkPHP反序列化POP链本质是魔术方法自动接力:从可控__destruct(如Windows类)出发,经file_exists触发__toString(如Conversion类),再通过$relation->visible()触发__call(如Request类),最终由call_user_func_array执行任意代码。

ThinkPHP反序列化利用链不是靠单个危险函数撑起来的,而是靠多个类的魔术方法自动接力调用形成的“对象流”。只要入口可控、中间属性可赋值、终点能触发call_user_func_array或file_put_contents这类操作,链就成立。
从 __destruct 开始的起点为什么最常用
ThinkPHP中大量类在__destruct里做了实际动作,比如清理临时文件、保存缓存、关闭连接。这些逻辑往往直接使用$this->xxx属性,而这些属性又恰好能通过反序列化控制。
-
think\process\pipes\Windows类的__destruct调用removeFiles(),再调file_exists($this->files)——这里$this->files是私有属性,反序列化时必须写成"\0think\process\pipes\Windows\0files"格式才能赋值成功 -
Psr6Cache类的__destruct调用$this->pool->save($this->key, ...),只要$this->pool和$this->key都可控,就能跳转到任意实现了save()方法的类 - 别迷信
__wakeup:TP6 默认禁用了unserialize对__wakeup的调用(因PHP 7.4+废弃警告),很多链在新版环境里直接断掉
__toString 是最可靠的中间跳板
只要对象被当作字符串使用,__toString就会触发——而这种场景太常见了:file_exists($obj)、echo $obj、strval($obj)、甚至json_encode([$obj])都会强制类型转换。
-
think\model\concern\Conversion里的__toString会调toJson()→toArray()→$relation->visible($name),只要$relation是可控对象,就能继续往下跳 - 注意trait不能直接实例化:像
Conversion是trait,得找Model类这种use了它的类来承载 - 别漏掉类型隐式转换:比如
if ($obj == 'test')也会触发__toString,但部分PHP版本下可能跳过,不如file_exists稳定
__call 是通向RCE的最后一道门
当$obj->nonexistentMethod()被调用时,__call接管执行。ThinkPHP里不少类的__call实现直接把参数喂给call_user_func_array,这就等于把控制权交给了攻击者。
立即学习“PHP免费学习笔记(深入)”;
-
think\Request类的__call里有return call_user_func_array([$this, $method], $args),只要$method可控(比如设为system),$args是数组['id'],就能执行命令 -
$this->hook在Request中是public属性,反序列化时可直接赋值,但要注意它通常是个数组,得确保结构匹配(如['visible' => ['system']]) - 别硬凑
__invoke:它要求对象被当成函数调用($obj()),这种上下文在业务代码里极少出现,实战中基本不可靠
构造Payload时最容易翻车的三个细节
一条链在本地跑通,放到目标环境就失效,大概率栽在这三处。
- 私有/受保护属性名里的不可见字符:PHP序列化字符串中,
protected属性前是\0*\0,private是\0ClassName\0,少一个\0或大小写错,属性就赋不上值 - 类名大小写敏感:ThinkPHP自动加载机制依赖PSR-4,
think\Model和think\model在Linux上是两个类,反序列化时类名必须完全匹配已加载的类定义 - PHP版本差异:PHP 8.1+ 对
__destruct调用时机做了调整,某些链在8.0能打,在8.2里因对象提前销毁而中断;建议用phpggc thinkphp6 --fast生成兼容性更强的payload
真正卡住人的从来不是“有没有链”,而是“某个属性到底能不能控”“这个__toString到底在什么条件下被触发”“call_user_func_array的第二个参数是不是真能塞进命令”。每一步都得拿源码对着调试,而不是靠模板改几个类名就交差。



















