# Variant RCA Report — CVE-2026-54466 Bypass

## Summary

The 0.7.5 fix for CVE-2026-54466 adds a single guard in the draft-75 length accumulator (`case 1`): `if (this._length > this._maxLength) return this.close()`. This prevents oversized length headers but does **not** address the two independent root-cause bugs that actually produce the message corruption: (A) `_parseLeadingByte` does not reset `this._buffer` when entering binary frame mode, leaving a stale reference to the previous text frame's content; and (B) in stage 2, the `if (octet === 0xFF)` frame-terminator check fires before the skip-mode branch, so `0xFF` is treated as a frame terminator even during binary-frame payload consumption. By sending a binary frame with a **valid** length (within `_maxLength`) that is `>=` the next frame's wire size, an attacker can cause the parser to consume a subsequent valid sentinel text frame as skip data; when the sentinel's `0xFF` terminator is encountered in skip mode, the stale `_buffer` is emitted as a message, and the sentinel is lost. This bypass is confirmed on the fixed `0.7.5` build — the `_maxLength` guard never fires because the length is well within bounds.

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** "If `_length <= _maxLength`, the parser state is safe." The fix assumes that message corruption requires an oversized `_length` to function.
- **Code paths explicitly covered:** `case 1` (the base-128 length accumulator in `Draft75.prototype.parse`). The guard fires after every continuation octet, closing the driver if the accumulated `_length` exceeds `_maxLength`. This covers both `Draft75` and `Draft76` (which inherits `parse` from `Draft75`).
- **What the fix does NOT cover:**
  - `_parseLeadingByte` does not reset `this._buffer` when the high bit is set (binary frame path). The stale buffer from a prior text frame persists.
  - Stage 2's `if (octet === 0xFF)` check fires unconditionally — before the skip-mode branch (`if (this._length)`) — so `0xFF` is treated as a frame terminator even in skip mode, emitting the stale buffer.
  - These two gaps combine to allow the same message corruption with a valid, bounded `_length`.

## Variant / Alternate Trigger

**Bypass path:** binary frame with valid (within `_maxLength`) length header

- **Entry point:** `Driver.server(...).parse(chunk)` / `Server.parse(chunk)` → `Draft75.parse()` — the same public runtime path as the original CVE, selected via a draft-75 handshake (no `Sec-WebSocket-*` headers).
- **Wire sequence:**
  1. Positive control text frame: `0x00 "POSITIVE-CTRL-OK" 0xFF` → populates `_buffer` with `"POSITIVE-CTRL-OK"`
  2. Variant binary frame header: `0x80 0x1E` → `_parseLeadingByte`: high bit set → `_length=0`, `_stage=1`. Then `case 1`: `_length = (0x1E & 0x7F) + 128*0 = 30`. Fix guard: `30 > 67108863` → **false, does NOT fire**. High bit clear → `_length != 0` → `_skipped=0`, `_stage=2` (skip mode). `_buffer` still holds stale `"POSITIVE-CTRL-OK"`.
  3. Sentinel text frame: `0x00 "SENTINEL-VULN-XYZ-12345" 0xFF` (25 bytes) → consumed byte-by-byte in skip mode. Bytes 0–23: not `0xFF`, `_skipped` increments to 24 (< 30, stay in stage 2). Byte 24: `0xFF` → `if (octet === 0xFF)` fires → emits `Buffer.from(this._buffer)` = stale `"POSITIVE-CTRL-OK"` as a message → `_stage=0`.
- **Code path:** `lib/websocket/driver/draft75.js` → `parse()` → `case 0` (`_parseLeadingByte`) → `case 1` (length accumulator, fix guard passes) → `case 2` (skip mode, `0xFF` triggers stale buffer emission).
- **Result:** Sentinel is consumed and lost. A stale duplicate of the previous message is emitted instead. The connection stays open (`readyState=1`). This is identical to the original CVE's impact.

## Impact

