DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

[CVE-2026-126187] — Unauthenticated RCE in React Server Components' Flight Protocol

At a glance

Item Details
CVE / GHSA CVE-2026-126187 / GHSA-xqc2-xf4c-x3h3
CWE CWE-502 (Deserialization of Untrusted Data)
CVSS 4.0 9.5 (Critical) — AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:N
Affected packages react-server-dom-webpack, react-server-dom-parcel, react-server-dom-turbopack
Affected versions 19.0.0–19.0.8, 19.1.0–19.1.9, 19.2.0–19.2.8, 19.3.0
Fixed versions 19.0.9, 19.1.10, 19.2.9, 19.3.1 (disclosed 2026-11-10, patch merged 10/14)
Public PoC briskets/CVE-2026-126187-poc (lab: Next.js 16.4.0 / React 19.3.0 / Node 22)

A vulnerability in Flight, the wire protocol behind React Server Components (RSC), lets attacker-controlled bytes break the protocol's own framing and smuggle in an arbitrary module reference disguised as data. Combined with Next.js 16's Cache Components (use cache), this chains into unauthenticated remote code execution on the server.

React's own advisory calls the trigger condition "extremely rare cases — responses that transport multiple typed arrays and an attacker-controlled value," but in practice Node.js's default 8 KiB buffer pool makes this condition more common than the wording suggests.


1. How the Flight protocol works

RSC doesn't render a screen and send it all at once — it streams the result as a sequence of rows. This stream can carry far more than JSON: Promises, module references, Server Function references, Blobs, and typed arrays, all as "live" JavaScript concepts. That expressiveness is the whole point of Flight's design.

Rows come in a few flavors:

1:I["victim-module",[],"*"]
2:o4,ABCD
0:{"msg":"hello","mod":"$1","ta":"$2"}
Enter fullscreen mode Exit fullscreen mode
  • Text rows (0:{...}) — newline-terminated JSON. Sigil-prefixed strings like $1 or $2 point at other rows (ids 1 and 2).
  • Module reference rows (1:I[...]) — tell the client to resolve an actual module through the bundler. In a webpack build, this ends in __webpack_require__(id), and that module runs.
  • Binary rows (2:o4,ABCD) — o is the type tag (Uint8Array), 4 is the hex-encoded byte length, and ABCD is the payload. The parser consumes exactly that many bytes as data, no questions asked. There's no delimiter like a newline — the declared length is the boundary.

The critical point: module reference rows get executed; binary rows get consumed. If the boundary between the two ever slips, bytes that were supposed to be inert data can masquerade as an executable row instead.

Next.js 16's Cache Components feature adds a new trust boundary on top of this. A function marked "use cache" has its return value serialized through this same Flight format, stored in a cache entry, and later deserialized back inside the Node.js server process on subsequent requests. The server is parsing something it wrote itself — except part of that data came from the requester.


2. Root cause — a header that promises bytes that no longer exist

The mechanism sits at the intersection of ArrayBuffer detach semantics and the Web Streams API.

A single ArrayBuffer can back multiple typed-array views. But a byte stream transfers — and thereby detaches — the entire ArrayBuffer of every chunk it receives. The patch commit's own comment spells this rule out explicitly.

The vulnerable serialization code looked roughly like this:

// 19.2.8 (vulnerable) — simplified typedArrayToBinaryChunk
function typedArrayToBinaryChunk(content) {
  return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
  // No copy! This view still points at the original shared buffer.
}
Enter fullscreen mode Exit fullscreen mode

Any typed array larger than 2048 bytes (VIEW_SIZE) gets wrapped in a view over the original buffer, with no copy, and written straight to the destination. The trouble starts when two large views share one buffer.

Example: an 8192-byte ArrayBuffer shared by two 4096-byte views

view A (offset 0–4095)   →  write(A)  →  entire buffer is detached
view B (offset 4096–8191) → B.byteLength is now 0 (buffer is empty)
Enter fullscreen mode Exit fullscreen mode

Instrumenting the write path during serialization confirms it:

19.2.8 (vulnerable): write: byteLength=4096  bufferByteLength=8192   ← live shared buffer
19.2.9 (fixed):      write: byteLength=4096  bufferByteLength=4096   ← exact-size copy
Enter fullscreen mode Exit fullscreen mode

But by the time view B is processed, the serializer has already written its framing header. It already promised "4096 bytes follow" — and then, because the buffer was detached, it actually delivers a 0-byte payload. Inside writeChunk, a silent guard made this worse:

