Salta al contenuto
Note per Studenti Esercizio - Buffer overflow nello stack e versione sicura (slide di sicurezza, parte 1)

Esercizio - Buffer overflow nello stack e versione sicura (slide di sicurezza, parte 1)

In questa pagina 5

Testo (programma bufferover.c e slide di sicurezza del corso di Sistemi Operativi, Ing. Informatica UniPD).

  1. Il programma bufferover.c dichiara due stringhe di 8 caratteri adiacenti, ne stampa il contenuto, poi scrive 15 caratteri '3' in str2 e stampa di nuovo. Cosa stampa? Perché?
  2. Esempio delle slide: una funzione che legge con gets() dalla tastiera nel buffer str2 di 8 caratteri, con str1 che contiene "START". Cosa succede con l'ingresso BADINPUTBADINPUT?
  3. Scrivere una versione sicura.

Teoria: Sicurezza e protezione nei sistemi operativiLe minacce all'accesso sono gli intrusi (masquerader, misfeasor, clandestine user) e il software malevolo (parassita: virus, logic bomb, backdoor; indipendente: worm, bot); contromisure: sistemi di rilevamento delle intrusioni (IDS, host o network, con sensori, analizzatori e interfaccia), autenticazione (identificazione + verifica con qualcosa che si sa, si ha, si è, si fa) e controllo degli accessi (permessi, sudo, SELinux, ACL, auditing, firewall); il buffer overflow scrive oltre un buffer nello stack e può sovrascrivere variabili o l'indirizzo di ritorno (code injection, shellcode); difese a compilazione (linguaggi sicuri, codice sicuro, librerie, stack protection con canarino) e a esecuzione (NX bit, ASLR, guard pages); sulla rete ci sono sniffing, spoofing e man-in-the-middle, e la crittografia (simmetrica e asimmetrica, RSA) limita mittenti e destinatari; la tolleranza ai guasti usa ridondanza (spaziale, temporale, informativa) e si misura con R(t), MTTF, MTTR e disponibilità.Sicurezza e protezione nei sistemi operativi →.


Il programma

c
/* bufferover.c - due stringhe adiacenti nello stack e un ciclo che scrive troppo.
 * Compilare:  gcc -Wall -Wextra -fno-stack-protector -o bufferover bufferover.c
 *             (senza l'opzione, gcc puo' inserire il canarino e abortire)
 */
#include <stdio.h>

static void fun1(void) {
    char str1[8] = "1111111";
    char str2[8] = "2222222";

    puts(str1);
    puts(str2);

    for (int i = 0; i < 15; i++) /* str2 ha 8 posti: le scritture 8..14 sforano */
        str2[i] = '3';

    puts(str1);
    puts(str2);
}

int main(void) {
    fun1();
    return 0;
}

