React Server Components Just Got an Unauthenticated RCE: Breaking Down CVE-2026-126187

The CyberSec Guru

CVE-2026-126187: Next.js React Flight RCE

If you like this post, then please share it:

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Why your support matters: Zero paywalls: Keep the main content 100% free for learners worldwide.

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

A silent patch, then a 9.5 CVE: timeline of the Next.js RCE disclosure

On October 8, 2026, the Next.js team gave the ecosystem a week’s notice that an out-of-band security release was coming: two critical issues, one high-severity issue, and two fixes that had been delayed from September because of upstream dependencies. Six days later the patches landed. React published advisory GHSA-xqc2-xf4c-x3h3 and backported fixes into four release lines at once, 19.0.9, 19.1.10, 19.2.9, and 19.3.1, across react-server-dom-webpack, react-server-dom-turbopack, and react-server-dom-parcel. Vercel shipped Next.js 16.4.1 on the Active LTS line and 15.5.28 on Maintenance LTS the same day.

Most teams reading release notes between sprints would have filed this under routine maintenance. On November 10, the CVE record told a different story: CVE-2026-126187, an unauthenticated remote code execution flaw in React Server Components, CVSS v4.0 base score 9.5. Within a day, a working proof of concept with a reproducible Docker lab was on GitHub under briskets/CVE-2026-126187-poc.

The number worth sitting with isn’t the 9.5. By cve.now’s November 11 read of npm, 73% of react-server-dom-webpack downloads in the preceding week (2.7 million of 3.8 million) were still on versions inside the affected ranges, nearly a month after the fix shipped. Part of the problem is that the bug is structurally hard for tooling to catch: it had no CVE for its first four weeks, and in Next.js applications the vulnerable code sits inside a compiled copy of React vendored into the framework itself, where a plain dependency scan for react-server-dom-webpack comes back clean.

What follows is a reconstruction of the flaw from the advisories, the actual patch diffs, and the public PoC.

How React Flight turns a cache entry into an attack surface

React Server Components move rendering to the server and stream the result to the client through Flight, a wire protocol built to carry far more than JSON. A Flight stream is a sequence of numbered chunk rows, and the format can reconstruct promises, module and server-function references, Set and FormData instances, binary blobs, and typed arrays, with sigil-prefixed strings inside JSON payloads pointing at other chunks ($@6, for instance, points at the raw chunk object for id 6). That expressiveness is the whole point: a server component can hand the client a streaming binary payload or a pending promise without custom glue code.

Next.js 16’s Cache Components change the trust picture. When a function marked use cache runs, its return value is serialized through this same Flight transport, stored as a cache entry, and deserialized back inside the Node.js server process on later requests. Ostorlab’s analysis of the advisory spells out the consequence: the deserializer now handles data whose origin is a cache entry, and the GitHub Advisory Database classifies the flaw as CWE-502, deserialization of untrusted data. React’s own advisory describes the trigger as “extremely rare cases where responses transport multiple typed arrays and an attacker-controlled value,” scoped to applications whose React code uses a server and a framework, bundler, or bundler plugin supporting RSC. Put those together and the shape of the bug appears: the server parses its own serialized output, part of which the requester supplied, and the parser trusts the framing of the stream it wrote.

Root cause: a length header for bytes that no longer exist

The mechanism sits at the intersection of ArrayBuffer view semantics and the Web Streams API. One ArrayBuffer can back several typed-array views, and the patch’s own comments state the rule that makes this dangerous: “A byte stream transfers, and thereby detaches, the whole ArrayBuffer of every chunk it receives.” When a view is enqueued into a byte stream controller, the stream takes ownership of the entire underlying allocation, its byteLength drops to zero, and every other view over that allocation becomes an empty husk pointing at a detached buffer.

In the vulnerable Node stream configuration, typedArrayToBinaryChunk returned new Uint8Array(content.buffer, content.byteOffset, content.byteLength) with no copy, and chunks larger than the internal VIEW_SIZE threshold (2,048 bytes in the Node and Edge stream configs) were written straight to the destination. So when a cached response carried two large views over one shared buffer, the first write detached the buffer and the second view reported zero length. The serializer had already emitted that second view’s framing header, promising its original byte count. writeChunk then hit its if (chunk.byteLength === 0) return; path and silently skipped the payload. Upwind’s patch analysis puts it plainly: the server could announce bytes it never sent, and the reader would trust the wrong bytes anyway.

When the cached stream is read back, the decoder honors the declared length, consumes that many bytes from whatever follows (the chunk holding the attacker-influenced value), and resumes row parsing at that offset, in the middle of caller-supplied bytes. The serializer wrote a hole into the stream, and the deserializer treats the hole as protocol. It’s framing injection, the same bug class as HTTP request smuggling, happening entirely inside React’s serialization layer. A minimal shape that satisfies the advisory’s trigger condition:

