Sanitization, Nginx JavaScript module e gli incubi da virtual patching

In questo articolo voglio parlarvi di nginScript (Nginx JavaScript module, meglio conosciuto come njs), di virtual patching e bypass sfruttando stupide features PHP

Sanitization, Nginx JavaScript module e gli incubi da virtual patching

In questo articolo voglio parlarvi di nginScript (Nginx JavaScript module, meglio conosciuto come njs), un modulo di Nginx che ne espande le potenzialità grazie a JavaScript. Voglio prendere l'occasione al balzo per parlare anche di Sanitization, di Virtual Patching, di come effettuare bypass in PHP solo agendo sul nome dei parametri della querystring, andando a toccare particolari che solitamente non interessano a nessuno, e quindi mi sembrava un'ottima idea annoiarvi in questo post.

nginxScript è un'implementazione di JavaScript per Nginx pensata, principalmente, per operazioni server-side e per processare request e response HTTP. Njs non si limita a questo, infatti può anche essere utilizzato per funzionare all'interno di contesti nginx stream su protocollo TCP e UDP. Le potenzialità sono tendenti all'infinito e la scelta di JavaScript permette anche a utenti meno esperti di poter espandere le funzionalità di nginx.

Sul post di presentazione del progetto, vengono proposti alcuni esempi di utilizzo che spaziano dal virtual patching, al load balancing fino alla definizione di custom log. Ammetto che la prima cosa su cui mi sono concentrato è stata proprio la parte di virtual patching (nonostante il termine provochi in me pruriti distribuiti e nausea).

Prima di pigiare sul pulsante "accensione" del motore a polemiche scontate, inizio affermando che concordo pienamente con il concetto per cui il "virtual patching" non sia una soluzione concreta al problema e che l'unico modo sensato di correggere una vulnerabilità consista nell'intervento sul codice stesso.

In questo caso stiamo parlando di una soluzione alternativa in contesti in cui non è possibile modificare il codice o, come accade spesso, non lo si può fare in un tempo utile ad evitare che "exploits in the wild" violentino la web application del nostro cliente.

Se non vi venisse in mente nessun contesto del genere, sappiate che avreste la mia totale invidia, la vostra vita lavorativa sarebbe sicuramente migliore della mia sotto questo punto di vista.

Perché virtual patching è una parola proibita?

Credo che "virtual patching" derivi da una parola indiana che significa "o mio dio, no". Si tende a sconsigliare il virtual patching perché, in alcuni casi, le possibilità di elusione e bypass sono tante a tal punto che, al lato pratico, non si riesce ad avere il totale controllo sullo user input. Sono abbastanza d'accordo con questo concetto ma vi prego di considerare due aspetti molto importanti:

  • Spesso, il virtual patching è usato per guadagnare tempo nell'attesa di un fix o quando la modifica del sorgente non è fattibile (per motivi tecnici o burocratici). Bloccare la maggior parte dei payload e restringere il raggio di azione è già un grande successo.
  • Si parla sempre di bypass di regole, ma non si parla mai dei vantaggi che un set di regole può apportare. Oltre a questo non bisogna mai dimenticare che l'esperienza e la conoscenza del linguaggio e delle tecnologie che compongono la web application gioca un ruolo fondamentale per chi deve eseguire un virtual patching.

Come accade per le regole WAF, IMHO il segreto è non fare "virtual patching fatto in casa" ma attingere sempre dalla community. Vi faccio un esempio pratico che vi convincerà ancor di più che le vulnerabilità si risolvono sul codice dell'applicazione:

PHP e le sue "features"

Mettiamo caso che lo script news.php del sito del comune di "Mazzate sul naviglio" abbia il parametro news_id vulnerabile a SQLi. Classico, no? Il comune di Mazzate sta aspettando il benestare e i fondi della regione per aggiornare il sito (vecchio e mai aggiornato) e, nel frattempo, vuole che noi della ACME s.r.l. (Andrea Cyber Menin Enterprise) gli risolviamo il problema senza toccare il codice della webapp. Fortunatamente il cliente può modificare il record DNS del sito e far transitare il traffico attraverso un nostro WAF. Oltre a questo può impostare una regola di firewall per accettare connessioni al sito solo dall'IP del nostro server.

Ora, la cosa più semplice che possiamo pensare di fare per risolvere il problema è creare una regola che accetti solo request verso news.php con il parametro news_id numerico. Proviamo (semplificando la sintassi ModSecurity):

SecRule ARGS:news_id "!@rx ^[0-9]+$" "id:1,deny"

Questa regola controlla con una regex che il contenuto di news_id sia formato da numeri da 0 a 9. Questo blocca request tipo /?news_id=1+OR+1=1-- perché il contenuto del parametro non è un numero da 0 a 9. Purtroppo PHP, che non è nostro amico, permette di bypassare questa regola in modi che noi umani possiamo solo immaginare.

bypass 1: null byte

un semplice /?news_id%00=... fa si che la nostra regola non riesca ad intercettare il parametro e non blocchi la request.

bypass 2: whitespace no grazie

