前言
在半个月前的CISCN线上初赛的时候碰到了一道关于ThinkPHP6.0.12LTS反序列化漏洞的题目,当时解题是直接用的网上的POC,没有细致的研究这个漏洞POP链的挖掘,这篇文章就对这个漏洞进行一个复现和分析。
环境搭建
PHP8.0.2
thinkphp6.0.12
thinkphp6.0.12下载:
1 | composer create-project topthink/think tp6 # 自动下载最新版的 |
这里需要注意的是,thinkphp6需要用到php7.1以上才能下载。
反序列化分析
反序列化的入口一般是__wake()、__destruct()或者__construct(),所以我们直接全局搜索,定位到vendor\topthink\think-orm\src\Model.php:
可以发现当$this->lazySave为true时,就会执行$this->save()。继续跟进$this->save():
其中漏洞方法为updateData()。因此我们需要先绕过第一个if语句:
跟进$this->isEmpty()方法:
发现这里判断$this->data这个变量是否为空,绕过也很简单,给$this->data赋值即可。接着跟进$this->trigger():
发现只要我们将$this->withEvent设为false,那么$this->trigger()就会返回true,从而使得false===true,结果为false,成功绕过if来到536行的反序列化链漏洞点:
这里将$this->exists设为true即可调用$this->updateData方法。跟进$this->updateData():
这里调用了$this->checkAllowFields()方法,跟进一下:
跟进后,发现这里调用了$this->db()方法,继续跟进$this->db方法:
发现如果$this->table不为空的话,就会将$this->table与$this->suffix进行拼接,同时调用table方法进行处理。这里进行了拼接操作,因此我们可以全局搜索一下是否有__toString()方法,这样我们只需要将$this->table和$this->suffix其中一个赋值为__toString()所在的类,即可调用该__toString方法。全局搜索后,定位到vendor\topthink\think-orm\src\model\concern\Conversion.php:
这里调用了$this->toJsong()方法,跟进一下:
继续跟进$this->toArray():
发现这里的$data变量是将$this->data与$this->relation进行合并,接着遍历$data这个数组,最终执行图中箭头所示的代码,执行$this->getAttr($key)。可以发现如果我们可以控制$this->data和$this->relation其一,我们就可以控制这个$key,也就是控制$this->getAttr()的参数。那么跟进一下$this->getAttr():
发现这里的$this->getData()传入了我们的可控参数$name,跟进一下:
$this->getRealFieldName()方法中传入了可控参数$name,继续跟进:
发现这里默认返回的就是我们传入的可控参数$name。那么也就是说getData()中的$fieldName可控,那么紧接着的是getData()中返回的值($this->data[$filedName])可控,然后就是getAttr()中的$value可控,这一点可以通过观察上面三副图片可以知道。
发现getAttr()中的$value可控,那么接下来就是getAttr()中的$this->getValue()了,跟进一下:
其中$fieldName = $this->getRealFieldName($name);,getRealFieldName()我们刚刚看了,默认返回的就是$name,因此这里的$fieldName的值就是$name。接着审计,可以发现$this->getJsonValue()中传入的两个参数都是我们可控的。跟进一下这个函数:
我们看一下我们可控的参数:$name,$value,$this->withAttr。审计一下这段遍历数组的部分,因为这里遍历的是$this->withAttr[$name],因此我们需要将$this->withAttr[$name]设为一个数组。并且还可以发现$value也应该是一个数组。那么至此思路就很明显了:
1 | $this->data=['rainb0w'=>['temp'=>'whoami']] # $name此时为'rainb0w',$value为['temp'=>'whoami'] |
起初我以为$value['temp'] = system('whoami',$value)这里并不能执行成功,后来在本地测试了一下,结果如下:
至此POP链已经找完了。接下来就是写POC了。不过在写POC的之前,我们先在app/controller/index.php中加入我们的测试代码:
POC编写
首先入口是Model类中的__desruct()方法:
这里需要将$this->lazySave设为true,接着进入$this->save():
这里需要将$this->data不设为空,使得$this->isEmpty()的值为false。$this->withEvent设为false,使得$this->trigger()返回ture,从而使得false===true,绕过if。接着将$this->exists的值设为true,执行$this->updateData()。
因此此时的POC如下:
1 |
|
这样一部分一部分地写POC也是为了更方便的去了解整个POC的编写流程。继续根据上面的分析,编写POC。
接下来就是从跟进$this->updateData()那里开始了,发现需要将$this->table和$this->suffix这两个其中一个new一个Conversion类,但是需要注意的是,Conversion类是被trait修饰的类,并不是一个可实例化的类:
这是文档中关于trait的解释:
因此接下来,我们需要找一下谁use了这个Conversion类,全局搜索一下,发现正是刚刚的Model类:
但是Model类是个抽象类abstract,也无法被调用,我们需要再找一下谁use了Model类,继续全局搜索,有符合的很多类,但是大多数师傅们都选的是这个Pivot类,因此我们这里也来使用一下吧:
继续编写一部分POC:
1 |
|
接着从toJson()那里的分析往下看:
这里的$data的值就是$this->data的值,调用了$this->getAttr($key),其中的$key就是$this->data中的’rainb0w’。跟进$this->getAttr():
根据上面的分析,这个$value就是$this->data['rainb0w']。接着从跟进$this->getValue()的分析那里继续走:
如果想调用$this->getJsonValue(),我们就要进入if语句。因此$this->json应该被设为array('rainb0w'),$this->withAttr['rainb0w']应该是一个数组。
到getJsonValue():
这里的$this->withAttr需要的值也在上面的分析中给出,同时也要让$this->jsonAssoc的值应该是true,这样才能进入if中。因此可以编写出全部的POC了:
1 |
|
测试利用
首先确保index.php中已经写了反序列化点,比如:
接着去浏览器传一下payload,弹个计算器玩玩:






























