作者:lucifaer
作者博客:https://www.lucifaer.com/
鸡肋的漏洞,不过官方的解决方案也是有点意思…
8月27号有人在GitHub上公布了有关Discuz 1.5-2.5版本
中后台数据库备份功能存在的命令执行漏洞的细节。
Discuz! 1.5-2.5
官方论坛下载相应版本就好。
需要注意的是这个漏洞其实是需要登录后台的,并且能有数据库备份权限,所以比较鸡肋。
我这边是用Discuz! 2.5
完成漏洞复现的,并用此进行漏洞分析的。
漏洞点在:source/admincp/admincp_db.php
第296行:
@shell_exec($mysqlbin.'mysqldump --force --quick '.($db->version() > '4.1' ? '--skip-opt --create-options' : '-all').' --add-drop-table'.($_GET['extendins'] == 1 ? ' --extended-insert' : '').''.($db->version() > '4.1' && $_GET['sqlcompat'] == 'MYSQL40' ? ' --compatible=mysql40' : '').' --host="'.$dbhost.($dbport ? (is_numeric($dbport) ? ' --port='.$dbport : ' --socket="'.$dbport.'"') : '').'" --user="'.$dbuser.'" --password="'.$dbpw.'" "'.$dbname.'" '.$tablesstr.' > '.$dumpfile);
在shell_exec()
函数中可控点在$tablesstr
,向上看到第281行:
$tablesstr = ''; foreach($tables as $table) { $tablesstr .= '"'.$table.'" '; }
跟一下$table
的获取流程,在上面的第143行:
if($_GET['type'] == 'discuz' || $_GET['type'] == 'discuz_uc') { $tables = arraykeys2(fetchtablelist($tablepre), 'Name'); } elseif($_GET['type'] == 'custom') { $tables = array(); if(empty($_GET['setup'])) { $tables = C::t('common_setting')->fetch('custombackup', true); } else { C::t('common_setting')->update('custombackup', empty($_GET['customtables'])? '' : $_GET['customtables']); $tables = & $_GET['customtables']; } if( !is_array($tables) || empty($tables)) { cpmsg('database_export_custom_invalid', '', 'error'); } }
可以看到:
C::t('common_setting')->update('custombackup', empty($_GET['customtables'])? '' : $_GET['customtables']); $tables = & $_GET['customtables'];
首先会从$_GET
的数组中获取customtables
字段的内容,判断内容是否为空,不为空则将从外部获取到的customtables
字段内容写入common_setting
表的skey=custombackup
的svalue
字段,写入过程中会将这个字段做序列化存储:
之后再将该值赋给$tables
。
至此可以看到漏洞产生的原因是由于shell_exec()
中的$tablesstr
可控,导致代码注入。
漏洞的调用栈如下:
admin.php->source/class/discuz/discuz_admincp.php->source/admincp/admincp_db.php
跟着漏洞的调用栈看一下如何利用。
首先在admin.php
中:
if(empty($action) || $frames != null) { $admincp->show_admincp_main(); } elseif($action == 'logout') { $admincp->do_admin_logout(); dheader("Location: ./index.php"); } elseif(in_array($action, $admincp_actions_normal) || ($admincp->isfounder && in_array($action, $admincp_actions_founder))) { if($admincp->allow($action, $operation, $do) || $action == 'index') { require $admincp->admincpfile($action); } else { cpheader(); cpmsg('action_noaccess', '', 'error'); } } else { cpheader(); cpmsg('action_noaccess', '', 'error'); }
关键点在构造参数满足require $admincp->admincpfile($action);
且$action
为db
。也就说需要构造参数满足:
$admincp->isfounder && in_array($action, $admincp_actions_founder) # 为真 $admincp->allow($action, $operation, $do) # 为真
$admincp->isfounder
是确认当前用户的,返回为True,这里只需要构造$action
为db
。
跟进require $admincp->admincpfile($action);
:
function admincpfile($action) { return './source/admincp/admincp_'.$action.'.php'; }
这里就包含了source/admincp/admincp_db.php
。跟进看一下:
这边需要满足$operation == 'export'
,同时存在exportsubmit
字段。
之后,
需要构造file
字段,同时$_GET['type'] == 'custom'
且$_GET['setup']
和$_GET['customtables']
非空。向下跟,还需要满足最后一个条件$_GET['method'] != 'multivol'
,这样才能调用else
中的操作,完成代码注入。
有了上面的这些基础分析,我们抓个符合上方条件的包来看一下。经过测试,
这样可以抓到符合我们条件的请求包。
接下来只需要将customtables
的内容更改一下就可以造成命令执行了:
效果为:
通过上面的分析可以看到最终可控参数的获取都是利用$_GET
来获取的,但是我们在构造时发送的是post数据,那么为什么会照常获取到呢?
在admin.php
第18行包含了source/class/class_core.php
:跟进看一下:
... C::creatapp(); class core { ... public static function creatapp() { if(!is_object(self::$_app)) { self::$_app = discuz_application::instance(); } return self::$_app; } ... }
跟进到source/class/discuz/discuz_application.php
中:
public function __construct() { $this->_init_env(); $this->_init_config(); $this->_init_input(); $this->_init_output(); }
接着跟进到_init_input()
中:
... if($_SERVER['REQUEST_METHOD'] == 'POST' && !empty($_POST)) { $_GET = array_merge($_GET, $_POST); } ...
可以看到如果构造了post请求,Discuz
的核心类会将$_GET
和$_POST
这两个list拼接到一起,赋给$_GET
数组。
可以利用addslashes()
对可控点进行限制,同时利用escapeshellarg()
函数来限制$tablesstr
执行命令。
Discuz 3.4
非常有趣的一点不是把这个漏洞修了,而是直接在source/admincp/admincp_db.php
第307行写了一个错误…:
list(, $mysql_base) = DB::fetch($query, DB::$drivertype == 'mysqli' ? MYSQLI_NUM : MYSQL_NUM);
调用了一个未声明的静态变量,所以该功能直接是挂掉的,没有办法使用,可谓是简单粗暴…