PHP rimuove automaticamente gli spazi all'inizio dei parametri della querystring. Perché lo fa? Perché PHP tenta disperatamente di trasformare il contenuto della querystring in qualcosa di valido e che possa essere utilizzato come variabile. Questo significa che spazi all'inizio di un parametro vengono rimossi, e che possiamo bypassare la regola con /?%20news_id=...

bypass 3: convertire caratteri speciali in underscore

Per lo stesso motivo del bypass 2, caratteri come spazio, punto, parentesi quadra, ecc... vengono trasformati in underscore. Questo vuol dire che la request /?news%5bid=... può eludere la regola.

Riepilogando:

  • news_id%00
  • %20news_id
  • news[id
$ curl -s 'http://localhost/test.php?news_id1=originale&%20news_id2=spazio&news_id3%00=null+byte&news\[id4=replace+\[+with+_'

Array
(                                                                             
    [news_id1] => originale
    [news_id2] => spazio
    [news_id3] => null byte
    [news_id4] => replace [ with _
)

Una regola più sensata potrebbe essere qualcosa di molto simile a:

SecRule ARGS:/news.id/ "!@rx ^[0-9]+$" "id:1,deny"

Sanilidatzion?

Ho notato che si tende a confondere, o usare impropriamente, il termine "sanitization". Molto spesso, infatti, leggo "input sanitization" quando si vuole intendere "validation" e viceversa. Cercherò invano di fare chiarezza:

Validation: regole e criteri che un dato "user input" deve rispettare prima di essere processato e utilizzato. Potremmo affermare che l'azione prevista a valle della validazione dovrebbe essere "disruptive" ossia: il dato viene rifiutato/rimosso o, nel caso citato da Nginx usando ModSecurity, l'intera request HTTP viene bloccata.

Sanitization: prevede che il dato inviato dall'utente passi attraverso un pre-processing alla fine del quale subisca sostanziali modifiche secondo vari criteri. Un esempio pratico di differenza tra input validation e sanitization, potrebbe essere quello messo a disposizione dalla funzione PHP filter_var:

In questo esempio vediamo la differenza tra FILTER_VALIDATE_EMAIL e FILTER_SANITIZE_EMAIL i cui nomi mi sembrano abbastanza esplicativi:

Come da screenshot, la prima funzione controlla il formato della mail pippo(/;id/)@example.com e il risultato è che il dato viene sostituito con "false" (bool) perché ritenuto un indirizzo e-mail non valido. La seconda funzione trasforma l'input dell'utente rimuovendo tutto ciò che non è lettere, numeri e simboli !#$%&'*+-=?^_`{|}~@.[]. Il risultato è che l'indirizzo e-mail viene trasformato in un formato valido e utilizzabile.

Obiettivo

Voglio usare nginScript per creare un processo di input sanitization su di uno script PHP che accetta un indirizzo e-mail sul parametro mail e invia un messaggio. Lo script di test che userò sarà qualcosa del genere:

<?php
    print_r($_GET);
    
    echo "E-mail sent to: ".$_GET["mail"];
    
    // mail(...);

Installazione

partendo dall'immagine docker ubuntu:latest, questi sono i passaggi che potreste fare per una situazione nginx+njs funzionante:

cd /opt/
apt-get update

# installo il necessario per compilare nginx
apt-get install build-essential \
        mercurial \
        wget \
        libpcre3 libpcre3-dev \
        zlib1g-dev

# scarico la nginx stable e estraggo il contenuto dell'archivio
wget http://nginx.org/download/nginx-1.16.0.tar.gz && \
   tar -zxf nginx-1.16.0.tar.gz

# clono il repo njs
hg clone http://hg.nginx.org/njs

# compilo nginx
cd nginx-1.16.0
./configure --add-module=../njs/nginx
make && make install

nginx.conf

http {
    ...
    
    js_include /usr/local/nginx/conf/sanitize.js;
    
    server {
        listen 80;
        
        proxy_redirect off;
        proxy_set_header Host $http_host;
        
        # intercetto test.php ed eseguo virtual_patch_test()
        location /test.php {
            default_type text/html;
            js_content virtual_patch_test;
        }
        
        # location post sanitization accessibile solo da loopback
        location /virtual-patch-test.php {
            allow 127.0.0.1;
            deny all;
            
            proxy_pass http://172.17.0.1:8888/test.php;
        }
        
        # per tutto il resto...
        location / {
            proxy_pass http://172.17.0.1:8888;
        }
    }
}

In breve, la configurazione di nginx prevede 3 location:

  1. /test.php in cui eseguiremo la funzione JavaScript virtual_patch_test() che si occuperà di trasformare il contenuto del parametro mail
  2. /virtual-patch-test.php che useremo per inviare le request "sanitizzate" verso /test.php (accessibile solo dalla loopback)
  3. / che farà proxy_pass verso tutto il resto

la riga 4 include il file sanitize.js tramite la funzione js_include, il cui contenuto consisterà nella funzione virtual_patch_test che andremo a creare, e che verrà eseguita alla riga 15 all'interno della location /test.php. Solo per test, faccio in modo che la funzione restituisca un semplice messaggio:

// contenuto di /usr/local/nginx/conf/sanitize.js

function virtual_patch_test(r) {
    r.return(200, "tutto ok qui\n")
}

Come si può vedere, la request HTTP viene passata all'interno del primo parametro. Potete trovare una lista di funzioni dell'oggetto r nella documentazione ufficiale all'indirizzo http://nginx.org/en/docs/njs/reference.html#http

virtual_patch_test()

function virtual_patch_test(r) {
   var newargs = ""
   for(var i in r.args) {
      if(decodeURIComponent(i).toLowerCase().replace('\0','').trim() == "mail") {
         var mail = decodeURIComponent(r.args[i])
         .replace(
             /[";$()]+/gi, 
             ''
         )

         newargs = newargs+"mail="+mail+"&sanitized=1&"
      } else {
         newargs = newargs+i+"="+r.args[i]+"&"
      }
   }

   r.subrequest("/virtual-patch-test.php", {
       "args":newargs.substring(0,(newargs.length-1)), 
       "body":r.requestBody, 
       "method":r.method
       }, 
       function(resp) {
          for(var i in resp.headersOut) {
             if(typeof(r.headersOut[i]) === "undefined") {
                r.headersOut[i] = resp.headersOut[i]
             }
          }

          r.return(resp.status, resp.responseBody)
       }
   )
}

Analizziamo per pezzi. Come prima cosa dobbiamo iterare tutti gli argument presenti nella querystring. Per fare ciò, eseguo un ciclo for (righe 3-15) per ogni parametro presente (automaticamente interpretato da Nginx e che njs espone nell'oggetto r.args). Qui si presenta il primo problema: r.args è read-only e non ci sono concesse modifiche, quindi non possiamo sostituire il contenuto di r.args["mail"]. L'unica opzione è quella di intercettare la request originale e crearne una nuova, sostituendo la parte di querystring con il risultato del nostro processo di sanitizzazione.

Per creare una nuova request partendo dall'originale inviata dal browser, njs ci mette a disposizione la funzione r.subrequest.

Alla riga 4 controllo che il nome del parametro sia mail e lo faccio trasformando la stringa in vari modi:

  • decodeURIComponent() converto ogni sequenza di escape nella parte di URI codificata con il carattere che la rappresenta.
  • toLowerCase() trasformo caratteri da A a Z nella loro versione minuscola
  • replace('\0','') elimino ogni null byte
  • trim() rimuovo ogni "whitespace" (spazio, tab, no-break space - che in italiano è "spazio unificatore", ecc...) all'inizio e alla fine

Perché eseguire decodeURI del nome del parametro? per un motivo molto semplice: evitare bypass tipo ?mai%6c=.... Molto importante è anche l'ordine in cui usare le due funzioni: se trasformassi in lowercase la stringa prima di eseguire decodeURI consentirei bypass tipo mai%4c => maiL (ammesso che l'applicazione/server web lo consenta).

Oltre a decodificare e trasformare tutti i caratteri nel corrispettivo "lowercase", rimuovo tutti i null byte con replace('\0',''). Anche in questo caso, faccio ciò per prevenire un possibile bypass. Infatti, cosa succederebbe se non eliminassi i null byte e inviassi la request in questo modo? /?mail%00=...

bypass con mail<null>=e-mail

Nota folkloristica: se avessi scelto mail_address come nome del parametro, in questo caso mi sarebbe bastato usare mail%20address=... per eludere il controllo. Questo perché PHP trasforma automaticamente gli spazi nei param-name in underscore...

Torniamo a noi. Alla riga 5 assegno il contenuto di mail alla variabile mail, anche in questo caso usando decodeURIComponent, sostituendo (o rimuovendo) ogni carattere " ; $ ( ). A questo punto alla riga 11 rigenero la querystring e la salvo nella variabile newargs.

Fatto ciò, ho completato il mio (banale e molto semplificato) processo di sanitization del contenuto di mail. Dalla riga 17 alla 31 creo la nuova subrequest verso la location /virtual-patch-test.php (vedi nginx.conf) utilizzando come querystring la variabile newargs e raccogliendo i response header inviati dall'upstream (righe 23-27).

Come da screenshot il risultato è che inserendo la mail pippo";$(id)@example.com viene automaticamente trasformata in pippoid@example.com.

Conclusione

Il vantaggio in tutto ciò consiste nel fatto che, anche non avendo possibilità di modificare il sorgente dell'applicazione vulnerabile, è possibile validare o sanitizzare l'input utente prima che esso vada a destinazione.

"I vendor mi dicono che la loro soluzione di virtual patching è affidabile ed è sempre la scelta migliore, è vero?" No! Il virtual patching deve essere concepito solo come temporaneo ed extremam rationem belli.

"Gli esperti di sicurezza mi dicono di evitare sempre e comunque il virtual patching perchè fa piú danni che altro... è vero?" No! chi dice ció sta facendo una stupida semplificazione e dimostra di non conoscere in profondità la tematica.