漏洞名称:Discuz!X 前台任意文件删除
影响版本:全版本
危害等级:严重
在说漏洞前,咱们可以先来学习一下Discuz的执行流程,其实已经有大佬把任意文件删除漏洞分析扔网上了,所以这里我顺带剖析一下discuz的运行原理,其实搞懂一个系统的运行原理与架构比分析一个漏洞的价值更高吧,首先来看看Discuz项目的目录结构:
这里比较重要的是/source/目录,为程序模块功能函数,论坛所有的功能实现都要从主文件里面包含调用这里的模块来执行相应的操作
,/data/目录是附件数据、数据库与文件缓存,/api目录是第三方接口,包含了论坛的第三方接口文件,还有UCenter文件,这里也是漏洞常发文件,平时审计的时候也非常注重这个地方,/config不用说了配置文件,全局的核心配置文件,哪里要调用直接读取相应文件的字段值即可,更多了解参考如下链接:http://www.godiscuz.com/dzman/discuzcode_dir_class.html
不像现在大多数CMS系统使用流行开源框架在application
里面来写Controller
,Controller
里面多个function
,Discuz的服务端功能是以模块文件的形式来加载的,也就是说一个方法可能就是一个文件,要执行的时候去调用这个文件去执行就ok了,在根目录下放着所有的主文件,外部基本上都是访问这里的主文件,在主文件里面去调用执行指定的接口模块,大致流程是这样(以spacecp_profile为例):
这里就拿系列漏洞触发的主文件home.php来看,home.php是论坛用户的个人中心文件:
<?php
/**
* [Discuz!] (C)2001-2099 Comsenz Inc.
* This is NOT a freeware, use is subject to license terms
*
* $Id: home.php 32932 2013-03-25 06:53:01Z zhangguosheng $
*/
define('APPTYPEID', 1);
define('CURSCRIPT', 'home');
if(!empty($_GET['mod']) && ($_GET['mod'] == 'misc' || $_GET['mod'] == 'invite')) {
define('ALLOWGUEST', 1);
}
require_once './source/class/class_core.php';
require_once './source/function/function_home.php';
$discuz = C::app();
$cachelist = array('magic','userapp','usergroups', 'diytemplatenamehome');
$discuz->cachelist = $cachelist;
$discuz->init();
$space = array();
$mod = getgpc('mod');
if(!in_array($mod, array('space', 'spacecp', 'misc', 'magic', 'editor', 'invite', 'task', 'medal', 'rss', 'follow'))) {
$mod = 'space';
$_GET['do'] = 'home';
}
if($mod == 'space' && ((empty($_GET['do']) || $_GET['do'] == 'index') && ($_G['inajax']))) {
$_GET['do'] = 'profile';
}
$curmod = !empty($_G['setting']['followstatus']) && (empty($_GET['diy']) && empty($_GET['do']) && $mod == 'space' || $_GET['do'] == 'follow') ? 'follow' : $mod;
define('CURMODULE', $curmod);
runhooks($_GET['do'] == 'profile' && $_G['inajax'] ? 'card' : $_GET['do']);
require_once libfile('home/'.$mod, 'module');
?>
首先获取外部mod
变量,也就是模块名称,接着对$mod
进行gpc
判断,再看$mod
是否在数组里面的,是否是指定的space
模块,最终带入到libfile
解析出模块路径进行包含执行,在浏览器post访问如下接口:
>POST /home.php?mod=spacecp&ac=profile&op=base
>data: affectivestatus=wwwwshph0r3ak
通过动态调试跟进去:(注:代码还是以编辑形式展示,红色代码段为phpstorm跟进的地方)
function libfile($libname, $folder = '') {
$libpath = '/source/'.$folder;
if(strstr($libname, '/')) {
list($pre, $name) = explode('/', $libname);
$path = "{$libpath}/{$pre}/{$pre}_{$name}";
} else {
$path = "{$libpath}/{$libname}";
}
return preg_match('/^[\w\d\/_]+$/i', $path) ? realpath(DISCUZ_ROOT.$path.'.php') : false;
}
这里libfile
用于组合被包含文件的路径地址,从而包含执行目标文件,执行完后直接跳到/source/module/home/home_spacecp.php,前面一步可以看作是一个功能模块,后面这一步可以看作是在指定功能模块文件目录下去定位到目标文件,也就是最后要包含执行的脚本,这样会更好理解一些吧:
if(!defined('IN_DISCUZ')) {
exit('Access Denied');
}
require_once libfile('function/spacecp');
require_once libfile('function/magic');
$acs = array('space', 'doing', 'upload', 'comment', 'blog', 'album', 'relatekw', 'common', 'class',
'swfupload', 'poke', 'friend', 'eccredit', 'favorite', 'follow',
'avatar', 'profile', 'theme', 'feed', 'privacy', 'pm', 'share', 'invite','sendmail',
'credit', 'usergroup', 'domain', 'click','magic', 'top', 'videophoto', 'index', 'plugin', 'search', 'promotion');
$_GET['ac'] = $ac = (empty($_GET['ac']) || !in_array($_GET['ac'], $acs))?'profile':$_GET['ac'];
$op = empty($_GET['op'])?'':$_GET['op'];
if(!in_array($ac, array('doing', 'upload', 'blog', 'album'))) {
$_G['mnid'] = 'mn_common';
}
if($ac != 'comment' || !$_G['group']['allowcomment']) {
if(empty($_G['uid'])) {
if($_SERVER['REQUEST_METHOD'] == 'GET') {
dsetcookie('_refer', rawurlencode($_SERVER['REQUEST_URI']));
} else {
dsetcookie('_refer', rawurlencode('home.php?mod=spacecp&ac='.$ac));
}
showmessage('to_login', '', array(), array('showmsg' => true, 'login' => 1));
}
$space = getuserbyuid($_G['uid']);
if(empty($space)) {
showmessage('space_does_not_exist');
}
space_merge($space, 'field_home');
if(($space['status'] == -1 || in_array($space['groupid'], array(4, 5, 6))) && $ac != 'usergroup') {
showmessage('space_has_been_locked');
}
}
$actives = array($ac => ' class="a"');
list($seccodecheck, $secqaacheck) = seccheck('publish');
$navtitle = lang('core', 'title_setup');
if(lang('core', 'title_memcp_'.$ac)) {
$navtitle = lang('core', 'title_memcp_'.$ac);
}
$_G['disabledwidthauto'] = 0;
require_once libfile('spacecp/'.$ac, 'include');
?>
通过外部GET
进来的ac
参数指定了spacecp
模块下的子模块为profile
,最后进入include
目录下的spacecp
模块里面的接口文件spacecp_profile.php
,这个文件即是漏洞触发点,这个下面做分析,可以看到整个流程还是很简单的,论坛系统功本来是很复杂的,Discuz通过这样的外部传参,多步模块调用的形式使代码结构很容易被人理解。
通过上面的分析知道最终包含执行的文件是/source/include/spacecp/spacecp_profile.php 个人资料模块
第一部分,从数据库中读取出“个人资料”模块中的五个字段:”基本资料”、“联系方式”、“教育情况”、“工作情况”、“个人信息”,再提取出当前用户的原始个人信息:
if(!defined('IN_DISCUZ')) {
exit('Access Denied');
}
$defaultop = '';
$profilegroup = C::t('common_setting')->fetch('profilegroup', true);
foreach($profilegroup as $key => $value) {
if($value['available']) {
$defaultop = $key;
break;
}
}
$operation = in_array($_GET['op'], array('base', 'contact', 'edu', 'work', 'info', 'password', 'verify')) ? trim($_GET['op']) : $defaultop;
$space = getuserbyuid($_G['uid']);
space_merge($space, 'field_home');
space_merge($space, 'profile');
从$operation
里可见这个模块还包含了“密码安全的功能”(password)和"用户名修改”(verify)的字段
if(submitcheck('profilesubmit')) {
require_once libfile('function/discuzcode');
$forum = $setarr = $verifyarr = $errorarr = array();
$forumfield = array('customstatus', 'sightml');
$censor = discuz_censor::instance();
if($_GET['vid']) {
$vid = intval($_GET['vid']);
$verifyconfig = $_G['setting']['verify'][$vid];
if($verifyconfig['available'] && (empty($verifyconfig['groupid']) || in_array($_G['groupid'], $verifyconfig['groupid']))) {
$verifyinfo = C::t('common_member_verify_info')->fetch_by_uid_verifytype($_G['uid'], $vid);
if(!empty($verifyinfo)) {
$verifyinfo['field'] = dunserialize($verifyinfo['field']);
}
foreach($verifyconfig['field'] as $key => $field) {
if(!isset($verifyinfo['field'][$key])) {
$verifyinfo['field'][$key] = $key;
}
}
} else {
$_GET['vid'] = $vid = 0;
$verifyconfig = array();
}
}
if(isset($_POST['birthprovince'])) {
$initcity = array('birthprovince', 'birthcity', 'birthdist', 'birthcommunity');
foreach($initcity as $key) {
$_GET[''.$key] = $_POST[$key] = !empty($_POST[$key]) ? $_POST[$key] : '';
}
}
if(isset($_POST['resideprovince'])) {
$initcity = array('resideprovince', 'residecity', 'residedist', 'residecommunity');
foreach($initcity as $key) {
$_GET[''.$key] = $_POST[$key] = !empty($_POST[$key]) ? $_POST[$key] : '';
}
}
foreach($_POST as $key => $value) {
$field = $_G['cache']['profilesetting'][$key];
if(in_array($field['formtype'], array('text', 'textarea')) || in_array($key, $forumfield)) {
$censor->check($value);
if($censor->modbanned() || $censor->modmoderated()) {
profile_showerror($key, lang('spacecp', 'profile_censor'));
}
}
if(in_array($key, $forumfield)) {
if($key == 'sightml') {
loadcache(array('smilies', 'smileytypes'));
$value = cutstr($value, $_G['group']['maxsigsize'], '');
foreach($_G['cache']['smilies']['replacearray'] AS $skey => $smiley) {
$_G['cache']['smilies']['replacearray'][$skey] = '';
}
$value = preg_replace($_G['cache']['smilies']['searcharray'], $_G['cache']['smilies']['replacearray'], trim($value));
$forum[$key] = discuzcode($value, 1, 0, 0, 0, $_G['group']['allowsigbbcode'], $_G['group']['allowsigimgcode'], 0, 0, 1);
} elseif($key=='customstatus' && $allowcstatus) {
$forum[$key] = dhtmlspecialchars(trim($value));
}
continue;
} elseif($field && !$field['available']) {
continue;
} elseif($key == 'timeoffset') {
if($value >= -12 && $value <= 12 || $value == 9999) {
C::t('common_member')->update($_G['uid'], array('timeoffset' => intval($value)));
}
} elseif($key == 'site') {
if(!in_array(strtolower(substr($value, 0, 6)), array('http:/', 'https:', '[ftp://'](https://webmail.alibaba-inc.com/alimail/#this);, 'rtsp:/', 'mms://')) && !preg_match('/^static\//', $value) && !preg_match('/^data\//', $value)) {
$value = '[http://'.$value](http://%27.%24value/);
}
}
if($field['formtype'] == 'file') {
if((!empty($_FILES[$key]) && $_FILES[$key]['error'] == 0) || (!empty($space[$key]) && empty($_GET['deletefile'][$key]))) {
$value = '1';
} else {
$value = '';
}
}
if(empty($field)) {
continue;
} elseif(profile_check($key, $value, $space)) {
$setarr[$key] = dhtmlspecialchars(trim($value));
} else {
if($key=='birthprovince') {
$key = 'birthcity';
} elseif($key=='resideprovince' || $key=='residecommunity'||$key=='residedist') {
$key = 'residecity';
} elseif($key=='birthyear' || $key=='birthmonth') {
$key = 'birthday';
}
profile_showerror($key);
}
if($field['formtype'] == 'file') {
unset($setarr[$key]);
}
if($vid && $verifyconfig['available'] && isset($verifyconfig['field'][$key])) {
if(isset($verifyinfo['field'][$key]) && $setarr[$key] !== $space[$key]) {
$verifyarr[$key] = $setarr[$key];
}
unset($setarr[$key]);
}
if(isset($setarr[$key]) && $_G['cache']['profilesetting'][$key]['needverify']) {
if($setarr[$key] !== $space[$key]) {
$verifyarr[$key] = $setarr[$key];
}
unset($setarr[$key]);
}
}
if($_GET['deletefile'] && is_array($_GET['deletefile'])) {
foreach($_GET['deletefile'] as $key => $value) {
if(isset($_G['cache']['profilesetting'][$key])) {
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $setarr[$key] = '';
}
}
}
看到这里:
foreach($_POST as $key => $value):
其实这里可以联想到全局变量覆盖的问题,也就是通过这里来遍历外部的所有POST
变量,然后将变量带入到下面的几处判断分支里面去,直到遍历完全部$_POST
参数:
if(in_array($field['formtype'], array('text', 'textarea'))
if(in_array($key, $forumfield))
if($field['formtype'] == 'file’) //判断是否文件类型参数
if(empty($field)) //XSS过滤
if($field['formtype'] == 'file’) //判断是否文件类型参数
if($vid && $verifyconfig['available'] && isset($verifyconfig['field'][$key]))
if(isset($setarr[$key]) && $_G['cache']['profilesetting'][$key]['needverify'])
变更数据信息通过变量覆盖原始数据传入数据库来达到更新数据的目的,下面的代码就是对上传文件的参数进行操作了:
if($_FILES) {
$upload = new discuz_upload();
foreach($_FILES as $key => $file) {
if(!isset($_G['cache']['profilesetting'][$key])) {
continue;
}
$field = $_G['cache']['profilesetting'][$key];
if((!empty($file) && $file['error'] == 0) || (!empty($space[$key]) && empty($_GET['deletefile'][$key]))) {
$value = '1';
} else {
$value = '';
}
if(!profile_check($key, $value, $space)) {
profile_showerror($key);
} elseif($field['size'] && $field['size']*1024 < $file['size']) {
profile_showerror($key, lang('spacecp', 'filesize_lessthan').$field['size'].'KB');
}
$upload->init($file, 'profile');
$attach = $upload->attach;
if(!$upload->error()) {
$upload->save();
if(!$upload->get_image_info($attach['target'])) {
@unlink($attach['target']);
continue;
}
$setarr[$key] = '';
$attach['attachment'] = dhtmlspecialchars(trim($attach['attachment']));
if($vid && $verifyconfig['available'] && isset($verifyconfig['field'][$key])) {
if(isset($verifyinfo['field'][$key])) {
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $attach['attachment'];
}
continue;
}
if(isset($setarr[$key]) && $_G['cache']['profilesetting'][$key]['needverify']) {
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $attach['attachment'];
continue;
}
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
$setarr[$key] = $attach['attachment'];
}
}
}
这一块也是漏洞问题的触发点,详情在下面的漏洞分析中会分析,最终的SQL执行语句:
后面还有一个方法是password方法:
if($operation == 'password’) {
...
...
}
修改密码的模块,限于篇幅,不做过多的分析了。
在说这个漏洞前,先说说14年Discuz的一个漏洞,也是任意文件操作漏洞,同样性质的漏洞,问题也是在spacecp_profile.php
中出现的(毕竟新洞是继承了老洞的坑),从source/include/spacecp/spacecp_profile.php中可以看到这段代码,也就是结束了foreach
循环操作后会对外部$_GET
进来的deletefile
进行处理:
if($_GET['deletefile'] && is_array($_GET['deletefile'])) {
foreach($_GET['deletefile'] as $key => $value) {
if(isset($_G['cache']['profilesetting'][$key])) {
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $setarr[$key] = '';
}
}
}
首先判断外部是否有deletefile
数组,然后对$_G['cache']['profilesetting'][$key]
进行判断,看里面是否有值,这里比较关键的地方是:
>$_GET['deletefile'] as $key => $value
这一步将外部指定的字段值给了$key
值,比如外部是deletefile[affectivestatus]=1
,那么$key
值就是affectivestatus
,
跟下去的$space[$key]
就是数据库中个人资料的值(原始值),带入到下面的unlink
进行删除操作。
打印出来如下:
foreach($_GET['deletefile'] as $key => $value) {
if(isset($_G['cache']['profilesetting'][$key])) {
var_dump($_G['cache']['profilesetting'][$key]);
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
var_dump($space[$key]);
exit();
后来官方出的补丁是:
if($_GET['deletefile'] && is_array($_GET['deletefile'])) {
foreach($_GET['deletefile'] as $key => $value) {
if(isset($_G['cache']['profilesetting'][$key]) && $_G['cache']['profilesetting'][$key]['formtype'] == 'file')
{
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $setarr[$key] = '';
}
}
}
直接加了类型(formtype)
判断,只要判断出用户表单里面的类型为file
后才走下一步,之前affectivestatus
类型为text
:
但是开发只修复了这一个点,同一个文件里面的其他unlink
方法并没有Review
到其中存在的安全威胁,导致了又一个任意文件删除漏洞:
if(!$upload->error()) {
$upload->save();
if(!$upload->get_image_info($attach['target'])) {
@unlink($attach['target']);
continue;
}
$setarr[$key] = '';
$attach['attachment'] = dhtmlspecialchars(trim($attach['attachment']));
if($vid && $verifyconfig['available'] && isset($verifyconfig['field'][$key])) {
if(isset($verifyinfo['field'][$key])) {
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $attach['attachment'];
}
continue;
}
if(isset($setarr[$key]) && $_G['cache']['profilesetting'][$key]['needverify']) {
@unlink(getglobal('setting/attachdir').'./profile/'.$verifyinfo['field'][$key]);
$verifyarr[$key] = $attach['attachment'];
continue;
}
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
$setarr[$key] = $attach['attachment'];
}
首先设置任意POST
参数字段为你要删除的文件,然后再上传文件,网上很多是自己构造表单去上传文件,这里有个更简洁的方法,就是通过修改原始html表单的text参数为file后再上传就ok了。
保存截断跟踪参数走到
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
debug参数如下:
同样还是获取到了affectivestatus
的原始值参数并且拼接到unlink
后面去造成了任意文件删除漏洞。
最新的修复方案是官方直接把这个模块的所有unlink
函数给删掉,简单又粗暴,不过确实真的有效办法,从这两次的重复出现的漏洞点可以看出,开发是值得反思的,这其实是一个典型的案例,纵观现在的甲方企业安全建设也有同样的问题,当黑客只需通过某个脆弱点就能进入内网,安全部门的人在通知业务修复了这个脆弱点的同时是否还应该考虑一下其他的点是否也是外部攻击的脆弱点,业务在不懂安全开发的情况下安全工程师是否能够协助业务发现其他安全问题也是考量安全团队技术实力的指标。
参考:
http://bobao.360.cn/learning/detail/4508.html
https://mp.weixin.qq.com/s/jZ4dh-Cseoe7i0ibNsANag
https://gitee.com/ComsenzDiscuz/DiscuzX/commit/7d603a197c2717ef1d7e9ba654cf72aa42d3e574