前些日子,骑士cms 官方公布了一个系统紧急风险漏洞升级通知:骑士cms 6.0.48存在一处任意文件包含漏洞,利用该漏洞对payload文件进行包含,即可造成远程代码执行漏洞。这篇文章将从漏洞公告分析开始,叙述一下笔者分析漏洞与构造payload时遇到的有趣的事情。
官方发布的系统紧急风险漏洞升级通知如下:
http://www.74cms.com/news/show-2497.html
从官方公布的信息来看,官方修复了两个地方:
1、/Application/Common/Controller/BaseController.class.php
2、/ThinkPHP/Library/Think/View.class.php
从BaseController.class.php这处补丁来看:
笔者猜测漏洞多半出在了渲染简历模板的assign_resume_tpl方法中。从补丁修复上来看,增添了如下代码
$tpl_file = $view->parseTemplate($tpl); if(!is_file($tpl_file)){ return false; }
可以发现程序通过$view->parseTemplate对$tpl参数进行处理,并对处理结果$tpl_file进行is_file判断
我们先跟入$view->parseTemplate看看
从上图143行的结果来看,parseTemplate中也是先通过is_file判断,然后将符合的结果返回。
如果此处传入的$tpl变量是文件,那么这个文件可以顺利的通过parseTemplate与assign_resume_tpl方法中的is_file判断。回想一下,这是一个文件包含漏洞,成功利用的先前条件是恶意的文件得存在,然后被包含。这个漏洞多半是通过assign_resume_tpl方法的$tpl参数传入一个真实存在的待包含的恶意文件,而补丁先通过parseTemplate方法内的is_file判断了一次这个恶意文件是否存在,接着又在assign_resume_tpl方法通过is_file方法判断一次,成功的利用一定会使is_file为true。那assign_resume_tpl方法中增加的代码是否有作用?又有着什么作用?
这个问题笔者将在文章最后介绍。
接下来从第二处View.class.php这处补丁来看:
补丁将fetch 方法中
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
代码注释替换为
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_'));
在thinkphp中,E()函数是用来抛出异常处理的。可见这处的修改应该是不想让$templateFile变量值写到日志log文件中。
单从这点来看,命令执行所需的payload百分百是可以通过$templateFile变量写到log文件里的,然后配合任意文件包含漏洞将这个log文件包含并执行。
通过对漏洞情报的分析,我们差不多知道了这个漏洞的来龙去脉:
通过控制fetch 方法中$templateFile变量,将payload写入log文件
通过assign_resume_tpl方法包含这个存在payload的log文件
首先我们抛开怎么把payload写入log文件,先来看看文件包含漏洞怎么回事。
经过上文的猜测,我们可以通过assign_resume_tpl方法包含任意文件。首先我们要看看怎么通过请求调用assign_resume_tpl方法
assign_resume_tpl方法位于common模块base控制器下。通过对Thinkphp路由的了解,assign_resume_tpl方法多半是用如下url进行调用
http://127.0.0.1//74cms/index.php?m=common&c=base&a=assign_resume_tpl
但是实际上,程序抛出了个错误
这是为什么呢?经过动态调试发现一个有意思的事情:common模块是并不能被直接调用的。原因如下:
\ThinkPHP\Library\Think\Dispatcher.class.php中存在如下代码
从上图代码可见,因为我们common模块位于MODULE_DENY_LIST中,因此不能直接通过m=common来调用common模块。
既然不能直接调用,看看有没有其他的办法调用common模块base控制器下的assign_resume_tpl方法
经过研究发现,几乎所有其他的控制器,最终都继承自common模块的BaseController控制器
我们拿Home模块的AbcController控制器举例,见下图:
AbcController 继承FrontendController
而FrontendController由继承了BaseController
因此可以通过get请求
http://127.0.0.1/74cms/index.php?m=home&c=abc&a=assign_resume_tpl&variable=1&tpl=2
来调用BaseController下的assign_resume_tpl,并将$variable=1、$tpl=2参数传递进去
同理,Home模块下的IndexController控制器也是可以的,见下图
IndexController继承FrontendController,从上文可知,FrontendController继承BaseController。因此也可以通过get请求
http://127.0.0.1/74cms/index.php?m=home&c=index&a=assign_resume_tpl&variable=1&tpl=2
来访问BaseController下的assign_resume_tpl并向该方法传参
我们后续分析就用
http://127.0.0.1/74cms/index.php?m=home&c=index&a=assign_resume_tpl&variable=xxx&tpl=xxx
这样的形式调用assign_resume_tpl方法
既然我们可以通过请求向存在漏洞的assign_resume_tpl方法传参了,距离漏洞利用成功已经不远了
我们接下来”假装”在后台上传一个payload,用assign_resume_tpl这个接口包含下试试
笔者手动在如下目录里放了个test.html
为什么这么放呢?因为笔者在源代码里看到如下代码
这里是74cms使用assign_resume_tpl调用word_resume.html的形式。因此笔者在测试时也在word_resume.html通目录下放置了一个test.html,其内容如下:
构造如下请求
http://127.0.0.1/74cms/index.php?m=home&c=index&a=assign_resume_tpl&variable=1&tpl=Emailtpl/test
请求将调用assign_resume_tpl方法。动态调试过程如下:
可见此时$tpl为Emailtpl/test,get请求中参数成功传入了。
我们来看一下fetch里怎么实现的
程序会执行到fetch方法中的Hook::listen('view_parse',$params);代码处
此处代码很关键,需要详细说明下。Hook::listen('view_parse',$params);这处代码的作用大体上有两个:
Compiler:将模板文件经过一定解析与编译,生成缓存文件xxx.php
Load:通过include方法加载上一步生成的xxx.php缓存文件
简而言之,Hook::listen('view_parse',$params);先通过Compiler将攻击者传入的模板文件编译为一个缓存文件,随后调用Load加载这个编译好的缓存文件。
首先我们来看下生产缓存文件过程
从Hook::listen('view_parse',$params);到compiler方法的调用链如下:
该方法会将thinkphp的html模板中定义的标签,解析成php代码。例如模板中的”qscms:company_show/”
就会被解析成
除此之外,compiler方法还会将生成的xxx.php文件头部加上一个如下代码以防止该文件被直接执行
<?php if (!defined('THINK_PATH')) exit();
说完compiler方法的功能后,我们来看下compiler方法是如何处理我们的test.html。
test.html中的代码为<?php phpinfo(); ?>,经过解析之后,返回值见下图
上图compiler方法最终返回的是strip_whitespace($tmplContent);
但strip_whitespace方法的作用是去除代码中的空白和注释,对我们的payload没什么实际意义。
最终compiler方法返回值为
<?php if (!defined('THINK_PATH')) exit(); phpinfo();?>
这个值被写入一个缓存文件,见下图
缓存文件位于data/Runtime/Cache/Home/8a848d32ad6f6040d5461bb8b5f65eb0.php
到此为止,compiler流程已经结束,我们接下来看看加载过程
Load代码如下图所示
从Hook::listen('view_parse',$params);到load方法的调用链如下:
从第一张图可见,load代码最终会include 我们compiler流程中生产的那个data/Runtime/Cache/Home/8a848d32ad6f6040d5461bb8b5f65eb0.php缓存文件
当8a848d32ad6f6040d5461bb8b5f65eb0.php被include之后,其中的恶意代码执行,见下图
执行成功后,浏览器如下
等等,为什么没有phpinfo的回显呢?是不是我们phpinfo执行失败了?我们换一个payload试试,见下图
这次我们执行一个生产目录的命令
可见命令执行成功了。但是为什么phpinfo没有回显呢?
从上文看,我们使用测试文件进行包含利用成功了,但是phpinfo的回显却不见了。进过研究发现,原因还是在fetch方法里。在fetch中,注意看下图红框处代码:
Fetch中的load流程,即加载payload执行phpinfo的过程在上图126行处Hook::listen('view_parse',$params);代码中完成的。
而在此之前,程序通过ob_start打开缓冲区,因此phpinfo输出的信息被存储于缓冲区内,而在Hook::listen代码执行之后,又通过ob_get_clean将缓冲区里的内容取出赋值给$content并删除当前输出缓冲区。因此phpinfo虽然执行成功,但回显并不会显示在浏览器页面上。
如果想要获取回显,我们该怎么办呢?这其实很简单,见下图
此时生成的缓存文件如下:
虽然在include这个缓存文件之前,程序通过ob_start打开缓冲区将phpinfo的输出存到缓冲区里,但我们可以通过执行ob_flush冲刷出(送出)输出缓冲区中的内容,打印到浏览器页面上
上文我们一直在用一个手动上传的test.html,很显然这在实际漏洞利用过程中是不行的。我们需要想办法在目标服务器里写入一个payload。
在这里笔者绕了很多弯路,尝试着在图片上传处做文章,但最后失败了。后来笔者突然想起来官方的补丁,还记得上文我们从官方补丁中得到的漏洞情报?
补丁将fetch 方法中
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
代码注释替换为
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_'));
修改之处的E()函数是用来抛出异常处理的,而补丁将$templateFile删除,正是不想让$templateFile变量值写到日志log文件中。看来payload是可以写到日志文件里的。
我们回过头来,看看fetch 方法中$templateFile变量怎么控制
还记得上文的分析吗?$templateFile变量其实就是请求中传入的tpl变量可以被攻击者控制。从上图来看,只要请求中传入的tpl变量不是文件,就可以将tpl变量值写入log文件。
那么我们就让请求中传入的tpl变量为payload字符串,满足不是文件判断,让这个payload写到日志中
实际发送如下请求控制$templateFile变量写入日志文件
动态调试如下:
日志被写到data/Runtime/Logs/Home/20_12_02.log,见下图
但有个问题:我们为什么不像上文一贯作风,使用get请求传递tpl变量值呢?因为从get请求中url会在日志文件中被url编码,而post请求则不然。因此只能发送post请求。
到此,完整的利用链构造出来了,发送如下请求即可包含日志文件并执行payload
总得来说这个漏洞并不复杂,但是却很巧妙。在此过程中遇到很多有趣是问题。
在从官方补丁中发现利用log文件写入payload思路之前,笔者花费大量时间尝试利用图片上传写入payload。因为74cms中利用了ThinkImage(也就是php-GD)对图片的渲染和处理导致webshell代码错位失效,笔者尝试了这篇文章里的思路
https://paper.seebug.org/387/#2-bypass-php-gdwebshell
这下倒是成功了一半:ThinkImage出现异常抛出错误了,并没有对笔者webshell图片进行渲染和处理,这看起来太棒了。但坏消息是,因为ThinkImage抛出异常,程序并没有把笔者上传成功后存储于服务器上的图片名称抛出来,而图片名称是通过uniqid()函数生成的随机数。uniqid() 函数基于以微秒计的当前时间,生成一个唯一的ID。笔者也没有办法猜测出上传后的图片名是什么,因此作罢。
这个问题与接下来的问题相关,也就是官方的补丁到底有没有效
还记得上文漏洞情报分析那里,关于第一处补丁笔者的分析吗?
补丁在assign_resume_tpl方法中增添了如下代码
$tpl_file = $view->parseTemplate($tpl); if(!is_file($tpl_file)){ return false; }
笔者在分析漏洞之前的想法是:因为这是一个文件包含漏洞,而assign_resume_tpl方法正是这个漏洞的入口,因此如果我们传入的$tpl必定是一个文件,这样可以轻松的绕过$view->parseTemplate($tpl);(parseTemplate中进行判断,如果传入的tpl是文件则直接return)与if(!is_file($tpl_file))判断。
但经过深入的漏洞分析发现,assign_resume_tpl方法不仅是文件包含漏洞的入口,也是后续将payload写入log文件的接口,通过控制assign_resume_tpl方法的tpl参数为字符串形式的payload,则这个payload将会在fetch中被写入日志文件。
但在assign_resume_tpl方法中增加了判断
$tpl_file会是payload字符串拼接.html这样的形式,接下来的if(!is_file($tpl_file))会return false,而保护程序不进入fetch。
但这样真有必要吗?因为fetch中也打了补丁,经过上文对补丁的分析,就算是assign_resume_tpl方法中没有修改使得payload进入了fetch,由于补丁的原因fetch中也不会把payload写入日志了,因此这里的补丁显的没有太大必要。
经过从上面两个问题的思考,可以发现一个新的问题,那就是官方补丁是否可以绕过。通过对漏洞的了解,官方补丁实际起作用的是不让payload写入日志文件。如果真的有人有办法在图片中写入payload并上传成功,在assign_resume_tpl方法中直接包含这个文件即可利用成功。assign_resume_tpl方法中的补丁并没有限制tpl参数为文件。
也就是说:要么官方补丁是可以轻松绕过的、要么通过构造图片webshell这条路走不通。具体哪个是对的,就要看看官方后续是否又出补丁绕过公告与一个新的补丁了。