js

📬 Stay Ahead of Cyber Threats

Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.

// Illustrative pattern matching the advisory's trigger: two large views
// over one shared buffer, returned alongside a requester-controlled value.
async function getCachedPayload(annotation) {
"use cache";
const shared = new ArrayBuffer(16384);
return {
headerBlock: new Uint8Array(shared, 0, 8192),
bodyBlock: new Uint8Array(shared, 8192, 8192), // detached by the first write
annotation, // attacker-controlled bytes from the request
};
}

The “extremely rare” qualifier deserves a second look, because the patch explains why the condition is less exotic than it sounds. A comment added to the Node stream config notes that Node serves allocations below Buffer.poolSize >>> 1 from an internal view it shares between unrelated Buffers, so a Buffer under that size is itself such a view. With Node’s default 8 KiB pool, ordinary small Buffer allocations are partial views over a shared slab by construction. An application doesn’t need to write new Uint8Array(sharedBuffer, ...) explicitly for shared-buffer views to show up in a cached return value, which is why Vercel told everyone on an affected version to upgrade even if they believed no such function existed in their codebase.

From over-read to shell: the three-row exploit chain

An over-read is just a parsing error until something executes it. The public PoC, dependency-free Python 3.8+, with a Docker lab running Next.js 16.4.0 and React 19.3.0 on Node 22, turns it into unauthenticated code execution through a three-stage chain documented in the repository’s README.

Harvesting a target function. Server Actions compile to hexadecimal identifiers that ship in public client bundles so the browser can reference them, and the PoC finds them by reading the page and its client chunks for $ACTION_ID_… patterns or createServerReference(..., "<hex>") calls. No authentication required, since these identifiers are public by design.

Solving the byte-alignment problem. The forged rows need to land exactly where the decoder’s read cursor ends up after it finishes consuming the missing payload, and the padding calculation is circular: padding equals the over-read size minus the length of the annotation chunk’s own header (<chunk>:o<hexlen>,), but changing the payload length can change how many hex digits that header 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. For the lab’s layout (chunk id 4, an 8,192-byte over-read, an 8-byte header), the forged rows land at byte offset 8184, exactly as the tool reports.

The payload. Three newline-delimited Flight rows, placed at that offset inside the base64-encoded annotation query parameter:

text

6:["id; uname -a"]
5:{"id":"<discovered_action_hex_id>","bound":"$@6"}
4:{"then":"$h5"}

Row 6 defines the attacker’s argument array. Row 5 defines server-reference metadata whose id is a real, registered Server Action and whose bound field pre-binds the arguments from chunk 6. Row 4 overwrites the cached caller-influenced chunk with an object whose then property resolves, through the $h server-reference tag, to that bound server function. The trigger is JavaScript itself: under the ECMA-262 PromiseResolveThenableJob operation, any object with a callable then property is a thenable, and awaiting or resolving it invokes then(resolve, reject) automatically. When the runtime unwraps the cached value, it calls the bound Server Action with the attacker’s arguments already attached. The exploit never walks a prototype chain and never touches the Function constructor; it invokes a legitimate, registered server function through the protocol’s own reference-resolution semantics, which is why hardening property lookups alone wouldn’t have stopped it.

Two details make this worse than a typical RSC exploit. The whole chain rides a plain GET /?annotation=<base64> request, no multipart POST body, no Next-Action header, so middleware and WAF rules built to inspect POST traffic for Server Action invocations never see it. And the ceiling on impact is whatever the application’s actions do with their arguments: the lab’s action pipes its argument through /bin/sh for the demo, but a real application’s action might write to a database, touch the filesystem, or call an internal service, which is why the PoC’s --args-json flag exists, to match whatever argument shape a real action expects. For targets with an unknown chunk layout, --scan iterates common chunk ids (1 to 8) and view sizes. Against the vulnerable lab, the action’s output renders in the page; a GET /api/payload desync probe returns 500 {"ok":false,"error":"Connection closed."}; against a lab rebuilt on Next.js 16.4.1, the annotation blob stays opaque and nothing runs.

Why CVSS scored this 9.5: reading AT:P correctly

