事先声明:本次测试过程完全处于本地或授权环境,仅供学习与参考,不存在未授权测试过程。本文提到的漏洞《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类型&#