// vulnerable writeChunk skip logic
if (chunk.byteLength === 0) return;  // silently skipped — no error, no log
Enter fullscreen mode Exit fullscreen mode

The header promises 4096 bytes. The body delivers zero. That mismatch gets silently baked into the stream.

A Node.js-specific wrinkle: Node serves any Buffer allocation smaller than Buffer.poolSize >>> 1 (half of the default 8 KiB) from an internal slab that's shared across multiple unrelated Buffers. In other words, even without an application explicitly writing new Uint8Array(sharedBuffer, ...), an ordinary small Buffer allocation is already structurally "a view over a shared buffer." React's advisory calls this "extremely rare," but the actual exposure surface is likely broader than that phrasing implies.


3. How the parser ends up trusting the wrong bytes

The Flight client parser is a small state machine: read the row id up to a colon, read a tag byte, then either scan to a newline or consume an exact byte count. The fatal detail is this:

The parser only trusts the length declared in the header. Whatever follows the colon, it will blindly consume that many bytes as "data."

Here's the shape of a crafted stream:

2:o1000,<4096 bytes arrive>
3:o1000,                                ← STARVED: 4096 promised, 0 delivered
4:{"more":"rows continue"}\n            ← entirely swallowed as row 3's payload
5:o2000,<8192 bytes of attacker content>
        ^ parser resynchronizes inside this payload
Enter fullscreen mode Exit fullscreen mode

