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).
- Il programma
bufferover.cdichiara due stringhe di 8 caratteri adiacenti, ne stampa il contenuto, poi scrive 15 caratteri'3'instr2e stampa di nuovo. Cosa stampa? Perché? - Esempio delle slide: una funzione che legge con
gets()dalla tastiera nel bufferstr2di 8 caratteri, constr1che contiene"START". Cosa succede con l'ingressoBADINPUTBADINPUT? - Scrivere una versione sicura.
Il programma
/* 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'byte (riempie esattamente l'array). Lo stesso perstr2 = "2222222".- I primi due
putsstampano1111111e2222222. - Il ciclo scrive
'3'instr2[0], …, str2[14]: 15 scritture. L'array ha i posti : le scritture negli indici ( 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 ( seguita da , 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)stampa3333333:str1è stata corrotta anche se nessun'istruzione la nomina;puts(str2)stampa dastr2[0]fino al primo'\0':str2non ha più il terminatore, quindi la lettura prosegue dentrostr1, dove il primo'\0'èstr1[7](non sovrascritto): escono 15 caratteri3(8 distr2e 7 distr1).
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
BADINPUTriempionostr2(senza posto per il terminatore); - gli altri 8 (
BADINPUT) e il terminatore'\0'vanno oltrestr2: nell'esempio constr1adiacente,str1diventaBADINPUT(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.
/* 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:
riempiriceve la capacità del buffer (sizeof str2) e limita acap - 1: nessun accesso fuori dai limiti estr2è sempre una stringa C valida (con il terminatore). L'uscita attesa è1111111e3333333;fgets(riga, sizeof riga, stdin)legge al massimo caratteri più il terminatore: una riga più lunga viene troncata, non scrive fuori;- si usa
sizeofsull'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'( caratteri in un array da 8). - Usare
gets(),strcpy(),sprintf()oscanf("%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 disizeofnel 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 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.