Both advisories carry the identical vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:N. Network-reachable, no privileges, no user interaction, high confidentiality, integrity, and availability impact on the vulnerable system, plus high confidentiality and integrity impact on subsequent systems, a formal way of saying the Node process holds database credentials and internal network access. Once the target’s chunk layout is known, the exploit is deterministic, hence Attack Complexity Low. The metric doing the real work is AT:P, Attack Requirements Present, which CVSS v4.0 defines as prerequisite deployment and execution conditions whose primary purpose isn’t defense. Here, that prerequisite is a code shape: per the Next.js advisory, Next 16.0.0 through 16.4.0 with the App Router and Cache Components, containing a use cache function that returns multiple typed arrays, on a page that can resolve a Server Action.

That prerequisite is a property of the target’s code, not a control defenders can rely on, and it can arrive through a dependency update or a routine refactor. The exposure also isn’t confined to Next.js: any framework or custom setup built on the three affected RSC packages is in scope, and React on Rails, for one, shipped a release pulling in the fixed packages. For scale, cve.now’s November 11 npm read put affected-version shares at 73% for react-server-dom-webpack (2.7M of 3.8M weekly downloads), 44% for react-server-dom-turbopack (25.8K of 58.6K), and 40% for react-server-dom-parcel (10.4K of 26.4K), with the caveat that download counts include CI runs and mirrors, not just installs. Ostorlab measured next itself at 44.6 million downloads for the week of October 16-22. As of this writing there are no public reports of in-the-wild exploitation and the CVE isn’t in CISA’s KEV catalog; cve.now scores it a provisional 61/100 and notes the score would jump to roughly 89 if confirmed exploitation landed it in KEV. Public exploit code exists, and the gap between “rare trigger” and “unpatched at scale” is exactly where the risk sits.

Inside the patch: how React closed the Flight deserialization hole

The fix landed as pull requests through on the React repository, with the transport-protocol overhaul in , authored by eps1lon, reviewed by gnoff, merged into main as commit 0c9913a on October 14. The diffs touch 17 files, and 249ac87 carries the same work into the 19.1.10 backport line. The patch defends the boundary at several independent layers rather than fixing one offset calculation.

On serialization, typedArrayToBinaryChunk now copies any view larger than VIEW_SIZE that doesn’t span its entire backing buffer, or whose buffer is resizable, using view.slice() so each directly-written chunk owns its allocation and can’t detach a sibling. In environments lacking ArrayBuffer.prototype.detached (Node.js below 21, older browsers), it defensively slices even full-span views and logs a development warning about the performance cost. The silent zero-length skip in writeChunk is gone: if a chunk reports zero length and its buffer is detached, the runtime now throws error 612, “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().” A desync now fails loudly at write time instead of silently poisoning a cache entry.

On deserialization, ReactFlightClient.js introduces a module-private RESPONSE_SYMBOL and stores the live internal response object behind that symbol on a holder object, where a resolved model chunk previously carried it as a directly reachable property, putting the parser’s own state out of reach of ordinary property traversal. A new ReactFlightReplyBackingFormData.js replaces raw lookups against the live FormData with an indexed backing store that separates key spaces by kind: entries prefixed _ are referenced FormData entries consumed in insertion order, entries prefixed S are stream and async-iterable chunks, and everything else is a one-shot chunk or outlined blob looked up by exact name. A chunk’s kind now follows from where it’s stored rather than from an attacker’s choice of key. Ostorlab’s reading of the corresponding Next.js commit a4cea6a (“Improve Server Component transport protocol,” which replaces the bundled RSC runtimes across all twelve build variants) documents two more guards in the vendored code: createModel now returns null when a property named then holds a function, killing the thenable pivot at parse time even if forged rows ever land again, and loadServerReference rejects non-string metaData.id values before anything reaches resolveServerReference or requireModule. Typed-array backing reads now route through a getBackingBlob helper, which turns out to be the single most useful patch marker to grep for in compiled artifacts.

Next.js consumed the fixed React canary (99c106c4-20261014) through vercel/next.js#100215, merged October 15. The PR’s cross-references record a version-ordering trap worth knowing: canary builds numbered 16.5.0-canary.0 through .13 still vendored pre-fix React despite sorting above 16.4.1, and 16.5.0-canary.14 was the first build cut after the fix. Version comparison alone was never sufficient evidence of patch status here.

How to check if you’re exposed, and what to do about it

Neither vendor offers a workaround. The Next.js advisory says to upgrade immediately even without the triggering functions present, and the same release also closes the Image Optimization SVG RCE (CVE-2026-96889, via librsvg, exploitable on glibc-based Linux, with sharp 0.35.5 as an interim mitigation) plus a Server Actions CPU-exhaustion DoS, so the upgrade pays for itself twice. For Next.js, upgrade next itself to 16.4.1 or later (15.5.28 covers the maintenance line for the rest of the batch). Bumping react and react-dom alone leaves the vendored Flight runtime untouched, which is the single most common wrong fix for this bug. Outside Next.js, move the RSC packages to a fixed release in their line, with one registry quirk: although the advisory names 19.0.9 for all three packages, react-server-dom-parcel never published a 19.0.9 on npm (its version history jumps from the 19.0.x series straight to the 19.1.10 backport, tagged backport), so Parcel users on 19.0.x need 19.1.10 or higher.