Three details make this chain work.

  1. The starved row itself doesn't block the tree — the swallowed row does. Row 3 resolves as a Uint8Array over the swallowed bytes: wrong, but valid. Meanwhile row 4, whose header was itself eaten, never resolves, and anything referencing it just hangs. That's the opening.
  2. Padding is part of the exploit. Filler bytes like 0x43 (the character C) also happen to be valid hex digits for a row id. If the padding lands wrong, it glues itself onto the next row id and corrupts parsing from the root. So the attacker ends the crafted blob with a tiny, valid row like 5:0\n, making the parser resynchronize at exactly the next legitimate byte.
  3. Module chunks initialize lazily. A module reference row only executes once something consumes that chunk — so the planted row has to be reachable from something the application actually reads. Root references are the natural vehicle. Row ids are assigned sequentially within a single render, which made them predictable in testing (cross-request predictability wasn't verified).

4. The three-stage exploit chain, from the public PoC

briskets/CVE-2026-126187-poc is dependency-free Python 3.8+, reproducing the full chain against a lab running Next.js 16.4.0, React 19.3.0, and Node 22.

4.1 Harvesting a target Server Action

Next.js compiles each Server Action to a hex ID that ships straight into the public client bundle — the browser needs it to reference the action.

# What the PoC does, conceptually:
# Search the page HTML and client chunks for either pattern:
#   "$ACTION_ID_..." or
#   createServerReference(..., "<hex_action_id>")
# → No authentication needed at all — it's public by design.
Enter fullscreen mode Exit fullscreen mode

4.2 Solving the byte-alignment problem

Where the forged rows need to land depends on how many bytes get swallowed (the over-read size) — but that calculation is circular.

# padding = over_read_size - len(header_string)
# But changing the payload length can change how many hex digits
# header_string needs, which shifts the target offset by a byte.
#
# → The PoC resolves this with a fixed-point loop that iterates
#    until padding and header length agree with each other.
Enter fullscreen mode Exit fullscreen mode

For the lab's layout (chunk id 4, an 8192-byte over-read, an 8-byte header), the forged rows land exactly at offset 8184.

4.3 Using a thenable to trigger automatic execution

The payload planted at that exact offset is three Flight rows:

6:["id; uname -a"]
5:{"id":"<discovered_action_hex_id>","bound":"$@6"}
4:{"then":"$h5"}
Enter fullscreen mode Exit fullscreen mode
  • Row 6: the argument array the attacker wants to pass.
  • Row 5: Server Reference metadata — id is a genuine, registered Server Action ID, and bound pre-binds the arguments from row 6.
  • Row 4: overwrites the "attacker-influenced" cached chunk with an object carrying a then property. The $h tag points at the server reference.

The trigger is JavaScript's own specification. Under ECMA-262's PromiseResolveThenableJob, any object with a callable then property is treated as a thenable, and awaiting or resolving it auto-invokes then(resolve, reject). The moment the runtime unwraps the cached value, the bound Server Action — arguments and all — gets called automatically.

This chain never walks a prototype chain and never touches the Function constructor. It simply uses the protocol's own reference-resolution machinery, "correctly," to call a legitimately registered server function. That's exactly why hardening property lookups alone wouldn't have stopped it.

Two details that make this worse in the real world:

  • The entire attack rides a plain GET /?annotation=<base64> request. No POST body, no Next-Action header. Any WAF rule or middleware that only inspects POST traffic for Server Action calls never sees this.
  • The blast radius is whatever the targeted Server Action does with its arguments. The lab demo pipes the argument into /bin/sh, but a real application's action might write to a database, touch the filesystem, or call an internal service.

5. How the patch closed the hole

The fix shipped as PRs #37842–#37846 ("[Flight] Improve transport protocol"), and rather than patching a single offset calculation, it adds defenses at multiple independent layers.

Serialization (write) side

// 19.2.9+ — large views that don't span their entire buffer are always copied
function typedArrayToBinaryChunk(content) {
  if (content.byteLength > VIEW_SIZE && !spansEntireBuffer(content)) {
    return content.slice(); // copy — no longer shares the original buffer
  }
  // On older environments lacking ArrayBuffer.prototype.detached
  // (Node < 21, older browsers), even full-span views are defensively sliced
}
Enter fullscreen mode Exit fullscreen mode

And the silent skip is gone:

// after the fix — throws immediately instead of silently dropping the payload
if (chunk.byteLength === 0 && isDetached(chunk.buffer)) {
  throw new Error(
    "Cannot transport a detached buffer. This usually happens when " +
    "trying to transport multiple buffer-spanning views on the same " +
    "underlying buffer. Either reuse the exact same view by identity " +
    "or copy the underlying buffer manually via view.slice()."
  ); // error 612
}
Enter fullscreen mode Exit fullscreen mode

Deserialization (read) side

  • ReactFlightClient.js introduces a module-private RESPONSE_SYMBOL, moving the parser's internal state behind a symbol that's unreachable through ordinary property access.
  • A new ReactFlightReplyBackingFormData.js replaces raw FormData lookups with an indexed backing store that separates key spaces by kind — entries prefixed _ are referenced FormData entries, S marks stream/async-iterable chunks, everything else is a one-shot chunk. A chunk's kind is now determined by where it's stored, not by a key name the attacker chose.
  • createModel now returns null whenever a then property holds a function — shutting down the thenable pivot at parse time, even if a forged row manages to land again.
  • loadServerReference rejects any metaData.id that isn't a string before it ever reaches resolveServerReference or requireModule.

The fastest way to confirm a build is patched is to grep the compiled bundle for a new helper:

grep -c getBackingBlob \
  node_modules/next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js
# 0 = vulnerable code path; 1 or more = patched backing-blob logic present
Enter fullscreen mode Exit fullscreen mode


6. Affected versions and what to do

Package Affected range Fixed in
react-server-dom-webpack 19.0.0–19.0.8, 19.1.0–19.1.9, 19.2.0–19.2.8, 19.3.0 19.0.9 / 19.1.10 / 19.2.9 / 19.3.1
react-server-dom-parcel same same (though 19.0.9 was never actually published for this package on npm — use 19.1.10+)
react-server-dom-turbopack same same

Most applications pull these packages in transitively, through a framework like Next.js — which creates a common mistake.

Upgrading react/react-dom alone is not enough. Next.js vendors a compiled copy of the Flight runtime directly into the framework itself, so next must be upgraded to 16.4.1 (Active LTS) or 15.5.28 (Maintenance LTS) or later for the actual fixed code to take effect.

Three artifacts need to be checked, and they can disagree with each other:

# 1) What the lockfile resolves to
npm ls react-server-dom-webpack react-server-dom-parcel react-server-dom-turbopack
grep -E 'react-server-dom-(webpack|parcel|turbopack)' package-lock.json

# 2) What's actually compiled into the deployed image
grep -c getBackingBlob node_modules/next/dist/compiled/react-server-dom-webpack/cjs/*.production.js
Enter fullscreen mode Exit fullscreen mode

The trigger condition requires the App Router with Cache Components, where a use cache function returning multiple typed arrays sits on a page that can resolve a Server Action. If upgrading isn't immediately possible, interim mitigations include:

  • Refactoring use cache functions so they never return multiple typed arrays sharing one buffer
  • Encoding binary return values as strings so they serialize as plain text chunks instead
  • Adding argument validation and authorization checks to every "use server" function (this attack calls the action directly, bypassing any UI flow that might otherwise gate it)

From a detection standpoint, watch for unusually long base64 query parameters on GET requests to cached pages, decoded tails containing Flight row syntax such as "bound":"$@ or "then":"$h, and spikes of Connection closed. 500 errors on cached routes (a sign of failed offset guessing).

Top comments (0)