(Compilato con zig cc per Linux, senza avvisi; non eseguito: l'uscita dipende dal compilatore, come spiegato sotto.)

Domanda 1: cosa stampa

  • str1 = "1111111": 7 caratteri più il terminatore '\0' =8=8 byte (riempie esattamente l'array). Lo stesso per str2 = "2222222".
  • I primi due puts stampano 1111111 e 2222222.
  • Il ciclo scrive '3' in str2[0], …, str2[14]: 15 scritture. L'array ha i posti 0,…,70,\ldots,7: le scritture negli indici 8,…,148,\ldots,14 (77 byte) sono fuori dall'array.

Dove finiscono quei 7 byte dipende da come il compilatore ha disposto le variabili locali nello stack. Con le variabili adiacenti (str2\texttt{str2} seguita da str1\texttt{str1}, indirizzi crescenti):

str2[0..7] str1[0..7] registri salvati e indirizzo di ritorno
33333333 (il terminatore è stato sovrascritto) 3333333 + terminatore: i primi 7 byte sono diventati '3' intatti

In questo caso:

  • puts(str1) stampa 3333333: str1 è stata corrotta anche se nessun'istruzione la nomina;
  • puts(str2) stampa da str2[0] fino al primo '\0': str2 non ha più il terminatore, quindi la lettura prosegue dentro str1, dove il primo '\0' è str1[7] (non sovrascritto): escono 15 caratteri 3 (8 di str2 e 7 di str1).

Se invece il compilatore mettesse str1 prima (a indirizzi più bassi) di str2, l'overflow andrebbe verso lo spazio sopra str2 (i registri salvati, forse il canarino e l'indirizzo di ritorno): str1 rimarrebbe 1111111 ma si corromperebbero altri dati dello stack. L'ordine non è garantito dallo standard C: è comportamento indefinito.

Con -fno-stack-protector il programma non viene fermato. Con l'opzione di default di molti sistemi, il compilatore mette un canarino fra le variabili e l'indirizzo di ritorno e lo controlla all'uscita della funzione: se l'overflow arriva a sovrascriverlo, il programma termina con *** stack smashing detected ***. In questo programma l'overflow è di soli 7 byte e (nel layout sopra) rimane dentro str1: il canarino potrebbe non essere toccato e l'errore non essere rilevato. Il canarino protegge solo l'indirizzo di ritorno, non le variabili vicine.

Domanda 2: l'esempio con gets()

gets() legge una riga da stdin fino al carattere di a-capo senza nessun controllo sulla lunghezza: per questo è stata rimossa dallo standard C. Con str2[8] e l'ingresso BADINPUTBADINPUT (16 caratteri):

  • i primi 8 caratteri BADINPUT riempiono str2 (senza posto per il terminatore);
  • gli altri 8 (BADINPUT) e il terminatore '\0' vanno oltre str2: nell'esempio con str1 adiacente, str1 diventa BADINPUT (non più START) e il terminatore finisce nel byte successivo.

Con un ingresso più lungo si arriva a sovrascrivere anche l'indirizzo di ritorno della funzione: quando questa termina, il processore salta all'indirizzo scritto dall'attaccante, che può essere un punto del buffer stesso riempito con shellcode (iniezione di codice). Con ingressi di lunghezza moderata si corrompono invece solo i dati, senza nessun errore visibile: è il caso più insidioso ("corrupted data, but undetected ⇒ problem").

Domanda 3: versione sicura

La regola è non scrivere mai oltre la capacità: usare le funzioni che accettano un limite (fgets, snprintf, strncpy/strlcpy) e controllare ogni indice.

c
/* sicuro.c - la stessa operazione con i limiti controllati.
 * Compilare:  gcc -Wall -Wextra -o sicuro sicuro.c
 */
#include <stdio.h>
#include <string.h>

static void riempi(char *dest, size_t cap, char c, size_t n) {
    if (n > cap - 1) n = cap - 1; /* si lascia un posto per il terminatore */
    memset(dest, c, n);
    dest[n] = '\0';
}

int main(void) {
    char str1[8] = "1111111";
    char str2[8];

    riempi(str2, sizeof str2, '3', 15);
    puts(str1); /* 1111111: non toccata */
    puts(str2); /* 3333333 (7 caratteri) */

    char riga[16];
    if (fgets(riga, sizeof riga, stdin)) /* legge al massimo 15 caratteri */
        printf("letto: %s", riga);
    return 0;
}

(Compilato con zig cc per Linux, senza avvisi; non eseguito.)

Cosa cambia:

  • riempi riceve la capacità del buffer (sizeof str2) e limita nn a cap - 1: nessun accesso fuori dai limiti e str2 è sempre una stringa C valida (con il terminatore). L'uscita attesa è 1111111 e 3333333;
  • fgets(riga, sizeof riga, stdin) legge al massimo sizeof riga−1=15\texttt{sizeof riga}-1=15 caratteri più il terminatore: una riga più lunga viene troncata, non scrive fuori;
  • si usa sizeof sull'array (non un numero scritto a mano) per non sbagliare quando si cambia la dimensione.

Le altre difese, che non sostituiscono il codice corretto ma lo completano, sono quelle viste nella teoria: canarino (compilazione), bit NX (la pila non è eseguibile), ASLR (indirizzi casuali), guard pages (pagine di divieto fra le zone), e linguaggi con controllo dei limiti.


Errori tipici

  • Dimenticare lo spazio per il terminatore '\0' (77 caratteri in un array da 8).
  • Usare gets(), strcpy(), sprintf() o scanf("%s") senza limite.
  • Credere che il canarino impedisca la scrittura fuori dai limiti (la rileva solo se tocca il canarino) o che protegga le variabili locali adiacenti.
  • Dare per scontato l'ordine delle variabili nello stack: non è definito dal linguaggio.
  • Usare un numero magico (15) invece di sizeof nel controllo dei limiti.

Versione ripasso

Programma. char str1[8]="1111111", str2[8]="2222222"; ciclo che scrive 15 '3' in str2: indici 8-14 fuori dai limiti (7 byte). Con str1 adiacente sopra str2: str1 diventa 3333333 (corrotta senza errori); l'ordine delle variabili non è garantito (comportamento indefinito). -fno-stack-protector; il canarino non vede un overflow che resta dentro str1.

gets() e BADINPUTBADINPUT. Senza limite di lunghezza: 8 caratteri riempiono str2, gli altri (e il terminatore) vanno oltre e sovrascrivono str1 (e con un ingresso più lungo l'indirizzo di ritorno: iniezione di codice, shellcode). Il caso insidioso è la corruzione di dati non rilevata.

Versione sicura. Passare la capacità, se n > cap - 1 allora n = cap - 1, terminatore sempre; fgets(riga, sizeof riga, stdin); sizeof e non numeri scritti a mano. Difese: canarino, NX, ASLR, guard pages.

Codice sicuro. riempi(dest, cap, c, n): se n > cap - 1 allora n = cap - 1; memset(dest, c, n); dest[n] = '\0'. fgets(riga, sizeof riga, stdin) legge al massimo sizeof riga−1\texttt{sizeof riga}-1 caratteri. Con 15 scritture in str2[8]: 7 byte oltre l'array; puts(str1) stampa 3333333, puts(str2) stampa 15 caratteri 3 (finché non trova str1[7], che è '\0'). Il canarino rileva solo se l'overflow arriva fino a lui, non protegge le variabili vicine. Difese: canarino, NX, ASLR, guard pages, linguaggi con controlli.

Errori tipici: niente posto per '\0'; gets, strcpy, sprintf, scanf("%s") senza limite; canarino creduto onnipotente; ordine delle variabili dato per scontato; numeri magici al posto di sizeof.

Teoria: Sicurezza e protezione nei sistemi operativiLe minacce all'accesso sono gli intrusi (masquerader, misfeasor, clandestine user) e il software malevolo (parassita: virus, logic bomb, backdoor; indipendente: worm, bot); contromisure: sistemi di rilevamento delle intrusioni (IDS, host o network, con sensori, analizzatori e interfaccia), autenticazione (identificazione + verifica con qualcosa che si sa, si ha, si è, si fa) e controllo degli accessi (permessi, sudo, SELinux, ACL, auditing, firewall); il buffer overflow scrive oltre un buffer nello stack e può sovrascrivere variabili o l'indirizzo di ritorno (code injection, shellcode); difese a compilazione (linguaggi sicuri, codice sicuro, librerie, stack protection con canarino) e a esecuzione (NX bit, ASLR, guard pages); sulla rete ci sono sniffing, spoofing e man-in-the-middle, e la crittografia (simmetrica e asimmetrica, RSA) limita mittenti e destinatari; la tolleranza ai guasti usa ridondanza (spaziale, temporale, informativa) e si misura con R(t), MTTF, MTTR e disponibilità.Sicurezza e protezione nei sistemi operativi →.

Teoria collegata