Verification has to cover three artifacts that routinely disagree: the lockfile’s resolved versions, the compiled bundle inside the deployed image, and the running build. The PoC README offers a fast check of the second, grepping the vendored production runtime for the fix marker:

bash

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; > 0 = fixed backing-blob logic present

Pair that with npm ls next in every repository and image, and remember that preview and branch deployments keep serving the old bundle until they’re redeployed or deleted. On the code side, grep -n "cacheComponents" next.config.* and a project-wide search for the use cache directive tell you whether you’re anywhere near the trigger. If an upgrade is genuinely blocked, the PoC’s own guidance is to remove cached functions that return multiple typed arrays alongside attacker-controlled values, or encode binary returns as strings so they serialize as ordinary chunks, and to audit every use server function for in-body authorization and argument validation, since this exploit calls actions outside any UI flow that might have gated them. None of that substitutes for the upgrade.

For detection, the exploit leaves an unusual signature: very long base64 query parameters on GET requests to cached pages, where decoded tails reveal Flight row syntax such as "bound":"$@ and "then":"$h, sequences legitimate browser traffic has no reason to carry in a query string. Misaligned probing (the PoC’s scan mode) corrupts the stream parser and produces spikes of 500 responses with “Connection closed.” errors on cached routes ahead of any successful hit, and at the runtime layer, a Node server process spawning a shell or unexpected child process deserves an immediate look. One footnote explains why scanners may have slept through October: the advisories carried no CVE until November 10, the Next.js entry only entered the GitHub Advisory Database as reviewed on October 22, and npm audit stayed quiet in the early days, so tools keyed on CVE feeds saw nothing while 2.7 million weekly downloads kept pulling vulnerable code.

React2Shell to CVE-2026-126187: the second deserialization RCE in a year

This is the second critical deserialization RCE in the same three packages within eleven months. React2Shell (CVE-2025-55182) was reported by Lachlan Davidson through Meta’s bug bounty on November 29, 2025 and disclosed December 3 with a CVSS 10.0 rating, affecting versions 19.0, 19.1.0, 19.1.1, and 19.2.0, fixed in 19.0.1, 19.1.2, and 19.2.1. Its Next.js twin, CVE-2025-66478, was later rejected as a duplicate because both shared the same root cause in Flight payload handling. That bug worked against default setups, and the response was immediate: AWS documented China-nexus groups exploiting it within hours of disclosure, GreyNoise logged 95 IPs running opportunistic automated attempts from December 5, CISA added it to KEV that same day, and reporting at the time put close to 40% of cloud environments in range.

CVE-2026-126187 is a narrower bug, and treating it as a React2Shell repeat would be wrong. The resemblance is architectural, not procedural. Flight was designed to move live execution concepts (promises, bound closures, module references, binary views) across network and cache boundaries, and the security history of expressive deserializers, from Java’s ObjectInputStream to Python’s pickle, says that every feature of that kind enlarges the set of legal-but-dangerous inputs the parser has to reject. When length-prefixed binary framing and executable reference sigils share one stream, a specification-mandated behavior like byte-stream buffer detachment becomes a framing injection vector, and when the host language auto-invokes any callable then, framing injection becomes execution. The October patch reads like an acknowledgment of exactly this: it strips callable then properties from rebuilt models, type-checks reference identifiers, hides parser state behind private symbols, and separates binary and text key spaces so one namespace can’t corrupt another. That’s structural defense, not offset-chasing, and it’s the right shape.

For anyone running React 19 and Next.js 16, the practical mandate fits in one sentence: make the lockfile, the compiled bundle, and the running image agree that the Flight code in production is the one containing getBackingBlob. The alternative is the one that lets the caller decide where parsing resumes.

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Your contribution powers free tutorials, hands-on labs, and security resources.

Why your support matters:
  • Writeup Access: Get complete writeup access within 12 hours
  • Zero paywalls: Keep the main content 100% free for learners worldwide

Perks for one-time supporters:
☕️ $5: Shoutout in Buy Me a Coffee
🛡️ $8: Fast-track Access to Live Webinars
💻 $10: Vote on future tutorial topics + exclusive AMA access

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

If you like this post, then please share it:

Exploits

Discover more from The CyberSec Guru

Subscribe to get the latest posts sent to your email!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.