RCE Starting from a Neglected Installer Page
A note on disclosure scope. This article is a record meant to explain the vulnerability's mechanism, the conditions under which it applies, the vulnerable code and its patch, and what operators should do. In light of the recent rise in indiscriminate, automated/AI-assisted scanning and the reality that many sites run without a dedicated administrator, concrete attack payloads, along with any execution commands, request examples, or step-by-step reproduction that include them, have been intentionally omitted — both from the body and from any attachment. This adjustment was made in coordination with the vendor.
If you upload the files to a server but never finish the installation and leave the installer exposed externally, it remains in a state where install processing can be executed without authentication. Starting an installation and then leaving it is more common than you would think. This article starts from the question: what is possible on an installer page exposed in such a pre-install / incomplete-install state? One thing to make clear up front: a site where installation has finished normally — so the config file exists and install processing is therefore blocked — does not become a target of this attack merely because the install/ directory is still present.
An installer is inherently dangerous code. Because it has to operate at a stage before protections like login and permissions are even set up, it is usually open without authentication, and all it does is "take the values the user enters and solidify them into a server config file."
But many PHP CMSes build this config file as a PHP file that defines constants and arrays. And after installation, at the common entry point every request passes through, they load and execute this file on every request. So if the installer does not properly validate/encode the input, it is easy for a structure to arise in which "a string left in the config file" becomes "code executed on every request."
An entry point open without authentication, install logic that trusts input as-is, and a config file that is evaluated as code — the spot where these three overlap is exactly an installer left neglected in a pre-install / incomplete state.
To check whether this pattern actually holds, I chose Gnuboard5 (gnuboard5) as the target. It is an open-source CMS widely used for building websites. One thing to note up front, though: this article is not a general treatise but a record of following a single real vulnerability I found myself all the way through, so the detailed flow shown here is not something that generalizes as-is to other CMSes. This vulnerability holds when, on a vulnerable version, the installer is exposed externally in a pre-install / incomplete-install state and the conditions required for installation — a DB connection, writing the config file, and so on — are met.
To state the conclusion first: the installer records user input, verbatim as PHP code, into the config file data/dbconfig.php. And this file is thereafter included by common.php on every ordinary page request. Once code is planted during the install phase, the structure is such that the moment an ordinary page is next loaded, code runs with web server privileges.
Below is a record of following the code flow against v5.6.26 to analyze why this problem holds. This issue has already been fixed in v5.6.27, so read the content below against the v5.6.26 it was analyzed on.
An Unfinished Installer Is an Open Door
Gnuboard5's initial installation goes through three steps via scripts under install/.
install/install.php — start / license
install/install_config.php — form for DB connection info, table prefix, and admin account
install/install_db.php — creates tables from the input and writes the config file data/dbconfig.php
The key is the data/dbconfig.php that step 3 produces. By its name it looks like a DB config file, but in substance it is a PHP file that defines constants and arrays. And after installation, Gnuboard's common entry point common.php loads this file on every request.
// common.php:154
$dbconfig_file = G5_DATA_PATH.'/'.G5_DBCONFIG_FILE; // data/dbconfig.php
if (file_exists($dbconfig_file)) {
include_once($dbconfig_file); // common.php:156 ← included on every request
// ...
}
Let us pause on why include (and include_once) matters. PHP's include does not merely "read in" the specified file — it executes the PHP code in that file as if it had been copy-pasted right here. So "loading" the config file actually means "executing that file."
That is why, if dbconfig.php holds only tidy constant definitions, only the constants get filled in — but if you can wedge a single line of PHP in there, that line too is executed the moment the file is included. It is a "config file" in name only; as far as PHP is concerned it is a code file executed on every request. So the goal of the analysis becomes clear as well.
"Is there an input path to get code into this file?" That input path is exactly step 3's install_db.php. This entrance is not open at all times, though. Before processing the POST, install_db.php loads install.inc.php, which has a guard that blocks installation if the config file already exists.
// install/install.inc.php (included by install_db.php before POST processing)
$dbconfig_file = $data_path.'/'.G5_DBCONFIG_FILE; // data/dbconfig.php
if (file_exists($dbconfig_file)) {
// after printing "The program is already installed."
exit; // ← stops here
}
In other words, on a normally operating site where installation has finished and data/dbconfig.php exists, this entrance is closed. The window where the attack holds is only the pre-install / incomplete-and-neglected state where dbconfig.php does not yet exist, and both the analysis and the impact in this article rest on that premise.
And while that premise holds, there is effectively no threshold. Because install_db.php reads $_POST directly with no CSRF token or install-session check, an unauthenticated attacker can send a request straight at an exposed installer. A "neglected entrance," exactly as the phrase goes.
Two Prefixes, One Check
install_db.php takes in POST parameters. The table prefix table_prefix has a server-side whitelist check attached.
// install/install_db.php:33, :39
$table_prefix = isset($_POST['table_prefix']) ? safe_install_string_check($_POST['table_prefix']) : '';
...
if (preg_match("/[^0-9a-z_]+/i", $table_prefix) ) {
die('... Enter only letters, numbers, and _ for the table-name prefix. ...');
}
If it matches /[^0-9a-z_]+/i, it dies immediately. Quotes, ., and ( all fail to pass. Yet the shop (YoungCart) prefix g5_shop_prefix right next to it has no such check.
// install/install_db.php:48
$g5_shop_prefix = isset($_POST['g5_shop_prefix']) ? safe_install_string_check($_POST['g5_shop_prefix']) : 'yc5_';
Same role, same "table prefix," yet only one side has a whitelist. In the end, the only defensive line g5_shop_prefix relies on is safe_install_string_check().
What the Blacklist Actually Blocks
Let us take apart that single defensive line.
// install/install.function.php:25
function safe_install_string_check( $str, $is_json=false ) {
$is_check = false;
// (1) pattern: a dangerous function immediately after ');
if(preg_match('#\);(passthru|eval|pcntl_exec|exec|system|popen|fopen|fsockopen|file|file_get_contents|readfile|unlink|include|include_once|require|require_once)\s?#i', $str)) {
$is_check = true;
}
// (2) pattern: $_GET[...] / $_POST[...] / $_REQUEST[...] )
if(preg_match('#\$_(get|post|request)\s?\[.*?\]\s?\)#i', $str)){
$is_check = true;
}
if($is_check){
die("The entered value contains unsafe characters. Aborting installation.");
}
return array_map_deep('stripslashes', $str); // if it passes, only stripslashes is applied before returning
}
It is a blacklist. And it assumes only two shapes of attack.
- When a function like
system/exec/eval comes immediately after ); — the statement (syntax-terminating) form that cuts off existing syntax and calls a new command.
- A superglobal reference of the form where
) follows $_GET[...].
The problem is that the path by which this input actually turns into code falls under neither of those two patterns. The shape the blacklist assumed and the shape that actually holds are simply different. On top of that, the block depends on an enumerated list of function names, so it cannot cover cases that step outside that list. That is how the defense is bypassed wholesale.
A stripslashes runs once at the end, but since it acts only on backslashes, it is irrelevant to filtering out this kind of injection.
From String to Code
The g5_shop_prefix that passed is written later in the installation as the value of the G5_SHOP_TABLE_PREFIX constant in data/dbconfig.php (install_db.php:624). That write sits inside the if($g5_shop_install) block (:621), so reaching it requires that the shop (YoungCart) installation is also proceeding.
Here we see an interesting asymmetry. Other inputs, such as the DB connection info, are escaped by the install code as they are written to the config file, but g5_shop_prefix is not. So an abnormal value that passed the check can end up not as a string that is merely stored but as a value that is interpreted as code when the file is loaded. The context it lands in, and the exact form that achieves this, are not disclosed.
Why the SQL Phase Doesn't Kill It
One might expect it to get stuck here. Before writing the config file, the shop installation uses g5_shop_prefix as a table name to create tables and run INSERTs.
// install/install_db.php:124, :381
$file = preg_replace('/`g5_shop_([^`]+`)/', '`'.$g5_shop_prefix.'$1', $file); // replace the g5_shop_ prefix in the dump → reflected in CREATE statements
...
$sql = " insert into `{$g5_shop_prefix}default` set ... "; // INSERT using the same replaced prefix
These CREATEs and INSERTs all run via sql_query($sql, true, ...), and when the second argument is true, it **die**s the instant any single query fails (lib/common.lib.php). So at first I thought that, because of that abnormal prefix that passed the check, the SQL would break somewhere — in a CREATE or an INSERT — and it would die before even writing the config file. But it did not die.
The reason is that every place the prefix goes is a backtick-quoted table name. After prefix substitution, both the CREATE that makes the table and the INSERT that puts in values end up looking like this:
CREATE TABLE `<injected prefix>default` ( ... );
INSERT INTO `<injected prefix>default` SET ...
In MySQL/MariaDB, a backtick-quoted identifier accepts almost every character except the backtick itself as literal characters. Those special characters do not break the SQL syntax; they just become part of a "bizarre but perfectly valid table name." So the CREATE actually creates a table with that name, and the following INSERT finds the same name and inserts values. Neither raises an error, so no die fires, and the flow reaches the fwrite at :624 unbroken.
What is worth noting here is timing. At this stage $g5_shop_prefix is still just an ordinary string as far as PHP is concerned; it only gets "evaluated" as code later, when dbconfig.php is included. The problem holds precisely in the time gap between these two points.
Putting It Together
Setting aside the concrete request path and parameter combination, the whole flow from the analysis so far comes down to this:
Missing input validation
— unlike the adjacent table_prefix, g5_shop_prefix has no whitelist check (install_db.php:48),
and the sole defensive line, the blacklist, targets only one attack shape
and misses the form that actually holds (install.function.php:25).
⇩
Unsafe value written into the config file
— the value that passed is written, unescaped, into data/dbconfig.php as PHP code (install_db.php:624).
The intermediate SQL phase does not break the flow, because the value lands in a
backtick-quoted identifier (install_db.php:124/381).
⇩
Code execution when the file is loaded
— a later ordinary page request passes through the common entry point and includes
dbconfig.php (common.php:156), and at that moment the value left in the config file
is evaluated as code → executed with web server privileges.
Had any one of these three points held, the flow would not stand; instead it links up in a single line — missing validation → unsafe write → evaluation on include.
Verification and Execution Privileges
The reproduction was performed entirely within a local, self-owned test environment (127.0.0.1), against a pre-install v5.6.26 only. To state just the result: on the vulnerable version, following the flow traced above, the injected value was recorded as code in dbconfig.php and then evaluated and executed in the course of loading an ordinary page — confirmed. On the fixed version (v5.6.27 or later), the same value was caught by the install-step input validation (the whitelist) and blocked, so nothing was written into dbconfig.php — also confirmed. To protect sites in operation, concrete request/command examples, step-by-step reproduction, and the payload used are not disclosed in this article.
The command that runs follows exactly the privileges of the process driving PHP (the web server module or PHP-FPM). The vulnerability itself does not escalate privileges; it operates within whatever privileges the PHP process happens to hold at that moment. If, in some environment, this runs with root privileges, that is not a property of the vulnerability but a consequence of that environment configuring the web server/PHP to run as root (some Docker setups, etc.).
Impact
If an installer in a pre-install / incomplete-install state is exposed externally, an unauthenticated remote attacker can execute install processing and leave an unsafe value, as code, in data/dbconfig.php. As a result, an ordinary page request alone can then execute commands with web server privileges.
The consequences that follow can range from exposure of config/source and DB credentials, creation/tampering of admin accounts, and service tampering and outages, all the way to further lateral movement into the internal network — the breadth of the follow-on damage depending on the privileges of the PHP process and the server configuration. That said, this impact all presupposes the pre-install / incomplete window in which the config file does not yet exist.
Affected Versions
I traced back through the git history to see when it broke and when it was fixed. Using pickaxe, I tracked the appearance/changes of the vulnerable spot that writes the G5_SHOP_TABLE_PREFIX constant and of the g5_shop_prefix validation, and I pulled install/install_db.php from each tag to compare directly.
| Version |
Status |
| ~ 5.4.5.5 |
N/A — install_db.php has no shop install logic (g5_shop_prefix) |
| 5.4.6 (2021-06-17) |
Vulnerable code introduced via the YoungCart 5.4.5.5.1 merge → vulnerability begins |
| v5.6.26 (analysis target, 2026-04-16) |
Vulnerable (last version before the fix) |
| v5.6.27 (2026-05) |
Patched — g5_shop_prefix whitelist added (commit d5619707b) |
| v5.6.43 (latest) |
Patch maintained |
Here is what the retrace turned up.
-
In Gnuboard5 core installers at 5.4.5.5 and below, there was no shop install logic at all. g5_shop_prefix does not appear.
-
The vulnerable code was written on the YoungCart (youngcart) side in 2013 (commit 90ee89b4f, "change shop table name to g5_shop"), and flowed into the core at 5.4.6 along with the "Gnuboard 5.4.5.5 + YoungCart 5.4.5.5.1 = Gnuboard 5.4.6" merge. It is vulnerable from this version on.
-
safe_install_string_check, which filtered g5_shop_prefix, is a blacklist that came in with a 2017 install-RCE patch (commit 277ce801e, "17-0524,525" per the commit message). This blacklist, added to block a past install RCE, ended up being pierced right through on the shop-prefix path that was merged in later.
-
This issue was fixed in v5.6.27 (commit d5619707b, "[security] fix installer g5_shop_prefix PHP code injection (RCE)", 2026-05-26). The patch added one whitelist line — identical to table_prefix's — to g5_shop_prefix.
if (preg_match("/[^0-9a-z_]+/i", $g5_shop_prefix)) {
die('... Enter only letters, numbers, and _ for the shopping-mall table-name prefix. ...');
}
-
So the affected range is 5.4.6 ~ 5.6.26, and it is safe from v5.6.27 on. When I checked against the latest master (v5.6.43), the same input was caught by this check ("Enter only letters, numbers, and _ for the shopping-mall table-name prefix.") and was not injected into dbconfig.php.
Fix
Root fix ① — input whitelist. Apply the same validation to g5_shop_prefix as to table_prefix. This is exactly the approach the official patch (v5.6.27) took.
if (preg_match("/[^0-9a-z_]+/i", $g5_shop_prefix)) {
die("Enter only letters, numbers, and _ for the shopping-mall table-name prefix.");
}
Root fix ② — serialize safely at the output point. Write with var_export() so that no input is ever interpreted as code. The key is making "output encoding," not a blacklist, the default.
fwrite($f, "define('G5_SHOP_TABLE_PREFIX', ".var_export($g5_shop_prefix, true).");\n\n");
Guidance for users (operators). If you run Gnuboard5, I recommend the following.
- Update Gnuboard5 to v5.6.27 or later (ideally the latest release) to apply the fix for this vulnerability. On versions with the whitelist added, the same input is blocked right at the install step. → Download releases
- While installation is in progress, restrict external access to
install/ (install only from local/intranet, expose with IP restrictions only during installation, etc.). Even if you started an installation and then aborted it, do not leave it in an incomplete state — block access.
- (Additional recommendation) Once installation is done, delete the
install/ directory immediately or block access to it.
- Update PHP to the latest version (including response to the relevant PHP CVEs). Note that a PHP update does not substitute for Gnuboard's own patch for this issue.
Closing Thoughts
The whitelist that was in place on the parameter right next door was missing from g5_shop_prefix alone, and the blacklist set up as a defense assumed only one attack shape and missed the form that actually holds. In the end, the root of the problem was the very structure of inserting user input as a string into "a file that gets executed as code." That is why the remedy need not be grand: a single line of safe serialization at the output point closes most of it.
For an operator, the first thing to do is clear: update Gnuboard5 to v5.6.27 or later (ideally the latest release) to apply the fix for this vulnerability, and, in a pre-install / incomplete-install state, restrict access so the installer is not exposed externally.
This vulnerability has already been reported via the GitHub issue below.
→ https://github.com/gnuboard/gnuboard5/issues/377#issuecomment-4436735080
The analysis and verification in this article were performed entirely within a local, self-owned test environment (127.0.0.1), and contain no addresses or sensitive information of any real production site. This vulnerability was reported to the Gnuboard side and fixed in v5.6.27, and this article was written in coordination with the vendor. In light of the recent rise in indiscriminate, automated/AI-assisted scanning and the reality that many sites run without a dedicated administrator, concrete attack payloads, execution commands, request examples, step-by-step reproduction, and attack-extension techniques have been intentionally excluded. The content is intended to aid understanding and defense of the vulnerability, and must not be abused for testing or attacks against systems you are not authorized to touch.