- **Package/component affected:** `websocket-driver`, `Draft75`/`Draft76` parser path (`lib/websocket/driver/draft75.js`), reached via `Driver.server(...)` / `Server.parse(chunk)`.
- **Affected versions (as tested):** `websocket-driver@0.7.4` (vulnerable) and `websocket-driver@0.7.5` (fixed — **bypass confirmed**). Both are affected by this variant.
- **Risk level and consequences:** Critical protocol-level message corruption / message loss. An attacker who can write raw bytes to an established draft-75/76 connection can cause the parser to drop the framing boundary of a legitimate frame and emit a stale/misframed application message, defeating message integrity. The wire allocation is tiny (7 bytes for the variant + sentinel). The connection stays open, so the corruption is silent and ongoing.

## Impact Parity

- **Disclosed/claimed maximum impact:** Protocol message corruption / message loss (impact class `other`), via abuse of the draft-75/76 framing on a negotiated hixie-75/76 connection.
- **Reproduced impact from this variant run:** On both `0.7.4` and `0.7.5`, the driver remained open (`readyState=1`) after the variant binary frame, did **not** deliver the uniquely-marked sentinel text frame, and instead re-emitted a **stale** copy of the previous (positive-control) message. Positive control delivered in both builds, proving the parser/listener path was operational.
- **Parity:** `full` — the reproduced behavior matches the disclosed message-corruption/message-loss impact exactly, and the bypass reproduces identically on the fixed build.
- **Not demonstrated:** No code execution, no memory exhaustion, no native crash — none claimed.

## Root Cause

The message corruption in the draft-75 parser has two independent root-cause bugs:

1. **Stale `_buffer` (Gap A):** In `_parseLeadingByte`, when a leading byte with the high bit set is received (binary frame), `_buffer` is NOT reset. It retains the content from the previous text frame. The text-frame path (high bit clear) correctly creates a fresh `this._buffer = []`, but the binary-frame path does not.

2. **`0xFF` as unconditional frame terminator in stage 2 (Gap B):** In `case 2`, the check `if (octet === 0xFF)` is the first branch, before the skip-mode/text-mode split. In skip mode (`this._length` is truthy), `0xFF` should be treated as a payload data byte, not a frame terminator. Instead, it emits `Buffer.from(this._buffer)` (the stale buffer from Gap A) as a message and resets to stage 0.

The 0.7.5 fix (commit `5b197ca`) only adds the `_maxLength` guard in `case 1`. It does not address Gap A or Gap B. Because a valid, bounded `_length` (e.g., 30) is sufficient to trigger the corruption — the attacker just needs `_length >=` the sentinel frame's wire size so the sentinel's `0xFF` terminator is encountered in skip mode before `_skipped` reaches `_length` — the fix is bypassable.

Fix reference: commit `5b197ca874dab58e96cacad8a3c256797d804680` → `lib/websocket/driver/draft75.js`, one added line.

## Reproduction Steps

1. Reference: `bundle/vuln_variant/reproduction_steps.sh` (driver script) and `bundle/vuln_variant/variant_harness.js` (Node TCP peer harness). Both are self-contained; the script reuses the prepared project cache (`bundle/project_cache_context.json`, `prepared=true`) when available and otherwise falls back to `npm install websocket-driver@0.7.4` / `@0.7.5`.
2. What the script does:
   - Resolves the vulnerable (`0.7.4`) and fixed (`0.7.5`) published `websocket-driver` packages from the durable project cache (or installs them).
   - Verifies the versions and the presence of the `_maxLength` guard in `draft75.js` for the fixed build.
   - For each build, runs `variant_harness.js`, which starts a real loopback TCP server using `net.createServer` + `Driver.server({requireMasking:false})`, pipes `connection ↔ driver.io`, and a raw TCP client performs a draft-75 handshake (no `Sec-WebSocket-*` headers) so `Server.http` selects `Draft75`.
   - Sends: (1) a positive-control draft-75 text frame, (2) a variant binary frame header `0x80 0x1E` (valid length=30, within `_maxLength`), immediately followed by (3) a uniquely-marked sentinel text frame — all in a contiguous wire stream.
   - Records every emitted `open`/`message`/`close`/`error` event, the driver `readyState`, and the exact wire bytes, then writes a JSON result file.
   - Evaluates the oracle: on both versions, `sentinel_delivered=false`, `corrupted_message_after_malicious=true` (stale re-emit), `close_after_malicious=false`, `readyState_final=1`.
