3 - Difese dal Buffer Overflow
Ci sono quattro approcci di base per
difendersi contro vulnerabilità da buffer overflow:
Il metodo di forza bruta consistente nello
scrivere codice sicuro è descritto nel paragrafo 3.1.
La soluzione generale che riguarda il
sistema operativo descritta nel paragrafo 3.2 è quella di rendere l'area di
memoria, destinata a contenere le variabili, non eseguibile, in modo da
prevenire l'eventuale inserimento di codice d'attacco. Questo approccio
previene molti attacchi basati su buffer overflow, ma poiché l'attaccante non
deve necessariamente inserire codice d'attacco per realizzare un overflow,
questo metodo ha delle sostanziali vulnerabilità.
L'approccio diretto orientato al
compilatore, descritto nel paragrafo 3.3, è quello di controllare la dimensione
degli array ad ogni accesso. Questo metodo elimina completamente i problemi
di overflow, rendendolo impossibile, ma
induce sostanziali costi.
L'approccio indiretto orientato al
compilatore, descritto nel paragrafo 3.4, è, invece, quello di verificare
l'integrità dei puntatori prima di dereferenziarli. Mentre questa tecnica non
rende impossibili i buffer overflow, ne impedisce molti, e gli attacchi
che non previene sono difficili da realizzare. Inoltre, i vantaggi relativi
alla compatibilità ed alle prestazioni rispetto al controllo sulla dimensione
degli array sono notevoli.
3.1 - Scrivere codice sicuro
Scrivere codice sicuro è lodevole ma
irrimediabilmente costoso[3,4], specialmente per linguaggi, come il C, che ha
idiomi, come le stringhe, che inducono facilmente in errore, ed ha una
filosofia che antepone le prestazioni alla correttezza. A dispetto di lunghi
studi su come scrivere codice sicuro[5], continuano ad emergere, con regolarità,
programmi vulnerabili[6]. Pertanto, sono stati sviluppati strumenti e tecniche
per aiutare gli sviluppatori a scrivere programmi che, in qualche modo,
contengano meno facilmente vulnerabilità ad attacchi di buffer overflow.
Il metodo più semplice è quello di
cercare, nel codice sorgente, con utility come grep, chiamate a funzioni
di libreria molto vulnerabili, come strcpy e sprintf, che non
controllano la lunghezza degli argomenti che manipolano. Sono anche state
sviluppate versioni della libreria standard C che avvertono quando un programma
fa il link di tali funzioni, inoltre, gruppi di analisi di codice sorgente sono
nati[7,8] con l'esplicito obiettivo di analizzare manualmente grandi quantità
di codice, cercando comuni vulnerabilità[7].
In linea di massima, una programmazione
sicura dovrebbe seguire le seguenti regole:
Principio del minor privilegio possibile: Un programma deve avere i privilegi di cui
necessita e null'altro. In questo modo se un programma contiene un buco, l'impatto
di un eventuale exploit
non sarà distruttivo.
In determinati casi un programma può
richiedere privilegi elevati per effettuare operazioni altrimenti non
realizzabili. Spesso però questi privilegi sono necessari solo per
un'operazione, come l'apertura di un raw socket o una bind ad una
porta privilegiata, che può essere effettuata immediatamente. Tutto ciò che si
deve fare è poi ridimensionare i privilegi di modo che un eventuale intruso non
ottenga onnipotenti modalità operative.
Scrivere codice semplice: I programmi monolitici (sendmail ne
è il prototipo) sono fonte inesauribile di buchi. Nei programmi di limitate
dimensioni viene ridotta la possibilità di introdurre falle alla sicurezza, e
la concisione aiuta il testing e la manutenzione del software.
Non fidarsi di nessuno: Ogni dato in input al programma, sia esso
da linea di comando, da variabili d'ambiente o da qualsiasi altra fonte deve
essere trattato con sospetto. I programmi sicuri esaminano ogni dato prima di
utilizzarlo, al fine di evitare di eseguire operazioni potenzialmente
pericolose.
Comunque, vulnerabilità causate da buffer overflow,
possono essere, in molti casi, non facilmente individuabili.
Ogni programma scritto con strategie di
difesa alternative, come ad esempio usando funzioni tipo strncpy o snprintf,
può contenere vulnerabilità se il codice contiene un elementare errore off
by-one. Ad esempio, nel programma lprm è stata trovata una
vulnerabilità[9], nonostante fosse stato analizzato sotto l'aspetto della
sicurezza rispetto ad attacchi di buffer overflow.
Per combattere problemi di questo tipo,
dovuti quindi a dei bug nei programmi, sono stati sviluppati avanzati strumenti
di debug[10]. L'idea di base è quella di iniettare a caso, nei buffer
utilizzati, codice d'attacco per individuare eventuali vulnerabilità.
Mentre questi strumenti sono utili per sviluppare programmi più sicuri, la semantica del C non permette loro di garantire totale sicurezza. Le tecniche di debug possono solo minimizzare il numero di vulnerabilità.
Il concetto generale è quello di rendere
il segmento dati dello spazio di indirizzamento del programma vittima non
eseguibile, in modo da impedire all'attaccante di eseguire il codice inserito. Questa è attualmente la linea di
progettazione dei sistemi sui vecchi computer, ma i più recenti sistemi UNIX
e MS Windows, dipendono dalla possibilità di inserire codice dinamico
nel segmento dati dei programmi, per supportare diverse ottimizzazioni delle
prestazioni. Non è possibile per tutti i programmi rendere non
eseguibile il segmento dati, senza sacrificare sostanziali compatibilità dei
programmi già in uso.
Comunque, è possibile rendere lo stack non
eseguibile e preservare la compatibilità della maggior parte dei programmi.
Infatti, sono disponibili delle patch per Linux e Solaris[11,12],
che implementano questo criterio; dal momento che nessun programma
"normale" ha del codice eseguibile nello stack, l'approccio causa
pochi problemi di compatibilità. C'è però un caso eccezionale in Linux,
in cui codice eseguibile deve essere messo nello stack, ad esempio, nel caso
d'invio di segnali. La protezione realizzata da questo criterio è
molto valida per attacchi basati sull'iniezione di codice eseguibile nelle variabili
automatiche, ma non offre nessuna protezione contro altre forme d'attacco. E' possibile
aggirare questa strategia di difesa modificando l'indirizzo di ritorno
per farlo puntare a codice già residente nel programma[13], oppure inserire il
codice d'attacco in buffer allocati nell'heap o nel segmento dati statici.
3.3 - Controllo della dimensione degli array
Non basta inserire codice per realizzare
un buffer overflow, ma è anche necessario modificare il flusso del programma
in esecuzione. A dispetto del criterio precedente, il controllo sulla dimensione
degli array elimina completamente
le possibilità di realizzare tali attacchi. Se in un array non si può scrivere
oltre la sua dimensione non è possibile corrompere i dati adiacenti nello
stack. Implementare questo metodo comporta che ogni lettura e scrittura in
un array sia riferita ad uno spazio di memoria pari alla dimensione dell'array
in questione. L'approccio diretto è quello di testare tutti i riferimenti
agli array, ma è spesso possibile impiegare tecniche di ottimizzazione per
eliminare molti di questi controlli. Ci sono diversi approcci per implementare
il controllo sulla dimensione degli array.
3.3.1 - Compilatore C Compaq
Il compilatore Compaq per CPU
Alpha supporta, con l'uso dell'opzione "-check_bounds ", un controllo limitato :
·
sono
testati solo riferimenti espliciti;
·
dato che
tutti gli array in C sono convertiti in puntatori quando passati come argomenti
ad una subroutine, nessun controllo viene effettuato sugli accessi in suddette
subroutine;
·
funzioni
di libreria pericolose come strcpy() non sono compilate con tale
controllo e restano pericolose.
Poiché è molto comune in C l'uso
dell'aritmetica dei puntatori per gli accessi agli array, il controllo sulla
dimensione degli array non dà nessuna garanzia sull'immunità da buffer
overflow.
3.3.2 - Controllo sulla dimensione degli array per il linguaggio
C
Richard Jones e Paul Kelly hanno sviluppato una patch per il compilatore gcc[14] che fa un pieno controllo sulla dimensione degli array. I programmi compilati sono compatibili con altri moduli gcc poiché non è stata cambiata la rappresentazione dei puntatori. Infatti, essi derivano un puntatore base da ogni espressione che ne contiene uno, verificando gli attributi di quel puntatore per determinare se l'espressione rientra nei limiti. I costi in termini di prestazioni sono sostanziali: un programma che fa un forte uso dei puntatori (es: il prodotto di matrici tridimensionali) diventa 30 volte più lento. Dato che la lentezza del programma è proporzionale all'uso dei puntatori, abbastanza diffusi nei programmi con permessi di root, questa caduta di prestazioni è particolarmente svantaggiosa. Inoltre il compilatore non sembra essere molto funzionale né performante, dal momento che programmi come elm non sono rimasti immuni da buffer overflow una volta ricompilati.
3.3.3 - Controllo dell'accesso in memoria
Purify[15] e' un tool di debbugging orientato all'uso della memoria per
i programmi scritti in C. Purify utilizza l' object code insertion
per realizzare tutti gli accessi alla memoria. Dopo aver eseguito la fase
di linking con le librerie di Purify, otteniamo un programma eseguibile
standard in cui vengono controllati tutti i riferimenti ad array, per assicurare
che essi siano legittimi. Benché i programmi protetti con Purify girino
normalmente senza richiedere un ambiente particolare, Purify non è
attualmente considerato un tool per la produzione di codice sicuro: la sua
protezione rallenta l'esecuzione di un programma da 3 a 5 volte.
3.3.4 - Linguaggi Type-Safety
Tutte le vulnerabilità legate al buffer
overflow sono il risultato dell'assenza della tipizzazione in C. Se fosse
possibile eseguire solo operazioni "sicure" su di una data variabile,
non si potrebbero utilizzare input creativi nella variabile pippo per
cambiare in modo arbitrario il contenuto della variabile pluto. Allo
stato dell'arte, se si vuole ottenere del codice "sicuro", è
preferibile utilizzare un linguaggio che assicura l'integrità dei dati come
JAVA o ML.
Purtroppo, nei moderni sistemi operativi e nelle applicazioni dedicate alla sicurezza, ci sono milioni di linee di codice e la maggior parte di esse è scritta in C. D'altronde anche la Java Virtual Machine (JVM) è un programma C, e uno dei modi per attaccare codice in JAVA è tentare il buffer overflow alla JVM stessa.
L'intento del codice per il controllo
dell'integrità dei puntatori differisce leggermente dal controllo sui
"limiti" di un array. Invece di prevenire la modifica dei puntatori
(come visto precedentemente) questo approccio tenta di verificare se un
puntatore è stato sovrascritto prima di essere utilizzato.
In questo modo, anche se un attaccante riuscisse
a modificare un puntatore, quest'ultimo non sarà mai utilizzato. La verifica
sull'integrità dei puntatori non risolve perfettamente il problema del buffer
overflow: attacchi mirati alla modifica di componenti del programma che non
siano i puntatori andranno comunque a segno. La tecnica descritta presenta,
comunque, vantaggi sostanziali in termini di performance, di compatibilità
con il codice esistente e di sforzo implementativo.
Il controllo dell'integrità sui puntatori
è stato sviluppato in 3 modi distinti. Snarskii implementò una versione
personalizzata di libc per FreeBSD. Il progetto StackGuard
ha portato alla creazione di un compilatore che in modo del tutto trasparente
all'utente produce codice di controllo nel record di attivazione di ogni
funzione.
Infine, è in fase di sviluppo PointGuard,
un compilatore che generalizzando l'idea ispiratrice di StackGuard, realizza il
controllo di tutti i puntatori presenti nel programma.
|
||
|
|
|
|