3. Expected evidence: `bundle/logs/variant_oracle_eval.txt` shows `BYPASS CONFIRMED: true` — the variant reproduces on the fixed `0.7.5` build with exit code 0.

## Evidence

- **Log locations:**
  - `bundle/logs/variant_repro.log` — driver script output
  - `bundle/logs/vuln_variant_run.log` — vulnerable 0.7.4 harness output
  - `bundle/logs/fixed_variant_run.log` — fixed 0.7.5 harness output
  - `bundle/logs/vuln_variant_result.json` — vulnerable 0.7.4 structured result
  - `bundle/logs/fixed_variant_result.json` — fixed 0.7.5 structured result
  - `bundle/logs/variant_oracle_eval.txt` — oracle evaluation

- **Key excerpt (fixed 0.7.5):**
  ```
  Fixed 0.7.5:
    positive_control_delivered : true
    sentinel_delivered         : false
    close_after_malicious      : false
    corrupted_message_emitted  : true
    positive_message_count     : 2 (2+ => stale re-emit = misframing)
    readyState_final           : 1 (1=open, 3=closed)

  Oracle verdict:
    vuln_path_matches  : true
    fixed_path_matches : true
    BYPASS CONFIRMED   : true

  variant_length : 30 <= maxLength 67108863 (fix guard does NOT fire)
  ```

- **Environment:** Node.js, loopback TCP, `websocket-driver@0.7.4` and `@0.7.5` from published npm packages (cached in project cache). Fixed build commit: `5d6a9aaf5f019007d917bd9ddc7eeb775c86cc1f`.

## Recommendations / Next Steps

The fix must be extended to close Gap A and Gap B:

1. **Reset `_buffer` in `_parseLeadingByte`** when entering binary frame mode. Add `this._buffer = [];` (or `delete this._buffer`) in the `if ((octet & 0x80) === 0x80)` branch, alongside `this._length = 0` and `this._stage = 1`.

2. **Restrict the `0xFF` frame-terminator check in stage 2 to text mode only.** Move the `if (octet === 0xFF)` check inside the `else` branch (where `_length` is falsy / text mode), or restructure so that in skip mode (`this._length` is truthy), `0xFF` is treated as a regular skip byte. For example:
   ```js
   case 2:
     if (this._length) {
       // skip mode: 0xFF is a data byte, not a terminator
       this._skipped += 1;
       if (this._skipped === this._length) this._stage = 0;
     } else {
       // text mode
       if (octet === 0xFF) {
         this._stage = 0;
         message = Buffer.from(this._buffer).toString('utf8', 0, this._buffer.length);
         this.emit('message', new Base.MessageEvent(message));
       } else {
         this._buffer.push(octet);
         if (this._buffer.length > this._maxLength) return this.close();
       }
     }
     break;
   ```

Either fix alone would partially mitigate the issue, but both should be applied for defense in depth.

## Additional Notes

- **Idempotency:** The reproduction script was run twice with identical results (exit code 0 both times). The script is idempotent and deterministic.
- **Edge cases:** The variant length must be `>=` the sentinel frame's wire size (25 bytes in this test) for the sentinel's `0xFF` terminator to be encountered in skip mode before `_skipped` reaches `_length`. A length of 30 provides a safe margin. Any length from 25 to `_maxLength` (67,108,863) works.
- **Draft76 coverage:** `Draft76` inherits `parse` from `Draft75` and does not override it, so the bypass applies to draft-76 connections as well. The server selects `Draft76` when `Sec-WebSocket-Key1`/`Sec-WebSocket-Key2` headers are present.
- **Client-side parser:** The client-side driver (`client.js`) uses `Hybi` (modern WebSocket protocol), not `Draft75`, so the client-side parser is not affected by this variant.
