Un modello integrato control-flow e data-flow per il

Transcript

Un modello integrato control-flow e data-flow per il
Università degli Studi di Udine
Facoltà di Scienze Matematiche Fisiche e Naturali
Corso di Laurea Specialistica in Informatica
Tesi di Laurea
Un modello integrato control-flow e
data-flow per il rilevamento automatico
di intrusioni
Candidato:
Relatore:
Matteo Cicuttin
Prof. Marino Miculan
Anno Accademico 2010/2011
Università degli Studi di Udine
Via delle Scienze, 206
33100 Udine
Italia
Alla mia famiglia, che mi ha sempre supportato.
Ringraziamenti
In questo percorso mi sono trovato ad affrontare molte situazioni impegnative, quindi desidero innanzitutto ringraziare chi mi era vicino in quei momenti per avermi
sopportato, Ilaria in particolare. Spesso il suo aiuto è stato determinante e spesso
non sono stato capace di ricambiarlo.
Il successivo ringraziamento va al mio relatore, Prof. Miculan, per l’opportunità
che mi ha dato e per il “clima” in cui questo lavoro si è svolto. Sono in debito con
lui.
Tra quelli che devo ringraziare ci sono anche gli amici, interni ed esterni all’università, compresi quelli che ultimamente hanno perso un po’ la testa. Con loro ho
condiviso momenti di divertimento e importanti scambi di idee.
Naturalmente il ringraziamento più grande va alla mia famiglia per avermi supportato in questo percorso, nonostante i momenti difficili.
Anche se non leggeranno mai questi ringraziamenti, desidero dire grazie a chiunque sia coinvolto nello sviluppo dei software che ho usato per mettere insieme questa
tesi. Per scriverla ho usato Vim ma mi sono serviti anche un sacco di altri programmi, tra cui: LATEX, GraphViz, GHC, GCC, GDB, NASM, Subversion, le shell Bourne
e Korn, DTrace e anche qualcosina closed source come OmniGraffle. Questi software
mi hanno permesso tra l’altro di creare dei tool che hanno automatizzato molte parti
del mio lavoro, semplificandolo enormemente. Il loro denominatore comune però è
il sistema operativo che li fa girare, ovvero Unix. Ho usato in particolare Mac OS X
per elaborare il testo e la grafica e Solaris (OpenIndiana) per tutta la parte legata
a DTrace. FreeBSD invece sul mio server garantiva tutta una serie di servizi che mi
sono stati assai utili, storage e backup in primis. Senza Unix e tutto quello che ci
sta sopra tutto questo sarebbe stato infinitamente più difficile e frustrante.
Desidero infine rivolgere un ringraziamento particolare a Chad Mynhier, che mi
ha fornito materiale e consigli preziosissimi relativamente a DTrace.
Durante questa laurea specialistica poche cose sono andate come pensavo e sono
particolarmente felice di essere finalmente giunto al traguardo, anche se purtroppo
la felicità di questi giorni è offuscata dalla cattiva salute del mio Micio. Con la
laurea triennale avevo visto la punta di un iceberg, con la laurea specialistica ho
iv
Ringraziamenti
scoperto nuovi mondi. Spero che il futuro mi riservi una strada che mi consenta di
non smettere di studiare.
Indice
1 Introduzione
1
1.1
Anomaly detection e system call . . . . . . . . . . . . . . . . . . . .
3
1.2
Obiettivo del lavoro . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
1.3
Struttura della tesi . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
2 Modelli per l’anomaly detection
2.1
2.2
2.3
Modelli control flow . . . . . . . . . . . . . . . . . . . . . . . . . . .
10
2.1.1
Automi a stati finiti . . . . . . . . . . . . . . . . . . . . . . .
10
2.1.2
VtPath . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
12
2.1.3
Execution graphs . . . . . . . . . . . . . . . . . . . . . . . . .
13
2.1.4
Abstract stack, un modello costruito staticamente . . . . . .
16
Modelli data flow . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
17
2.2.1
Un modello data flow . . . . . . . . . . . . . . . . . . . . . .
19
Discussione dei modelli studiati . . . . . . . . . . . . . . . . . . . . .
21
3 Un modello che integra control flow e data flow
3.1
9
25
Debolezze dei modelli esistenti . . . . . . . . . . . . . . . . . . . . .
25
3.1.1
Primo scenario: vulnerabilità nel codice . . . . . . . . . . . .
26
3.1.2
Secondo scenario: errore di configurazione di un server . . . .
31
3.1.3
Terzo scenario: debolezza delle relazioni binarie . . . . . . . .
34
3.2
Proposta . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
35
3.3
Costruzione del modello . . . . . . . . . . . . . . . . . . . . . . . . .
37
3.3.1
Algoritmo di apprendimento delle relazioni unarie . . . . . .
37
3.3.2
Algoritmo di apprendimento delle relazioni binarie . . . . . .
38
3.3.3
Descrizione dell’algoritmo . . . . . . . . . . . . . . . . . . . .
44
L’algoritmo completo per la costruzione del modello . . . . . . . . .
46
3.4.1
Relazione con l’algoritmo originale rispetto ai falsi positivi . .
46
3.4.2
Una possibile variante . . . . . . . . . . . . . . . . . . . . . .
47
3.4
vi
Indice
3.5
L’algoritmo per la verifica delle tracce rispetto al modello . . . . . .
47
3.5.1
49
Gestione delle anomalie . . . . . . . . . . . . . . . . . . . . .
4 L’implementazione
51
4.1
Struttura generale del sistema . . . . . . . . . . . . . . . . . . . . . .
51
4.2
Introduzione a DTrace . . . . . . . . . . . . . . . . . . . . . . . . . .
52
4.2.1
Interfacciamento a DTrace tramite libdtrace(3LIB) . . . .
53
4.2.2
Note riguardo a DTrace . . . . . . . . . . . . . . . . . . . . .
55
Implementazione del sistema . . . . . . . . . . . . . . . . . . . . . .
56
4.3.1
Lo script di data collection . . . . . . . . . . . . . . . . . . .
56
4.3.2
Implementazione di NewArgs() . . . . . . . . . . . . . . . . .
57
Costo computazionale del modello . . . . . . . . . . . . . . . . . . .
59
4.4.1
Costo del learning . . . . . . . . . . . . . . . . . . . . . . . .
59
4.4.2
Costo della verifica . . . . . . . . . . . . . . . . . . . . . . . .
60
4.4.3
Alcuni dati sperimentali . . . . . . . . . . . . . . . . . . . . .
60
4.3
4.4
5 Conclusioni e sviluppi futuri
5.1
5.2
63
Riepilogo del lavoro svolto . . . . . . . . . . . . . . . . . . . . . . . .
64
5.1.1
Il modello . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
5.1.2
L’implementazione . . . . . . . . . . . . . . . . . . . . . . . .
64
Sviluppi futuri . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
5.2.1
L’uso dello stack nell’apprendimento delle relazioni binarie
.
65
5.2.2
Una dimensione statistica per il modello . . . . . . . . . . . .
65
5.2.3
Costruzione statica del modello . . . . . . . . . . . . . . . . .
66
5.2.4
Applicazione del modello a sistemi virtualizzati . . . . . . . .
66
Bibliografia
69
Elenco delle figure
1.1
Codice semanticamente equivalente. . . . . . . . . . . . . . . . . . .
1
1.2
Buffer overflow. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
2.1
Architettura generale di un sistema black-box.
. . . . . . . . . . . .
9
2.2
FSA d’esempio. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
12
2.3
Esempio riguardante la parte induttiva della definizione dell’EG. . .
15
2.4
Rappresentazione grafica del significato di successore. . . . . . . . . .
16
2.5
Time to check to time of use. . . . . . . . . . . . . . . . . . . . . . .
17
2.6
Banale programma vulnerabile ad un non-control data attack. . . . .
18
2.7
Informazioni apprese a runtime dall’analisi data flow. . . . . . . . . .
21
3.1
Programma vulnerabile d’esempio. . . . . . . . . . . . . . . . . . . .
27
3.2
FSA per il primo esempio. . . . . . . . . . . . . . . . . . . . . . . . .
27
3.3
Execution graph per il primo esempio. . . . . . . . . . . . . . . . . .
28
3.4
Passi dell’attacco al programma. . . . . . . . . . . . . . . . . . . . .
29
3.5
Programma per il secondo esempio. . . . . . . . . . . . . . . . . . . .
32
3.6
FSA per il secondo esempio. . . . . . . . . . . . . . . . . . . . . . . .
33
3.7
Execution graph per il secondo esempio. . . . . . . . . . . . . . . . .
34
3.8
Codice d’esempio per il terzo scenario. . . . . . . . . . . . . . . . . .
35
3.9
Nuovo modello per l’esempio del primo scenario. . . . . . . . . . . .
36
3.10 Nuovo modello per l’esempio del terzo scenario. . . . . . . . . . . . .
37
3.11 Esecuzione dell’algoritmo su una traccia. . . . . . . . . . . . . . . . .
40
3.12 Esecuzione dell’algoritmo su una traccia. . . . . . . . . . . . . . . . .
41
4.1
Struttura generale del sistema. . . . . . . . . . . . . . . . . . . . . .
52
4.2
Script di DTrace per monitorare lo stack. . . . . . . . . . . . . . . .
52
4.3
Tempi di esecuzione di ls senza e con tracing. . . . . . . . . . . . . .
53
4.4
Script di DTrace per monitorare lo stack. . . . . . . . . . . . . . . .
53
4.5
Strutture dati usate da un consumer DTrace. . . . . . . . . . . . . .
54
viii
Elenco delle figure
4.6
Snippet di walk(). . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
4.7
Struttura dati di supporto all’implementazione di NewArgs(). . . . .
58
5.1
Automa senza e con peso sugli archi. . . . . . . . . . . . . . . . . . .
66
1
Introduzione
Dai tempi della “dot-com bubble” stiamo assistendo ad una crescita enorme della diffusione di dispositivi informatici di ogni tipo e, a differenza di dieci anni fa,
è abbastanza difficile pensare di poter vivere senza interagire ogni giorno con un
computer potenzialmente connesso alla rete. Router casalinghi e telefonini montano
CPU sufficientemente potenti da poter far girare un kernel Unix, per non parlare di
televisori e media-center. Se pensiamo a quanti di questi dispositivi ognuno di noi
ha in casa e, soprattutto, alla quantità di dati personali che essi manipolano, la loro
protezione da attacchi informatici diventa un obiettivo primario. Le minacce per un
computer, comprendendo negli oggetti indicati con questa parola anche i dispositivi
appena citati, sono dei tipi più svariati ed impensabili e le tecniche di difesa sono
letteralmente centinaia, ognuna volta a proteggere da determinati tipi di attacchi.
Volendo prescindere dalla tipologia d’attacco possiamo distinguere due metodologie fondamentali nel rilevamento, la misuse detection, altrimenti detta signaturebased detection e la anomaly detection. Gli antivirus in buona approssimazione fanno
un lavoro di misuse detection: essi infatti, dato ad esempio un file eseguibile, sono
in grado di cercare al suo interno delle sequenze di byte corrispondenti a payload
malevoli già noti. Il confronto è puramente sintattico e quindi una “versione B”
del payload sintatticamente diversa ma semanticamente equivalente potrebbe non
venire rilevata. Prendiamo i due frammenti di assembler x86 di Figura 1.1:
jmp 0x01ab23cd
(a)
push 0x01ab23cd
ret
(b)
Figura 1.1: Codice semanticamente equivalente.
l’effetto netto del codice è esattamente lo stesso, quindi semanticamente sono identici
2
1. Introduzione
ma nel caso (a) la signature è E9 C2 23 AB 01 mentre nel caso (b) è 68 CD 23 AB
01 C3, quindi sintatticamente sono diversi. Se un programma antivirus conosce
la prima signature ma non la seconda il payload passa inosservato e il virus può
fare il suo lavoro. Naturalmente gli antivirus sono un po’ più furbi di cosı̀, ma
anche chi scrive i virus è molto furbo e si serve di payload polimorfici, crittografati e
quant’altro, molto difficili da rilevare (si veda ad esempio [23]). Al fine di contrastare
questo tipo di payload sono stati proposte diverse soluzioni basate sulla semantica
[19, 5, 20] ma, purché molto potenti, anche queste sono aggirabili.
Un’idea fondamentale da tenere in considerazione però è che per poter attaccare
un sistema è necessario interagire con esso, ed è qui che entra in gioco l’anomaly
detection. Per quanto l’affermazione precedente possa sembrare banale è abbastanza
naturale chiedersi se le interazioni che avvengono sono lecite o meno. Quello che in
genere succede è infatti che tramite interazioni non lecite un sistema viene spinto a
comportarsi in un modo non previsto originariamente dai suoi progettisti creando
quindi un’anomalia nel suo comportamento, una deviazione rispetto a quello che
dovrebbe essere il comportamento corretto. Rilevare un attacco dunque corrisponde
all’accorgersi della presenza di questa anomalia. Sorgono alcune domande:
• Quale è il comportamento corretto di un sistema?
• Come può essere rappresentato?
• Come si può rilevare un’anomalia?
L’anomaly detection prevede l’esistenza di un modello che descriva quali sono i comportamenti corretti del sistema. A runtime il sistema viene monitorato costantemente e viene verificata la conformità delle sue azioni rispetto al modello. Naturalmente
più il modello è dettagliato più il rilevamento di anomalie sarà accurato e più il suo
costo computazionale sarà elevato: la costruzione del modello è un punto critico. In
letteratura è possibile trovare moltissimi modelli per l’anomaly detection costruiti
nei modi più svariati e con gli obiettivi più svariati. La distinzione fondamentale che
però va fatta tra tutti i vari modelli esistenti è dovuta alla modalità con cui sono
costruiti, ovvero se con tecniche black-box o con tecniche white-box. Le prime prevedono soltanto l’osservazione di un processo a runtime, le seconde richiedono che sia
fatta un’analisi sul codice sorgente o comunque sul codice binario del programma.
La ricaduta fondamentale ovviamente è sull’applicabilità. Il codice sorgente non
sempre è disponibile e le analisi sul binario sono tutt’altro che semplici. Nel caso
1.1. Anomaly detection e system call
3
dell’architettura x86, attualmente la più diffusa in ambiente desktop e server, il problema di disassemblare correttamente un binario è addirittura indecidibile [25, 14].
Certamente disassemblatori come IDAPro fanno un egregio lavoro ma il loro output,
per quanto vicino al codice “vero”, è inerentemente non corretto. Una conseguenza di questo fatto è che la ricostruzione del control flow di un programma dato il
suo binario non è possibile, se non in modo approssimato. Un interessante lavoro
in questa direzione basato sull’interpretazione astratta è [11]. Le tecniche blackbox di contro, pur essendo universalmente applicabili, possono osservare soltanto le
interazioni di un processo con l’ambiente (ad esempio col sistema operativo o con
la rete). Potrebbe succedere però che, pur osservando per tempi molto lunghi un
processo, non si riesca ad osservare tutte le interazioni ottenendo anche in questo
caso un quadro incompleto. Le tecniche white-box tipicamente sono di tipo statico,
mentre le black-box di tipo dinamico. Le prime quindi necessitano della capacità di
riconoscere ed elaborare sintassi e semantica del programma che si vuole analizzare
(quindi devono avere la capacità di leggere ed interpretare il codice sorgente o il
codice eseguibile) e questo richiede il supporto di tool abbastanza complessi, come
ad esempio il compilatore, il che introduce un ulteriore livello di complessità. Le
tecniche black-box al contrario richiedono una fase di apprendimento in cui tramite
l’osservazione viene costruito il modello. La bontà di quest’ultimo è direttamente
legata al training svolto. Come nel caso del testing del software, anche il training del
modello deve essere fatto cercando di massimizzare la “copertura” dei casi. Nel caso
del testing però se la copertura non è adeguata non si scoprono possibili bug, nel
caso dei modelli black-box invece si ha una quantità inaccettabile di falsi positivi.
1.1
Anomaly detection e system call
Una tecnica d’attacco notevolmente diffusa è quella di fornire ad un programma un
input confezionato ad hoc per far si che esso esca dal suo comportamento previsto
ed esegua delle azioni utili al malintenzionato. Nel 1996 Aleph One in [17] mostrava
come, sfruttando una copia di stringhe fatta senza controllare i bound, fosse possibile
iniettare codice arbitrario in un programma e portarlo a lanciare una shell. Come
si può immaginare, se il programma gira con privilegi di superutente, in questo
modo è possibile ottenere il controllo completo sul sistema. L’idea di questo tipo di
attacco è molto semplice e in Figura 1.2 è riportato un programma vulnerabile. Nel
frammento di codice la stringa some other string viene copiata in buf, che è un
vettore allocato sullo stack. Non essendoci alcun controllo sul numero di caratteri
4
1
2
3
4
5
6
7
8
9
10
1. Introduzione
void g(void)
{
char buf[128];
strcpy(buf, some_other_string);
}
void f(void)
{
g();
}
Figura 1.2: Buffer overflow.
copiati è possibile scrivere oltre la fine del vettore, fino ad arrivare al record di
attivazione della procedura g. Nel record di attivazione è memorizzato il punto
del programma al quale restituire il controllo una volta che g è terminata: se lo
sovrascriviamo con l’indirizzo di buf e in buf inseriamo del codice eseguibile, esso
verrà eseguito senza problemi. Una buona prassi da seguire durante la stesura del
codice consiste quindi nell’evitare di allocare vettori sullo stack e preferire l’utilizzo di
malloc(). Naturalmente questo non evita totalmente questo tipo di problemi, tant’è
che dal buffer overflow (il tipo di attacco appena visto) si è passati ad altri attacchi
più sofisticati che vanno sotto i nomi di jump to register, heap overflow, return into
libc solo per citarne alcuni. Questo tipo di approccio ha avuto (ed ha) un successo
tale che esistono dei framework (Metasploit) che permettono la costruzione semiautomatica dell’attacco. Gli sforzi fatti per contrastare questo tipo di attacchi sono
stati svariati e vanno da tecniche puramente software, tipo la Address Space Layout
Randomization oppure lo Stack Protector di gcc a tecniche assistite dall’hardware,
tipo il noto execute disable bit (XD) implementato nelle recenti CPU Intel e AMD.
Nella stragrande maggioranza dei casi attaccare tramite l’iniezione di codice malevolo comporta l’esecuzione di chiamate di sistema estranee al normale flusso d’esecuzione di un programma: per lanciare una shell ad esempio è necessario eseguire
almeno una execve(). In Solaris è esistito un buffer overflow nel comando ping
(CVE-1999-0056) che permetteva appunto l’esecuzione di codice arbitrario. Tale
comando necessita di usare le raw socket, che però possono essere aperte solo dal
superutente: ping quindi era installato setuid root e dunque anche il codice iniettato veniva eseguito a privilegi elevati. Ora è chiaro che se ping esegue una execve,
questo è un evento del tutto anomalo perché per svolgere le sue funzioni il comando
in questione non ha bisogno di lanciare in esecuzione alcun processo. Avendo questa
1.2. Obiettivo del lavoro
5
informazione diventa quindi possibile approntare un semplice sistema che osserva le
chiamate di sistema e se non sono pertinenti prende provvedimenti quali impedirle
o addirittura uccidere il processo dal quale sono state fatte.
Già a metà degli anni ’90 ci si è resi conto che l’osservazione delle chiamate di
sistema effettuate da un programma durante la sua esecuzione è un buon modo per
capire se il suo comportamento è normale o meno. In [9] ad esempio viene proposto
un semplice metodo che, osservando le ultime tre chiamate di sistema effettuate da un
processo, è in grado di stabilire con buona approssimazione se il suo comportamento
è anomalo. Negli anni successivi sono stati proposti svariati nuovi metodi basati
sulle più disparate tecniche, sia statiche che dinamiche. Di queste ultime molte di
esse sono di tipo probabilistico, molte di esse si basano su analisi più formali. Le
più recenti tecniche dinamiche presenti in letteratura sono in grado di ricostruire
una parte significativa del control flow di un programma. Successivamente queste
tecniche sono state applicate con successo a sistemi virtualizzati, permettendo di
monitorare le attività dei processi in modo completamente invisibile dall’interno
della macchina virtuale [15].
Presto ci si è resi conto che osservare soltanto qual’era la system call effettuata
era limitativo e si è iniziato a prendere in considerazione anche i parametri. Anche in
questo caso sono state proposte idee molto diverse, prevalentemente di tipo statistico.
Recentemente però è stato proposto un modello in grado di apprendere il data flow
tra le chiamate di sistema.
1.2
Obiettivo del lavoro
Il lavoro presentato in questa tesi è centrato proprio sull’anomaly detection basata
sull’osservazione delle system call. Tra i vari modelli che si possono costruire a
questo scopo ne esistono alcuni basati su automi o su grafi, che si differenziano per
la loro precisione nel rappresentare il control flow del programma. Dal lato data
flow i modelli sono invece tutt’altro che numerosi. In questa tesi verranno prese in
considerazione entrambe le tipologie di modelli, verranno studiate le loro possibilità
sia considerando i modelli presi singolarmente sia se integrati tra di loro (control
flow + data flow) e verranno evidenziate delle debolezze. Verrà quindi proposto un
nuovo modello integrato che cerca di superarle.
6
1. Introduzione
1.3
Struttura della tesi
La tesi si sviluppa in 4 ulteriori capitoli oltre a quello presente.
Nel secondo capitolo verranno esaminati alcuni modelli per il control flow presenti
in letteratura, sia costruiti dinamicamente che staticamente. Verranno mostrate le
tecniche necessarie alla loro costruzione e verranno discussi vantaggi e svantaggi dei
modelli. Finita la discussione dei modelli control flow si osserverà come la protezione
di quest’ultimo non sia sufficiente a impedire che un processo venga attaccato e verrà
mostrato un semplice programma che è vulnerabile ad un attacco che permette di
ottenere una shell senza che il suo control flow sia alterato. Si osserverà quindi
che è necessario proteggere anche il data-flow e dopo una breve discussione verrà
presentato un modello in grado di apprendere relazioni tra i parametri delle chiamate
di sistema, anch’esso presente in letteratura.
Il terzo capitolo si aprirà vedendo come anche unendo un execution graph (uno dei
più potenti modelli control flow) con il modello data flow si possa comunque trovare
dei casi in cui è possibile attaccare il sistema senza essere scoperti. Si osserverà
quindi che questo è dovuto principalmente a due motivi che sono dati da un basso
accoppiamento tra i due modelli e dalla relativa povertà delle informazioni data flow
raccolte. Il modello data flow può infatti trarre notevole vantaggio da informazioni
già raccolte per la costruzione del modello control flow, inoltre verrà data la capacità
al modello data flow di apprendere delle alternative. Senza il loro apprendimento vi
sono casi in cui l’informazione raccolta è veramente povera. Si proporrà dunque un
nuovo modello integrato per control flow e data flow in grado di risolvere i problemi
osservati, assieme a tutti gli algoritmi necessari a costruirlo.
Il quarto capitolo tratterà l’implementazione. In una prima parte verrà analizzato il framework DTrace che permetterà la raccolta dati necessaria alla costruzione del
modello. Dopo aver visto come specificare quali dati si vuole raccogliere si passerà
ad alcuni dettagli di libdtrace(3LIB), necessaria per l’interfacciamento low-level
al framework e per l’estrazione dei dati raw. Verranno poi trattati alcuni dei dettagli
implementativi salienti del sistema e infine, in modo del tutto informale, si discuterà
sulla complessità computazionale del modello, sia dal punto di vista del training sia
dal punto di vista della verifica online.
Il quinto capitolo è dedicato alle conclusioni e alla descrizione dei possibili sviluppi futuri di questo lavoro. In particolare saranno proposte tre possibili estensioni.
La prima cerca di “spremere” ulteriormente i dati già raccolti per la costruzione
del modello per carpire più informazioni sulla struttura interna del programma. La
1.3. Struttura della tesi
7
seconda parte dall’osservazione che questi modelli sono totalmente ciechi di fronte
al denial of service e quindi la proposta in questo caso è di aggiungere una “dimensione” statistica al modello che vada in questa direzione. La terza idea è quella di
cercare di costruire il modello control flow staticamente invece che dinamicamente,
in modo che il compilatore oltre a restituire l’eseguibile restituisca un modello della
sua struttura che verrà successivamente controllato a runtime.
8
1. Introduzione
2
Modelli per l’anomaly detection
In questa tesi si è interessati al rilevamento e alla conseguente segnalazione di comportamenti anomali tenuti da parte di un processo in esecuzione su un elaboratore.
Questo obiettivo prevede innanzitutto il possesso di un modello del comportamento del processo e successivamente la capacità di verificare che il processo, durante
l’esecuzione, si comporti conformemente al modello (Figura 2.1).
Offline
Online
Processo
Processo
Eventi
Eventi
Algoritmo di
apprendimento
Modello
Motore di verifica
del modello
Figura 2.1: Architettura generale di un sistema black-box.
In letteratura si possono trovare decine di metodologie volte alla costruzione di
modelli per l’anomaly detection basate su idee anche molto differenti tra di loro. La
distinzione fondamentale però è forse quella tra metodologie white-box e black-box.
Le prime prevedono di avere a disposizione il codice sorgente (o anche il binario)
del programma in modo da poterlo analizzare staticamente e costruire un modello. Le seconde invece prevedono esclusivamente l’osservazione dell’esecuzione di un
programma e, in base agli eventi generati, costruiscono un modello.
10
2. Modelli per l’anomaly detection
Le tecniche white-box e black-box hanno entrambe vantaggi e svantaggi: se
per esempio si è interessati alla struttura del programma è difficile ricostruirla solo
guardandone varie esecuzioni. Se ci sono dei rami di codice morto per un sistema
black-box è impossibile scoprirli, mentre per un sistema white-box è immediato.
Tuttavia, come vedremo, anche dalle sole osservazioni (a patto di eseguirle correttamente) è possibile ottenere un’incredibile quantità di informazioni. Una delle molte
situazioni in cui invece le tecniche black-box sono avvantaggiate è ad esempio quella
in cui si sta osservando dove si trovano i file che una chiamata ad open() apre: se in
un numero ragionevolmente grande di osservazioni si vede che i file stanno tutti in
una data directory dir si può affermare che quella open() deve aprire solo file che
stanno in dir. Questa, non conoscendo la struttura interna del programma osservato è sicuramente un’informazione tutt’altro che certa ma nonostante l’incertezza è
comunque un’informazione che l’analisi statica nella maggioranza dei casi non può
dare.
In questa tesi si cercherà di modellare per via black-box sia il control flow del
programma che il data flow. Verranno prese in considerazione diverse tecniche già
note, le quali però in certi contesti presentano delle debolezze e dunque l’obiettivo
è di migliorarle e di combinarle in modo da eliminare i problemi che presentano,
ottenendo un modello in grado di rilevare un numero maggiore di attacchi.
2.1
Modelli control flow
I modelli che analizzeremo in questa sezione sono basati sulla descrizione di proprietà
che riguardano il flusso di controllo di un programma. Analizzando gli eventi che
si osservano a runtime è possibile costruire degli automi che rappresentano in modo
più o meno fine le transizioni ammesse per un dato programma.
2.1.1
Automi a stati finiti
Il modello di automi a stati finiti sicuramente più interessante è stato proposto in
[21]. Gli autori osservano come tutti i modelli precedenti abbiano problemi o limitazioni più o meno gravi, o di carattere computazionale [10, 18] o dovute al fatto
che semplicemente è stata proposta una metodologia che poco si presta all’implementazione [12] e propongono una tecnica molto veloce per apprendere un automa
in grado di rilevare una consistente categoria di attacchi.
L’automa viene costruito a partire da una o più tracce ottenute dall’osservazione
del sistema. Ogni traccia è composta da un certo numero di eventi, rappresentabili
2.1. Modelli control flow
11
con una coppia (si , pi ). Ogni evento contiene due informazioni e in particolare la
chiamata di sistema che è stata eseguita e il punto del programma dal quale è stata
eseguita.
L’automa è rappresentabile come un grafo G = (V ∪ {end}, E = V × V × L)
e, dati due eventi consecutivi (si , pi ) e (si+1 , pi+1 ) di una traccia di lunghezza k, la
costruzione avviene nel seguente modo:
• per 0 ≤ i ≤ k: V = V ∪ {pi }
• per 0 ≤ i ≤ k: E = E ∪ {(pi , pi+1 , si )}
• infine: E = E ∪ {(pk , end, sk )}
L’idea dietro a questo automa è che per passare da uno stato ad un altro del programma deve avvenire una transizione causata da una chiamata di sistema. Intuitivamente, questa costruzione porta quindi ad un automa in cui gli stati sono
etichettati con il punto del programma dal quale viene eseguita la system call e gli
archi con la chiamata di sistema coinvolta nella transizione. Vediamo un esempio.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void f(int cond)
{
open();
if (cond % 2)
read();
else
write();
close();
}
int main(void)
{
int i = 3;
while (i--)
f(i);
}
La traccia di una esecuzione del programma d’esempio sarebbe la seguente:
(open, 3), (write, 7), (close, 8), (open, 3), (read, 5),
(close, 8), (open, 3), (write, 7), (close, 8)
12
2. Modelli per l’anomaly detection
open
3
open
5
7
read
write
8
close
end
close
Figura 2.2: FSA d’esempio.
che da luogo agli insiemi
V ={3, 5, 7, 8, end}
E ={(3, 5, open), (3, 7, open), (5, 8, read),
(7, 8, write), (8, 3, close), (8, end, close)}
corrispondenti all’automa rappresentato in Figura 2.2.
2.1.2
VtPath
VtPath [6] è un metodo che migliora gli FSA, andando a guardare l’intero user
space stack del processo nel momento della chiamata di sistema invece di osservare
soltanto il punto del programma in cui la chiamata è stata fatta. Questo sistema
si basa sul concetto di virtual path, di seguito delineato. Siano A = {a1 , . . . , an } e
B = {b1 , . . . , bm } gli stack osservati in due system call consecutive. Essi vengono
confrontati finché non si trova un indice l tale che al 6= bl . A questo punto si definisce
il path tra le due system call come:
P = an → Exit; . . . ; al+1 → Exit; al → bl ; Entry → bl+1 ; . . . ; Entry → bm
Entry ed Exit sono dei nodi fittizi che rappresentano rispettivamente il punto
d’ingresso e il punto d’uscita di una funzione. Questi path vengono appresi durante il training e, a training completato, vengono utilizzati per verificare il corretto comportamento del programma. Possono generarsi differenti anomalie, in
particolare:
• Stack anomaly, se lo stack osservato non è tra quelli appresi durante il training
• Return address anomaly, se uno qualunque dei return address sullo stack non
è corretto rispetto a quelli osservati durante il training
• System call anomaly, se la system call eseguita non è corretta
2.1. Modelli control flow
13
• Virtual path anomaly, se non è possibile trovare il percorso effettuato tra quelli
appresi
2.1.3
Execution graphs
Gli execution graphs costituiscono forse il più potente modello control flow presente
in letteratura ottenuto con tecniche black-box [7] ed è uno dei due componenti da
cui si è partiti per sviluppare il modello presentato in questa tesi.
L’obiettivo dell’execution graph è quello di ottenere, osservando le esecuzioni di
un programma, un modello che accetta le stesse sequenze di chiamate di sistema che
sarebbero accettate da un modello basato sul control flow graph, quindi costruito
staticamente. Naturalmente questo non è possibile perché nel codice potrebbero
esserci dei rami morti, rilevabili solo a tempo di compilazione. Tuttavia utilizzando
solo tecniche black-box si riesce a costruire un execution graph con due proprietà
molto importanti:
• accetta solo sequenze di chiamate di sistema che sono consistenti con il control
flow graph del programma
• il linguaggio accettato dall’execution graph è massimale rispetto ai dati appresi durante il training: in altre parole ogni estensione dell’execution graph
potrebbe far passare inosservati degli attacchi
Definizione 1 (Osservazione ed esecuzione) Un’ osservazione è una n-pla di interi positivi hr1 , r2 , . . . , rk i con k > 1. Un’ esecuzione è una sequenza di lunghezza
arbitraria di osservazioni.
In particolare in un’osservazione hr1 , r2 , . . . , rk i, r1 è un indirizzo in main(), rk−1
è il return address corrispondente all’istruzione che esegue la system call ed rk è il
numero corrispondente alla system call eseguita. In altre parole un’osservazione è
una fotografia dello stack del processo al momento in cui viene eseguita una system
call.
crs
Definizione 2 (Execution graph, foglia, →) Un execution graph per un insieme di esecuzioni X è un grafo EG(X ) = (V, Ecall , Ecrs , Eret ) in cui V è un insieme
di nodi mentre Ecall , Ecrs , Eret ⊆ V × V sono insiemi di archi diretti, definiti come
segue:
14
2. Modelli per l’anomaly detection
• Per ogni esecuzione X ∈ X e ogni osservazione hr1 , r2 , . . . , rk i ∈ X, V contiene
i nodi r1 , . . . , rk . rk è una foglia dell’execution graph. Se l’osservazione a cui
appartiene rk è la prima di un’esecuzione allora rk è detto anche nodo di
ingresso, se è l’ultima è detto anche nodo di uscita.
• Gli insiemi Ecall , Ecrs , Eret sono definiti induttivamente e contengono solo
archi ottenuti dalle seguenti regole:
– Caso base: Per ogni esecuzione X ∈ X e ogni coppia di osservazioni
0
0
0
consecutive hr1 , r2 , . . . , rk i e hr1 , r2 , . . . , rk0 i in X
Ertn ← Ertn ∪ {(ri+1 , ri )}l≤i<k
0
Ecrs ← Ecrs ∪ {(rl , rl )}
0
0
Ecall ← Ecall ∪ {(ri , ri+1 )}l≤i<k0
dove: l =
(
0
0
0
k − 1 se hr1 , r2 , . . . , rk i = hr1 , r2 , . . . , rk0 i
0
0
0
(max j : hr1 , r2 , . . . , rj i = hr1 , r2 , . . . , rj i) + 1
altrimenti
Se rk è un nodo d’ingresso
Ecall ← Ecall ∪ {(ri , ri+1 )}1≤i≤k
Se rk è un nodo d’uscita
0
0
Ertn ← Ertn ∪ {(ri+1 , ri )}1≤i≤k
crs
0
0
– Caso induttivo: Sia r → r se esiste un percorso da r a r composto
solo da archi in Ecrs :
crs
∗ se (x0 , x1 ) ∈ Ecall , x1 → x2 e (x2 , x3 ) ∈ Ertn allora Ertn ← Ertn ∪
{(x2 , x0 )} e Ecall ← Ecall ∪ {(x3 , x1 )}
crs
∗ se (x0 , x1 ) ∈ Ecall , x1 → x2 e (x3 , x2 ) ∈ Ecall allora Ecall ← Ecall ∪
{(x3 , x1 )} e Ecall ← Ecall ∪ {(x0 , x2 )}
crs
∗ se (x1 , x0 ) ∈ Ertn , x1 → x2 e (x2 , x3 ) ∈ Ertn allora Ertn ← Ertn ∪
{(x1 , x3 } e Ertn ← Ertn ∪ {(x2 , x0 }
Vediamo ora un esempio (Figura 2.3), tratto da [7], che mostra l’importanza
della parte induttiva della definizione. Nell’esempio f() è chiamata da due punti
differenti di main() con valori fissati. A causa di questo alcuni percorsi possibili
2.1. Modelli control flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
15
int main() {
int a, b;
a = 1; b = 2;
f(a);
g();
f(b);
}
main.4
void f(int x) {
syscall5();
if (x == 1)
syscall3();
else if (x == 2)
syscall4();
}
f.10
syscall5()
main.5
main.6
f.12
f.14
g.18
syscall3()
syscall4()
syscall2()
void g() {
syscall2();
}
Figura 2.3: Esempio riguardante la parte induttiva della definizione dell’EG.
ma non osservati potrebbero non essere scoperti, in questo caso particolare quelli da
f.14 a main.4 e da f.12 a main.6. Grazie alla parte induttiva si riescono a ricavare
senza problemi.
call rtn
Definizione 3 ( → , →) Sia EG(X ) = (V, Ecall , Ecrs , Ertn ) un execution graph.
call
0
0
r → r se e solo se esiste un percorso da r a r costituito solo da archi in Ecall .
rtn
0
0
Analogamente r → r se e solo se esiste un percorso da r a r costituito solo da
archi in Ertn .
xcall
Definizione 4 ( → , Execution stack) Sia EG(X ) = (V, Ecall , Ecrs , Ertn ) un exexcall
0
cution graph. r → r se e solo se:
0
• (r, r ) ∈ Ecall oppure
00
00
00
crs
• esiste r ∈ V tale che (r, r ) ∈ Ecall e r → r
0
0
0
0
Definizione 5 (Successore) Un execution stack s0 = hr1 , r2 , . . . , rn0 i è un successore di s = hr1 , r2 , . . . , rn i in un execution graph se esiste un intero k tale che
rtn
0
0
call
0
0
rn → rk , (rk , rk ) ∈ Ecrs , rk → rn0 e ri = ri per 1 ≤ i < k.
Intuitivamente quest’ultima definizione significa che affinché s0 sia successore di s i
due stack devono:
16
2. Modelli per l’anomaly detection
• essere uguali nei livelli dal primo al k-esimo
• nel caso di s ci devono essere tutti gli archi di return da rn a rk
0
0
• nel caso di s0 ci devono essere tutti gli archi di call da rk a rn0
0
• deve esserci un arco da rk a rk in Ecrs
rn
rtn
call
rtn
call
...
rk
r'n'
...
crs
r'k
...
...
r2
r'2
r1
r'1
Figura 2.4: Rappresentazione grafica del significato di successore.
Questa definizione è forse la più importante di questa sezione: essa gioca un ruolo cruciale nell’implementazione del metodo degli execution graph perché specifica
esattamente ciò che deve fare il software. Una volta eseguito il training l’IDS avrà
a disposizione gli insiemi V, Ecall , Ecrs ed Ertn e per verificare che il programma
stia eseguendo operazioni conformi a quelle apprese durante il training è sufficiente
verificare che valga la relazione di successore tra gli stack che man mano si osservano.
2.1.4
Abstract stack, un modello costruito staticamente
Per completezza riportiamo un modello costruito staticamente [24], denominato abstract stack model. L’idea in questo caso è quella di costruire un automa pushdown
non deterministico che riconosce un linguaggio context-free. I simboli del linguaggio
sono le chiamate di sistema.
Supponiamo di avere il control flow graph G = hV, Ei del programma, CFG che
include gli archi interprocedurali. Si costruisce un NDPDA il quale ha un alfabeto
che provoca operazioni sullo stack V ∪ Σ, un alfabeto di input Σ e un insieme
di transizioni che avvengono come spiegato di seguito. Inizialmente nello stack
dell’automa è presente un simbolo v ∈ V :
• se v è un nodo corrispondente ad una chiamata alla funzione f lo si toglie dallo
stack, inserendo successivamente il nodo di ritorno v 0 e il punto di ingresso di
f denotato con Entry(f )
2.2. Modelli data flow
17
• se v è Exit(f ) semplicemente si rimuove v
• se v non riguarda una chiamata di funzione ma una system call si toglie v e si
inserisce s ∈ Σ e non-deterministicamente si sceglie w : (v, w) ∈ E e si inserisce
w
Se invece s si trova sulla cima dello stack si verifica che s = s0 , dove s0 è il simbolo
corrente dell’input. Se l’uguaglianza è verificata si estrae s dallo stack e si procede,
altrimenti si entra in uno stato d’errore che viene segnalato.
2.2
Modelli data flow
I precedenti modelli visti focalizzano la loro attenzione esclusivamente sul flusso di
controllo del programma, senza tenere in alcuna considerazione i dati (ovvero i parametri) coinvolti nelle chiamate di sistema. Tuttavia monitorare anche i parametri
si rivela di notevole importanza. Si prenda ad esempio un attacco che sfrutta una
race condition del tipo TOCTTOU (Time of check to time of use) dove una risorsa
riferita da un nome cambia tra il momento in cui viene fatto un test e il momento
in cui viene usata (si veda l’esempio di Figura 2.5). In un attacco come questo le
chiamate di sistema fatte da un programma sono sempre le stesse ma l’interpretazione dei loro parametri in un momento piuttosto che in un altro è completamente
diversa.
if (access("file", W_OK) != 0) {
exit(1);
}
/* In questo esatto momento in un altro processo un
attaccante esegue symlink("/etc/passwd", "file"); */
fd = open("file", O_WRONLY);
write(fd, buffer, sizeof(buffer));
Figura 2.5: Time to check to time of use.
Un altro scenario è quello delineato in [4]. Gli autori notano che chi attacca
un sistema attualmente è concentrato su metodologie che portano il processore ad
eseguire codice in qualche modo estraneo al programma (control data attacks: buffer
overflow, heap overflow, return-to-libc,... ricadono in questa categoria). Da questa
osservazione si chiedono se, nel momento in cui il control flow è protetto, diventa
18
2. Modelli per l’anomaly detection
possibile e realistico costruire degli attacchi (non-control data attacks) che non hanno
bisogno di portare il sistema ad eseguire codice malevolo. Dal loro studio emerge che
oltre ad essere perfettamente possibile, la gravità degli attacchi non-control data è
equivalente a quella dei più classici control-data. In [4] vengono identificate 4 classi
di dati critici per la sicurezza del software:
• dati di configurazione
• input dell’utente
• identità dell’utente
• dati di decision-making
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define STRSZ 32
int authenticate(char *user, char *password) {
if ( (strncmp(user, "matteo", STRSZ) == 0) &&
(strncmp(password, "pippo123", STRSZ) == 0) )
return 1;
return 0;
}
int main(void) {
int authenticated = 0;
char user[32], password[32];
gets(user);
gets(password);
if ( authenticate(user, password) )
authenticated = 1;
if (authenticated) system("/bin/sh");
else printf("Not allowed\n");
}
Figura 2.6: Banale programma vulnerabile ad un non-control data attack.
Delle quattro l’ultima è sicuramente la più interessante: vi sono alcuni dati che sono necessari affinché un programma decida che azione intraprendere e, riuscendo a
corromperli, è possibile portare un programma a fare qualcosa di contrario rispetto
a ciò che andrebbe fatto. Prendiamo ad esempio il programma di Figura 2.6: il
2.2. Modelli data flow
19
suo scopo è autenticare un utente su un sistema e per farlo utilizza una procedura
authenticate() (che potrebbe essere di complessità arbitraria). Se l’autenticazione
ha successo viene impostato un flag per ricordarlo ed il programma può procedere.
L’uso non sicuro di gets() permette però ad un utente malintenzionato di impostarlo ad un valore diverso da zero e, anche se l’autenticazione non ha successo, l’accesso
al sistema viene garantito. Al di là del fatto che gets() è qualcosa di talmente
pericoloso che se viene usata viene generato un warning dal compilatore, in un caso
come questo un IDS potrebbe verificare i valori di ritorno delle read() provocate
dalle gets() ed, essendo anomali, uccidere il processo.
È notevole fino a dove ci si sia spinti con attacchi classificabili come non-control
data: in [3], provocando fallimenti casuali all’hardware e dunque errori nelle computazioni, è stato mostrato come sia possibile rendere del tutto vulnerabili alcuni
codici crittografici, RSA compreso. È dunque chiaro come, oltre al control flow, sia
necessario proteggere anche il data flow.
2.2.1
Un modello data flow
Nel tempo diversi sono stati i tentativi di utilizzare anche i parametri nel rilevamento
di comportamenti anomali [13, 22, 8] e gli approcci utilizzati sono stati prevalentemente statistici. Un modello notevole però è quello proposto in [2]. Gli autori
mostrano una tecnica che permette di apprendere delle relazioni tra i parametri delle system call. Ad esempio, se viene eseguita una open() ed il file descriptor da
essa restituito viene usato da una successiva read() e da una successiva close(), il
sistema è in grado di apprendere che il valore del file descriptor tra le tre chiamate
deve essere uguale.
Il sistema è in grado di apprendere sia relazioni unarie, ovvero che valgono puntualmente su un singolo argomento, sia binarie, ovvero che valgono tra due argomenti
differenti.
Algoritmo 1: Algoritmo del modello data flow.
learnRelations(EvArg X, Value V);
Y = lookup(V );
CurRels[R][X] = CurRels[R][X] ∩ Y;
Yn = Y ∩ N ewArgs(X);
CurRels[R][X] = CurRels[R][X] ∪ Yn ;
update(X, V );
20
2. Modelli per l’anomaly detection
Nello pseudocodice R è la relazione che si vuole apprendere, che potrebbe essere
equals, elementOf, inRange, ... La relazione appresa dipende sostanzialmente
da cosa restituisce lookup() nel primo passo: questa chiamata infatti ha il compito
di restituire tutti i nomi dei parametri precedentemente visti che avevano valore in
relazione con V . Ad esempio:
Esempio 1 (lookup) Si è osservata:
• la traccia (X = 5); (Y = 6); (Z = 5); (W = 4); (Q = 5) e si sta processando l’evento P = 5. Si vuole imparare la relazione equals, quindi lookup(5)
restituisce {X, Z, Q}.
• la traccia (X = /a/b/c); (Y = /a/b); (Z = /zz); (W = /a/b/c/d) e si sta
processando l’evento Q = /a. Si vuole apprendere la relazione isWithinDir,
quindi lookup() dovrà restituire {X, Y, W }
Prima di proseguire dobbiamo notare che in questo modo si apprende la R esclusivamente rispetto ad un preciso punto di una traccia. Quello che invece si vuole è
apprendere qualcosa che sia valido lungo tutta la traccia, ovvero RT . Quindi, se R
è la relazione che si vuole apprendere, è necessario definire il suo lifting RT su una
traccia. Quello che l’algoritmo apprenderà sarà proprio RT (RT se applicato a più
tracce).
Definizione 6 (Lifting di una relazione R su una traccia) Sia data una relazione R tra due argomenti X e Y . Scriviamo X RT Y se e solo se per ogni occorrenza di X e l’occorrenza di Y immediatamente precedente vale X R Y . Sia T
l’insieme di tutte le tracce osservate: se ∀T ∈ T : X RT Y allora X RT Y .
Nel secondo passo CurRels tiene traccia della RT finora appresa e, intersecando
con il risultato di lookup() vengono scartati tutti quei parametri per i quali non vale
più R. Nel terzo passo N ewArgs(X) è una funzione che restituisce tutti gli eventi
che compaiono per la prima volta dopo la precedente occorrenza di X e, intersecando
il suo risultato con Y, si possono identificare i nuovi eventi che si trovano in relazione
con X, per poi aggiungerli a CurRels al passo successivo.
Esempio 2 (NewArgs) Sia data la sequenza di eventi
A,B,C,D,B,E,C,F,D,A,...
• N ewArgs(B) è {A} per la prima occorrenza di B, è {C, D} per la seconda
2.3. Discussione dei modelli studiati
21
• N ewArgs(C) è {A, B} per la prima occorrenza di C, è {D, E} per la seconda
In Figura 2.7 è riportato un piccolo programma insieme alle informazioni apprese
dal modello data flow.
1 int main(void)
2 {
3
int fd;
4
fd = open("test.txt");
5
write(fd,"Hello");
6
close(fd);
7 }
fd@5 equals fd@4
fd@6 equals fd@4
fd@6 equals fd@5
Figura 2.7: Informazioni apprese a runtime dall’analisi data flow.
2.3
Discussione dei modelli studiati
In questo capitolo sono stati descritti quattro modelli control flow ed uno data flow.
Dei quattro modelli data flow, tre sono costruiti black-box, ovvero senza bisogno di
aver accesso al codice sorgente: questo permette una loro applicazione universale
a differenza del quarto modello che, pur essendo potenzialmente più preciso, trova
limitata applicabilità. La costruzione di questo modello, dovendo essere effettuata
staticamente, deve essere supportata da un tool in grado di riconoscere il linguaggio
sorgente del programma o addirittura dal compilatore stesso e questo ne accresce
notevolmente la complessità implementativa. Inoltre, per gli execution graphs, in
[7] viene dimostrata una proprietà molto importante, ovvero che il linguaggio riconosciuto da un execution graph è contenuto in quello riconosciuto dal control flow
graph del medesimo programma. Questo significa che, a patto di eseguire un training corretto, viene colta praticamente tutta la struttura del programma utile per
l’anomaly detection.
I modelli sono stati presentati in ordine crescente di capacità di rivelare eventuali
anomalie e le loro caratteristiche sono riassunte nella Tabella 2.1. Le relazioni ⊆ e
⊇ in questo caso significano rispettivamente che le sequenze accettate dal modello
sono un sottoinsieme e un soprainsieme di quelle ammissibili dal control flow graph.
All’atto pratico questo si traduce nella presenza di falsi positivi e falsi negativi. In
particolare il modello FSA può riconoscere come non valide transizioni che invece
lo sono, generando falsi positivi (a causa di un training insufficiente) ma può anche
riconoscere come valide transizioni che non lo sono, generando falsi negativi (ad
22
2. Modelli per l’anomaly detection
esempio il problema dell’impossible path) e questo è causato dall’eccessiva semplicità
del modello.
L’execution graph invece può certamente generare falsi positivi, quindi riconoscere come attacchi sequenze di stack che non lo sono (e questo è causato di nuovo dal
training insufficiente), ma le successioni di stack che ammette sono tutte ammesse
anche dal control flow graph.
Modello
FSA
VtPath
Execution graph
Abs. Stack
Costruzione
Dinamica
Dinamica
Dinamica
Statica
Relazione con CFG
né ⊆ né ⊇
né ⊆ né ⊇
Dimostrato formalmente ⊆
= per costruzione
Tabella 2.1: Caratteristiche dei modelli.
Il modello FSA è estremamente semplice e se l’automa viene costruito con l’opportuna cura si rivela efficace contro una discreta quantità di attacchi. Un buffer
overflow per esempio, fatto nel modo standard [17], viene rivelato senza grossi problemi. Anche l’implementazione di un IDS basato su questa tecnica non presenta
difficoltà, se non quelle legate alla gestione di fork ed exec [21]. La semplicità del
modello però è anche la sua debolezza ed è facile immaginare casi in cui l’automa
non è in grado di osservare comportamenti anomali. Uno di questi casi è quello
dell’impossible path. Supponiamo che in un programma ci sia una funzione f() e
che il codice al suo interno sia vulnerabile ad un buffer overflow: l’attaccante potrebbe modificare il return address di f() in modo da farla ritornare in un punto
diverso da quello di chiamata (il codice di Figura 2.3 potrebbe essere un esempio di
questo scenario, si entra da riga 4 e si esce da riga 6). Questo modello è totalmente
cieco a questo tipo di attacco e questa è una mancanza grave, in quanto il codice
tra le due invocazioni di f() potrebbe essere ad esempio quello che verifica delle
credenziali e consente o meno l’accesso al sistema.
Il modello VtPath si avvicina all’execution graph e per come è costruito è verosimile che le stringhe di system call che accetta sono un sottoinsieme di quelle
che sarebbero accettate dal control flow graph, questo fatto però non è provato
formalmente dagli autori.
Il modello dell’execution graph infine è sicuramente quello più interessante proprio per il fatto che è stato relazionato formalmente con il control flow graph, mostrando che le stringhe riconosciute sono contenute in quest’ultimo. Questo significa
che possono esserci casi in cui l’execution graph riconosce come illecita un’azione
2.3. Discussione dei modelli studiati
23
invece consentita (e questo succede a causa del training insufficiente) ma non ci
sono casi in cui sequenze non contenute nel control flow graph passano inosservate
all’execution graph.
Si è poi visto che una volta che il control flow è protetto è comunque possibile
attaccare un programma con attacchi del tipo non-control-data. Non è quindi sufficiente proteggere solo il flusso di controllo ma è necessario proteggere anche quello
dei dati. Si è allora studiato un modello data flow in grado di apprendere relazioni
tra i parametri delle system call.
L’obiettivo sarà, nel prossimo capitolo di questa tesi, integrare execution graph e
data flow in un unico modello più potente e migliorarne la capacità di individuazione
di comportamenti anomali.
24
2. Modelli per l’anomaly detection
3
Un modello che integra control
flow e data flow
L’execution graph e il modello data flow visti, a patto di eseguire correttamente il
training, se uniti costituiscono uno strumento molto potente per il rilevamento di
comportamenti anomali da parte del software che gira su un elaboratore. Il modello
costruito con questa tecnica rappresenta molto fedelmente e in modo molto sintetico
quello che è concesso e quello che non è concesso fare ad un programma. Tuttavia,
anche assumendo un training perfetto, vi sono situazioni in cui diventa impossibile
rilevare che il programma monitorato non sta facendo quello per cui è stato pensato.
La debolezza sorge dal fatto che l’accoppiamento del modello data flow con il modello
control flow è relativamente basso.
3.1
Debolezze dei modelli esistenti
Il software, come è noto, è costruito “a strati” e tipicamente le parti più basse possono essere richiamate da diverse parti che si collocano più in alto nell’architettura.
Ad esempio, un web server avrà del codice dedicato alla gestione dei log, codice
che viene richiamato dal codice che si occupa dell’autenticazione, da quello che si
occupa dell’interazione con i client e cosı̀ via. Pensando ad un’ipotetica funzione
log event(), se questa viene chiamata dal codice di autenticazione provocherà la
scrittura nel log-file che un dato utente, ad esempio, ha immesso la password errata,
mentre se chiamata dal codice che interagisce con i client provocherà, ad esempio, la
scrittura di un messaggio che informa l’amministratore che un dato client ha inviato
una richiesta malformata. Le proprietà data flow apprese dal modello di [2] però
non saranno in grado di dire nulla di più di qualcosa come
26
3. Un modello che integra control flow e data flow
“la funzione log event() può scrivere nel log-file che un utente ha fallito
l’autenticazione oppure che un client ha inviato una richiesta malformata”.
Questo permette ad un ipotetico attaccante di costruire attacchi tali che il programma, pur rimanendo all’interno del control flow ammesso, riporti informazioni
diverse da quelle che normalmente dovrebbe riportare. Nel contesto di un processo
industriale critico, questo potrebbe rappresentare un notevole problema. Sarebbe
ideale quindi riuscire a far si che le informazioni data flow che vengono apprese siano
qualcosa di più dettagliato, qualcosa del tipo
“la funzione log event(), se chiamata dal codice che si occupa dell’autenticazione può scrivere nel log-file che un utente ha inserito la password
errata, mentre se chiamata dal codice di dialogo col client può scrivere
che un client ha inviato una richiesta malformata”.
L’osservazione fondamentale che consente questo miglioramento è che una chiamata di sistema è caratterizzata in modo molto più preciso se, invece di considerare
soltanto la sua posizione assoluta nel codice, si considera anche il contesto in cui è
eseguita, ovvero la sequenza di record di attivazione che la precedono sullo stack.
Nei due casi si osserveranno sequenze differenti e questo consente di capire che si
è arrivati a log event() da due strade diverse. L’informazione relativa allo stack
viene già raccolta per la costruzione dell’execution graph e dunque vale la pena far
si che ne benefici anche il modello data flow. In questo modo, per un ipotetico IDS
costruito con queste tecniche, è immediato capire da dove proviene la richiesta di
inserire un messaggio nel log-file e quindi capire se quel messaggio è lecito oppure
no. Vediamo, nella pratica, come può presentarsi questo scenario.
3.1.1
Primo scenario: vulnerabilità nel codice
Nel programma della Figura 3.1 è presente un evidente buffer overflow, dovuto all’uso non sicuro della funzione strcpy(). Il buffer overflow permette di eseguire
codice arbitrario e, nel caso il programma sia installato setuid root, permette ad un
attaccante di prendere il controllo completo del sistema. Un attacco tramite buffer
overflow fatto nel modo standard tuttavia viene immediatamente rilevato anche dal
più semplice FSA (Figura 3.2). Quello che però l’FSA non può rilevare è un attacco
che, iniettando codice opportuno in buf, esegue un numero arbitrario di chiamate
a put str() (e quindi a write) con parametri arbitrari. Le cose non migliorano di
3.1. Debolezze dei modelli esistenti
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <unistd.h>
void put_msg(char *msg)
{
write(1, msg, strlen(msg));
}
void g(void)
{
put_msg("Hello,");
}
void f(char *path)
{
char buf[128]; /* Il programmatore non ha usato MAX_PATH! */
strcpy(buf, path);
/* ... qualcosa che non siano system calls ... */
}
int main(void)
{
char *path = chiedi_nome_file(); /* read() */
f(path);
g();
put_msg("world\n");
}
Figura 3.1: Programma vulnerabile d’esempio.
write
22
read
5
write
end
Figura 3.2: FSA per il primo esempio.
28
3. Un modello che integra control flow e data flow
molto aggiungendo la data flow analysis standard al modello e, sempre sfruttando
l’overflow, è possibile condurre un attacco che sfugge anche ad un ipotetico IDS
basato su execution graphs e data flow.
chiedi_nome_file
read
main.22
main.24
main.25
put_msg.5
g.10
write
Figura 3.3: Execution graph per il primo esempio.
Supponiamo quindi di avere a disposizione un sistema IDS basato su execution
graphs e data flow analisys. Quello che imparerebbe per questo programma è che i
valori ammissibili come secondo parametro della write sono Hello, e world\n:
buf@5 elementOf {“Hello,”, “world\n” }
A livello di control flow invece imparerebbe, nel caso di EG, un automa (Figura 3.3)
che ammette soltanto la seguente sequenza di stack:
[main.22|chiedi_nome_file|read]
[main.24|g.10|put_msg.5|write]
[main.25|put_msg.5|write]
Un ipotetico attacco dunque, per non essere scoperto, dovrebbe rispettare i seguenti
vincoli:
• eseguire una read seguita da due write
• eseguire le chiamate di sistema facendo in modo che lo stack, durante la loro
esecuzione, rispetti una certa struttura
• eseguire le write con i parametri Hello, oppure world\n
3.1. Debolezze dei modelli esistenti
main
29
main.24
0x1234
main
main
1
2
3
g.11
msg
main
main.25
main.25
main
main
4
5
6
Figura 3.4: Passi dell’attacco al programma.
Ci rimane quindi la possibilità di attaccare il programma in modo da far assomigliare
la prima write alla seconda, facendole scrivere lo stesso messaggio. I passi per
ottenere questo risultato sono illustrati nella Figura 3.4 e descritti di seguito (si
veda ad esempio [1] per informazioni sulle convenzioni di chiamata in Unix/Intel):
1. è stato letto l’input da chiedi nome file(), quindi la read() è stata eseguita.
Lo stack contiene solo il frame di main()
2. la f() parte e fa il setup del suo frame
3. la strcpy() copia la stringa puntata da path in buf e sovrascrive il return
address di f(). È essenziale che il valore del frame pointer sullo stack sia
conservato intatto
4. la f() è ritornata, il suo frame è stato distrutto e sta per essere eseguito il
codice iniettato nel buffer
5. lo shellcode fa una push del return address per g() ed esegue:
push %ebp
mov %esp, %ebp
Il finto stack frame per g() è al suo posto
6. lo shellcode fa la push dell’indirizzo di uno qualsiasi dei messaggi considerati
validi per put msg() e del return address. Il punto di ritorno deve essere
nell’epilogo di g() e precisamente a mov %ebp, %esp (o leave). Rimane da
30
3. Un modello che integra control flow e data flow
eseguire:
jmp put msg
A questo punto avviene la write con l’esatto stack che l’IDS si aspetta e dunque
non viene segnalata come non valida. Terminata la put msg() viene distrutto il suo
stack frame e viene passato il controllo al codice dell’epilogo di g(), che distrugge
il relativo frame. Il controllo infine torna al main(), dove verrà eseguita la seconda
chiamata di put msg().
Per poter ricostruire lo stack però è necessario ottenere alcune informazioni, in
particolare i return address delle procedure coinvolte. Nel nostro esempio vogliamo
simulare la prima write , quindi lo stack da ricostruire è:
[main.22|g.10|put_msg.5|write]
La prima informazione che ci serve è il punto in cui ritorna g quando è chiamata
dalla riga 22 di main:
(gdb) disas main
Dump of assembler code for function main:
0x00001fc7 <main+0>: push
%ebp
0x00001fc8 <main+1>: mov
%esp,%ebp
0x00001fca <main+3>: push
%ebx
0x00001fcb <main+4>: sub
$0x14,%esp
0x00001fce <main+7>: call
0x1fd3 <main+12>
0x00001fd3 <main+12>: pop
%ebx
0x00001fd4 <main+13>: call
0x1fae <f>
0x00001fd9 <main+18>: call
0x1f8d <g>
0x00001fde <main+23>: lea
0x26(%ebx),%eax
0x00001fe4 <main+29>: mov
%eax,(%esp)
0x00001fe7 <main+32>: call
0x1f4e <put_msg>
0x00001fec <main+37>: add
$0x14,%esp
0x00001fef <main+40>: pop
%ebx
0x00001ff0 <main+41>: leave
0x00001ff1 <main+42>: ret
End of assembler dump.
La seconda informazione riguarda put msg:
; <= questo
3.1. Debolezze dei modelli esistenti
31
(gdb) disas g
Dump of assembler code for function g:
0x00001f8d <g+0>: push
%ebp
0x00001f8e <g+1>: mov
%esp,%ebp
0x00001f90 <g+3>: push
%ebx
0x00001f91 <g+4>: sub
$0x14,%esp
0x00001f94 <g+7>: call
0x1f99 <g+12>
0x00001f99 <g+12>: pop
%ebx
0x00001f9a <g+13>: lea
0x59(%ebx),%eax
0x00001fa0 <g+19>: mov
%eax,(%esp)
0x00001fa3 <g+22>: call
0x1f4e <put_msg>
0x00001fa8 <g+27>: add
$0x14,%esp
0x00001fab <g+30>: pop
%ebx
; <= questo
0x00001fac <g+31>: leave
0x00001fad <g+32>: ret
End of assembler dump.
Se il data flow fosse stato di tipo “context sensitive” questo attacco non sarebbe
stato possibile.
3.1.2
Secondo scenario: errore di configurazione di un server
In questo scenario la vulnerabilità non è legata a come è stato scritto il codice, bensı̀
ad un errore di configurazione di un ipotetico server (Figura 3.5). Il server permette
ai suoi utenti di richiedere oppure di inviare dei files. Tipicamente applicazioni
simili a questa, tramite la chiamata chroot, vengono confinate all’interno di una ben
precisa area del filesystem. Accade però che a volte la chroot jail venga disabilitata,
per errore o deliberatamente, esponendo il sistema a dei rischi.
Ora, immaginiamo che il server giri su un sistema in cui è installato un IDS basato sui modelli esistenti. Durante il training il server viene lanciato e successivamente
gli vengono sottoposte richieste legittime. L’algoritmo di apprendimento non imparerebbe nulla, o comunque imparerebbe poco, relativamente ai parametri della open:
a causa di questo una richiesta da parte di un client di /etc/simpleserver.conf
sarebbe riconosciuta come legittima e non segnalata. Il file di configurazione potrebbe contenere informazioni sensibili, come ad esempio dati di autenticazione ad
altri servizi, quindi il suo furto rappresenta un problema concreto.
32
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
3. Un modello che integra control flow e data flow
#include <fcntl.h>
#define CONFIG "/etc/simpleserver.conf"
#define BUFSZ 1024
read_file(char *name, char *buf) {
fd = open(name, O_RDONLY);
read(fd, buf);
close(fd);
}
write_file(char *name, char *buf) {
fd = open(name, O_WRONLY);
write(fd, buf, len);
close(fd);
}
server_loop(struct config *cfg)
{
while (1) {
fd = accept_client();
read_req(fd, req);
if (req->method == GET) {
read_file(req->file, tmpbuf);
send(fd, tmpbuf);
}
else if (req->method == PUT) {
recv(fd, tmpbuf);
write_file(req->file, tmpbuf);
break;
}
}
}
main(void)
{
read_file(CONFIG, configbuf);
cfg = parse_config(configbuf);
if(cfg->admin_wants_chroot)
chroot(cfg->chroot_jail);
server_loop(cfg);
}
Figura 3.5: Programma per il secondo esempio.
3.1. Debolezze dei modelli esistenti
33
Il mancato apprendimento di informazioni utili relative ai parametri della open è
legato alla numerosità dei casi che potrebbero presentarsi: un client infatti potrebbe,
del tutto legittimamente, eseguire la PUT di un file che ha un nome non incontrato durante il training e poi farne la GET. Non è ovviamente possibile segnalare
un’intrusione ogni qualvolta si presenti questa situazione.
Quello che l’IDS potrebbe però apprendere è dove i file richiesti sono ubicati.
Supponendo che la root directory del server sia /srv, verrà appreso che il parametro
di open deve essere un file nelle directory /srv oppure /etc. Questa informazione
tuttavia non aiuta a capire se è stato tentato il download del file di configurazione.
Se osserviamo il sorgente possiamo notare però che l’unico caso in cui è richiesto
di aprire un file in /etc è quando il server viene lanciato, tramite la chiamata
read file in main. In altre parole, se lo stack è
[main.37|read_file.6|open]
allora il file che viene passato alla open deve essere /etc/simpleserver.conf,
mentre se lo stack è
[main.42|server_loop.24|read_file.6|open]
allora può essere aperto qualunque file stia in /srv. Se si costruisce il modello usando
anche le informazioni sullo stack è molto semplice rilevare che queste condizioni
valgano a runtime.
28
read
read
21
read
open
7
read
accept
12
20
close
open
13
6
write
14
close
8
accept
end
Figura 3.6: FSA per il secondo esempio.
In questo caso naturalmente il comportamento illecito è stato consentito non da
un errore da parte di chi ha creato il software e dunque da un bug, ma da parte di
34
3. Un modello che integra control flow e data flow
chi lo ha configurato e, riprendendo la terminologia usata nel precedente capitolo,
si può classificare questo problema come un non-control data attack. Anche questa
volta si può vedere come i modelli classici siano totalmente ciechi di fronte ad una
simile eventualità mentre un modello data flow che tiene conto dello stack potrebbe
venire in aiuto. L’automa a stati finiti (Figura 3.6) non può fare nulla in questa
situazione perché il control flow rimane totalmente lecito, stesso discorso vale per
l’execution graph (Figura 3.7). Se abbinati al modello data flow la situazione non
cambia perché il modello, come visto, non è sufficientemente potente.
main.37
main.40
read_file.6
chroot
main.42
read_file.7
read_file.8
open
write_file.12
server_loop.29
server_loop.20
server_loop.21
accept
write_file.13
server_loop.24
server_loop.25
send
write
read
write_file.14
server_loop.28
close
recv
Figura 3.7: Execution graph per il secondo esempio.
3.1.3
Terzo scenario: debolezza delle relazioni binarie
Finora abbiamo considerato unicamente le relazioni unarie e abbiamo visto, a livello
intuitivo, quello che è necessario fare affinché vengano apprese in modo più preciso e
dunque il rilevamento di comportamenti anomali sia più accurato. L’algoritmo data
flow tuttavia presenta delle debolezze anche per ciò che riguarda le relazioni binarie.
Consideriamo l’esempio di Figura 3.8 e in particolare osserviamo le tracce di
tutte le possibili esecuzioni:
Prima esecuzione: [fd@4 = 1 | fd@10 = 1 | fd@11 = 1]
Seconda esecuzione: [fd@6 = 2 | fd@10 = 2 | fd@11 = 2]
Ciò che impara l’algoritmo data flow da queste tracce è soltanto
• fd@10 = fd@6
• fd@11 = fd@6
• fd@11 = fd@10
3.2. Proposta
35
1 int main(void)
2 {
3
if (...)
4
fd = open(FILE_1);
5
else
6
fd = open(FILE_2);
7
8
[...]
9
10
write(fd, buf);
11
close(fd);
12 }
Figura 3.8: Codice d’esempio per il terzo scenario.
che, a prima vista, sembra addirittura sbagliato. Se per esempio, finito il training,
si mette il programma in produzione e la prima traccia che arriva è
[fd@4 = 1 | fd@10 = 1 | fd@11 = 1]
cosa si deve fare, dato che fd@10 non è uguale a fd@6? Il punto è che la condizione
sta parlando di qualcosa che non esiste, ovvero fd@6, perché non si è mai passati da
riga 6. Ma questo è davvero lecito? Il modello cosı̀ com’è in questo caso è molto
debole e se un attaccante riuscisse a sovrascrivere la variabile fd, l’IDS non se ne
accorgerebbe. Sarebbe ideale imparare:
• fd@10 = fd@11
• (fd@10 = fd@4) OR (fd@10 = fd@6)
L’evoluzione dell’algoritmo proposta in questa tesi va a risolvere anche questo tipo
di problema. L’idea di base è quella di imparare le relazioni in modo differenziato
rispetto al valore degli argomenti. Cosı̀ facendo, nel caso fd valga 1 viene appreso che (fd@10 = fd@4) mentre nel caso valga 2 viene appreso (fd@10 = fd@6).
Combinando queste due informazioni quello che si può dire è esattamente (fd@10 =
fd@4) OR (fd@10 = fd@6).
3.2
Proposta
Nella precedente sezione si sono valutati alcuni dei modelli proposti in letteratura,
sia di tipo control flow sia di tipo data flow. Nonostante, soprattutto se combinati,
36
3. Un modello che integra control flow e data flow
la loro efficacia nel proteggere un’applicazione sia elevata, si sono identificati dei casi
in cui degli attacchi passano inosservati. I falsi negativi sono dovuti al fatto che le
informazioni raccolte sono troppo povere e prive di un adeguato contesto. In questa
tesi si propone un modello integrato control flow/data flow in grado di risolvere
questi problemi e di rilevare attacchi non visibili dai modelli già esistenti.
Per quanto riguarda la parte control flow si è adottato il modello dell’execution
graph. La sua precisione è estremamente elevata e forse anche difficile da superare.
Per quanto riguarda la parte data flow invece si sono studiate delle estensioni al
modello di [2] relative alle relazioni unarie e alle relazioni binarie, inoltre lo si è
dotato della capacità di apprendere delle alternative.
chiedi_nome_file
read
main.22
main.24
main.25
put_msg.5
g.10
write
stack = [main.22 | g.10 | put_msg.5] implies buf@5 = “Hello,”
stack = [main.25 | put_msg.5] implies buf@5 = “world\n”
Figura 3.9: Nuovo modello per l’esempio del primo scenario.
Il risultato può essere visto come un execution graph annotato su ogni nodo
foglia con delle informazioni data flow che per ogni esecuzione valida del programma
devono essere verificate. Se non lo sono si è di fronte ad un comportamento anomalo,
verosimilmente un attacco.
Durante il training ad ogni evento viene aggiunta informazione al modello. Ogni
chiamata di sistema viene osservata assieme allo stack user-space e a tutti i suoi
parametri. Lo stack viene utilizzato per costruire l’execution graph e per contestualizzare le relazioni unarie, mentre i parametri vengono utilizzati nell’apprendimento
sia delle relazioni unarie che delle relazioni binarie.
Nella sua forma più semplice l’idea per apprendere le relazioni unarie è quella
di creare una tabella in cui le righe sono indicizzate dagli stack che si osservano
mentre le colonne sono indicizzate dai nomi dei parametri. Ad ogni chiamata di
3.3. Costruzione del modello
main.4
37
main.6
open
main.10
main.11
write
close
fd@10=fd@4
OR
fd@10=fd@6
fd@11=fd@10
Figura 3.10: Nuovo modello per l’esempio del terzo scenario.
sistema si individua la cesella che interessa e vi si aggiunge il valore del parametro
osservato. Concluso il training si va ad osservare ogni casella: se contiene un valore
soltanto l’informazione è che un dato parametro, in un dato contesto, può assumere
solo quel valore. Se i valori invece sono molteplici c’è tutta una serie di possibilità:
andando a prenderli tutti si può costruire la relazione elementOf, mentre prendendo
ad esempio il minimo ed il massimo si può costruire inRange.
Riguardo all’apprendimento delle relazioni binarie, come si è detto si vuole poter
apprendere delle alternative. Anche l’idea per ottenere questo risultato è molto
semplice e prevede di apprendere relazioni differenziate in base al valore osservato
del parametro. Concluso il training si passa ad una seconda fase, in cui si osserva se
le relazioni apprese sono identiche per ogni valore del parametro o meno. Nel primo
caso non si è appreso nulla di differente dal modello precedente ma nel secondo caso
si sono apprese le alternative cercate.
3.3
Costruzione del modello
Per costruire il modello proposto è necessario:
• costruire l’execution graph
• apprendere le relazioni unarie
• apprendere le relazioni binarie
3.3.1
Algoritmo di apprendimento delle relazioni unarie
L’apprendimento delle relazioni unarie è molto semplice, almeno nel caso delle relazioni equals e elementOf. Inoltre non è molto complicato rendere l’algoritmo
“furbo” e far si che impari anche relazioni come inRange o isWithinDir.
38
3. Un modello che integra control flow e data flow
Algoritmo 2: Algoritmo di apprendimento delle relazioni unarie.
learnU nary(EvArg X, V alue V, Stack S)
V als[S][X] = V als[S][X] ∪ {V }.
La funzione learnU nary viene chiamata per ogni evento su ogni traccia. Se dopo
aver processato tutte le tracce di training si ha che |V als[S][X]| = 1 l’informazione
appresa è che X, in un determinato stack S, è sempre uguale a V . Se invece
|V als[S][X]| > 1 si è imparato che X, in un determinato stack S, può assumere solo
i valori contenuti nell’insieme V als[S][X]. Nel caso del programma di Figura 3.1 si
avrebbe, denotando con buf@5 il parametro della write:
• V als[main.23|g.10|put msg.5][buf@5] = {“Hello, ”}
• V als[main.24|put msg.5][buf@5] = {“world\n”}
Se non si tenesse conto dello stack l’unica cosa imparata sarebbe
• V als[buf@5] = {“Hello, ”, “world\n”}
che è, come già detto, insufficiente per rilevare l’attacco.
3.3.2
Algoritmo di apprendimento delle relazioni binarie
L’apprendimento delle relazioni binarie avviene in due fasi: la prima non è molto
dissimile da quella originale mentre la seconda, del tutto nuova, è quella che permette
l’apprendimento delle alternative.
Algoritmo 3: Algoritmo di apprendimento delle relazioni binarie, prima parte.
lookup(V, R)
lookup := {e : (V, val(e)) ∈ R}
learnRelations(EvArg X, V alue V )
Y = lookup(V );
CurRels[R][X][V ] = CurRels[R][X][V ] ∩ Y;
Yn = Y ∩ N ewArgs(X, V );
CurRels[R][X][V ] = CurRels[R][X][V ] ∪ Yn
update(X, V )
Notiamo che ora la tabella CurRels ha un nuovo indice: il valore del parametro
preso in considerazione. Questo ci consente di costruire relazioni differenti per differenti valori della variabile. Nulla infatti garantisce che le relazioni siano invarianti
rispetto ai valori dei parametri delle chiamate di sistema.
3.3. Costruzione del modello
39
Un altro cambiamento riguarda la funzione N ewArgs(X, V ): essa, in questa
nuova variante, restituisce gli eventi che compaiono per la prima volta dopo la precedente occorrenza di X e tali che, se il loro valore era V 0 , vale che (V, V 0 ) ∈ R. Di
seguito vengono mostrati due esempi di esecuzione dell’algoritmo.
NOTA: Per brevità, nel seguito viene omesso [R], dando anche per sottinteso che
R è la relazione di uguaglianza. Inoltre CurRels è abbreviato da CR.
40
3. Un modello che integra control flow e data flow
Traccia: (fd@4 = 1), (fd@10 = 1), (fd@11 = 1), (fd@6 = 2), (fd@10 = 2), (fd@11 = 2)
X =fd@4, V = 1
Y = lookup(1) = ∅
CR[fd@4][1] = CR[fd@4][1] ∩ Y = ∅ ∩ ∅ = ∅
Yn = Y ∩ N ewArgs(fd@4, 1) = ∅ ∩ ∅ = ∅
CR[fd@4][1] = CR[fd@4][1] ∪ Yn = ∅ ∪ ∅ = ∅
update(fd@4, 1)
X =fd@10, V = 1
Y = lookup(1) = {fd@4}
CR[fd@10][1] = CR[fd@10][1] ∩ Y = ∅ ∩ {fd@4} = ∅
Yn = Y ∩ N ewArgs(fd@10, 1) = {fd@4} ∩ {fd@4} = {fd@4}
CR[fd@10][1] = CR[fd@10][1] ∪ Yn = ∅ ∪ {fd@4} = {fd@4}
update(fd@10, 1)
X =fd@11, V = 1
Y = lookup(1) = {fd@10, fd@4}
CR[fd@11][1] = CR[fd@11][1] ∩ Y = ∅ ∩ {fd@10, fd@4} = ∅
Yn = Y ∩ N ewArgs(fd@11, 1) = {fd@10, fd@4} ∩ {fd@10, fd@4} = {fd@10, fd@4}
CR[fd@11][1] = CR[fd@11][1] ∪ Yn = ∅ ∪ {fd@10, fd@4} = {fd@10, fd@4}
update(fd@11, 1)
X =fd@6, V = 2
Y = lookup(2) = ∅
CR[fd@6][2] = CR[fd@6][2] ∩ Y = ∅ ∩ ∅ = ∅
Yn = Y ∩ N ewArgs(fd@6, 2) = ∅ ∩ {fd@10, fd@11, fd@4} = ∅
CR[fd@6][2] = CR[fd@6][2] ∪ Yn = ∅ ∪ ∅ = ∅
update(fd@6, 2)
X =fd@10, V = 2
Y = lookup(2) = {fd@6}
CR[fd@10][2] = CR[fd@10][2] ∩ Y = ∅ ∩ {fd@6} = ∅
Yn = Y ∩ N ewArgs(fd@10, 2) = {fd@6} ∩ {fd@10, fd@11, fd@4, fd@6} = {fd@6}
CR[fd@10][2] = CR[fd@10][2] ∪ Yn = ∅ ∪ {fd@6} = {fd@6}
update(fd@10, 2)
X =fd@11, V = 2
Y = lookup(2) = {fd@10, fd@6}
CR[fd@11][2] = CR[fd@11][2] ∩ Y = ∅ ∩ {fd@10, fd@6} = ∅
Yn = Y ∩ N ewArgs(fd@11, 2) = {fd@10, fd@6} ∩ {fd@10, fd@11, fd@4, fd@6} = {fd@10, fd@6}
CR[fd@11][2] = CR[fd@11][2] ∪ Yn = ∅ ∪ {fd@10, fd@6} = {fd@10, fd@6}
update(fd@11, 2)
Figura 3.11: Esecuzione dell’algoritmo su una traccia.
3.3. Costruzione del modello
41
Traccia: (fd@4 = 1), (fd@10 = 1), (fd@11 = 1), (fd@6 = 2), (fd@10 = 2), (fd@11 = 1)
X =fd@4, V = 1
Y = lookup(1) = ∅
CR[fd@4][1] = CR[fd@4][1] ∩ Y = ∅ ∩ ∅ = ∅
Yn = Y ∩ N ewArgs(fd@4, 1) = ∅ ∩ ∅ = ∅
CR[fd@4][1] = CR[fd@4][1] ∪ Yn = ∅ ∪ ∅ = ∅
update(fd@4, 1)
X =fd@10, V = 1
Y = lookup(1) = {fd@4}
CR[fd@10][1] = CR[fd@10][1] ∩ Y = ∅ ∩ {fd@4} = ∅
Yn = Y ∩ N ewArgs(fd@10, 1) = {fd@4} ∩ {fd@4} = {fd@4}
CR[fd@10][1] = CR[fd@10][1] ∪ Yn = ∅ ∪ {fd@4} = {fd@4}
update(fd@10, 1)
X =fd@11, V = 1
Y = lookup(1) = {fd@10, fd@4}
CR[fd@11][1] = CR[fd@11][1] ∩ Y = ∅ ∩ {fd@10, fd@4} = ∅
Yn = Y ∩ N ewArgs(fd@11, 1) = {fd@10, fd@4} ∩ {fd@10, fd@4} = {fd@10, fd@4}
CR[fd@11][1] = CR[fd@11][1] ∪ Yn = ∅ ∪ {fd@10, fd@4} = {fd@10, fd@4}
update(fd@11, 1)
X =fd@6, V = 2
Y = lookup(2) = ∅
CR[fd@6][2] = CR[fd@6][2] ∩ Y = ∅ ∩ ∅ = ∅
Yn = Y ∩ N ewArgs(fd@6, 2) = ∅ ∩ {fd@10, fd@11, fd@4} = ∅
CR[fd@6][2] = CR[fd@6][2] ∪ Yn = ∅ ∪ ∅ = ∅
update(fd@6, 2)
X =fd@10, V = 2
Y = lookup(2) = {fd@6}
CR[fd@10][2] = CR[fd@10][2] ∩ Y = ∅ ∩ {fd@6} = ∅
Yn = Y ∩ N ewArgs(fd@10, 2) = {fd@6} ∩ {fd@10, fd@11, fd@4, fd@6} = {fd@6}
CR[fd@10][2] = CR[fd@10][2] ∪ Yn = ∅ ∪ {fd@6} = {fd@6}
update(fd@10, 2)
X =fd@11, V = 1
Y = lookup(1) = {fd@11, fd@4}
CR[fd@11][1] = CR[fd@11][1] ∩ Y = {fd@10, fd@4} ∩ {fd@11, fd@4} = {fd@4}
Yn = Y ∩ N ewArgs(fd@11, 1) = {fd@11, fd@4} ∩ {fd@10, fd@6} = ∅
CR[fd@11][1] = CR[fd@11][1] ∪ Yn = {fd@4} ∪ ∅ = {fd@4}
update(fd@11, 1)
Figura 3.12: Esecuzione dell’algoritmo su una traccia.
42
3. Un modello che integra control flow e data flow
Il risultato dell’algoritmo sulla traccia dell’esempio di Figura 3.11 è il seguente:
CR[fd@4][1] = ∅
CR[fd@10][1] = {fd@4}
CR[fd@11][1] = {fd@4, fd@10}
CR[fd@6][2] = ∅
CR[fd@10][2] = {fd@6}
CR[fd@11][2] = {fd@6, fd@10}
Ora raccogliamo in un unico insieme tutti gli insiemi identificati per i vari valori
osservati di una data variabile:
CR[fd@4] = {∅}
CR[fd@6] = {∅}
CR[fd@10] = {{fd@4}, {fd@6}}
CR[fd@11] = {{fd@4, fd@10}, {fd@6, fd@10}}
Relativamente a fd@4 e fd@6 il risultato è identico all’algoritmo di base. D’altronde
non esistono osservazioni precedenti e dunque non devono essere in relazione con
nessun altro argomento. Potrebbe comunque essere che informazioni relative a fd@4
e fd@6 vengano catturate da relazioni unarie. Quello che cambia è ciò che riguarda
fd@10 e fd@11: gli insiemi a loro associati contengono tutte le possibili relazioni
osservate, dalle quali si può dedurre:
• fd@10 = fd@4 OR fd@10 = fd@6
• fd@11 = fd@10 AND (fd@11 = fd@4 OR fd@11 = fd@6)
Facciamo lo stesso per l’esempio di Figura 3.12:
CR[fd@4][1] = ∅
CR[fd@10][1] = {fd@4}
CR[fd@11][1] = {fd@4}
CR[fd@6][2] = ∅
CR[fd@10][2] = {fd@6}
Raccogliamo:
CR[fd@4] = {∅}
CR[fd@6] = {∅}
CR[fd@10] = {{fd@4}, {fd@6}}
CR[fd@11] = {{fd@4}}
Da cui si ottiene
• fd@10 = fd@4 OR fd@10 = fd@6
• fd@11 = fd@4
3.3. Costruzione del modello
43
Particolare attenzione va posta se uno degli insiemi contiene l’insieme vuoto: esso
non va semplicemente scartato, al contrario esso causa la completa perdita di informazione per una data variabile. Supponiamo si siano osservate le variabili X, X 0
entrambe con valore V e si sia osservata, in un momento differente X con valore
V 0 : varrà su tutta la traccia che se il valore è V allora X equals X 0 mentre, se il
valore è V 0 , non si hanno relazioni con variabili viste in passato. Nel momento in cui
queste due informazioni vengono collassate assieme non si può far altro che dire che
globalmente non esiste nessuna proprietà che valga indipendentemente dal valore.
Questo è molto importante ed è indicativo del fatto che il processo di apprendimento
non è monotono: quello che fa l’algoritmo è cercare di costruire una teoria basandosi
su delle osservazioni, nel momento in cui arriva un’osservazione che contraddice la
teoria, quest’ultima decade.
Definizione 7 Siano:
• se, per una data variabile X si sono osservati i valori v1 , . . . , vn si avrà che
CurRels[R][X][v1 ] = P1 , . . . , CurRels[R][X][vn ] = Pn saranno definiti. Definiamo CurRels[R][X] = {P1 , . . . , Pn }
• V = {v1 , . . . , vn } l’insieme di tutti i valori osservati. Definiamo l’insieme
T
all[R][X] = v∈V (CurRels[R][X][v])
• or[R][X] = {P \ all[R][X] : P ∈ CurRels[R][X]}
Se all[R][X] = {a1 , . . . , ak } allora per ogni traccia vale (X R a1 ) ∧ . . . ∧ (X R ak ).
Inoltre vale anche che se or[R][X] = {A1 , . . . , Ah } e A1 6= ∅, . . . , Ah 6= ∅ allora
!
_
^
A∈or[R][X]
a∈A
(X R a)
(3.1)
Se ∅ ∈ or[R][X] si è nel caso in cui si sta scartando dell’informazione che non
è universalmente valida. Se anche all[R][X] = ∅, tutte le informazioni raccolte
relativamente ad X erano casi particolari non validi su tutte le tracce osservate.
Quest’ultima definizione è quella che caratterizza la seconda fase dell’algoritmo
di apprendimento, da eseguirsi una volta che sono state processate tutte le tracce.
L’algoritmo completo diventa quindi il seguente:
44
3. Un modello che integra control flow e data flow
Algoritmo 4: Algoritmo data flow completo.
for T ∈ T do
for e ∈ T do
learnU nary(arg(e), value(e), stack(e));
learnBinary(arg(e), value(e));
end
end
for R ∈ R do
for X ∈ X do
apply 3.1 to CurRels[R][X]
end
end
L’algoritmo genera una formula logica per ogni argomento X osservato e per ogni
relazione R che si vuole apprendere. Tali formule dovranno essere tutte e sempre
verificate a runtime, in caso contrario si ha un’anomalia che potrebbe corrispondere
ad un’intrusione.
3.3.3
Descrizione dell’algoritmo
Siano dati una traccia T , una relazione R e un valore V . Allora l’algoritmo apprende
quali sono le coppie di argomenti {(xi , xj ) : xi , xj ∈ T ∧ (xi , xj ) ∈ R}
Se la traccia è composta da un singolo evento (x = v), allora banalmente il
risultato è CurRels[R][x][v] = ∅
Supponiamo ora che la traccia sia composta da più eventi {(x1 = v1 ), . . . , (xk−1 =
vk−1 )}. All’arrivo dell’evento (xk , vk ) il valore vk viene cercato nella tabella di lookup
e viene restituito l’insieme Y di tutti gli argomenti xi finora visti che hanno valore
in relazione con vk . Allora si possono manifestare differenti casi:
• CurRels[R][X][V ] = ∅ e Y = ∅: In questo caso non è stata osservata nessuna relazione per l’argomento X quando ha valore V che sia valida su tutta
la traccia finora processata. Inoltre non ci sono visti argomenti che hanno
avuto come ultimo valore il valore V . Dunque l’argomento in analisi non ha
nessuna relazione con argomenti passati e in questo caso l’algoritmo apprende
correttamente CurRels[R][X][V ] = ∅
• CurRels[R][X][V ] = ∅ e Y 6= ∅: Per quanto riguarda la prima parte vale il
discorso fatto al punto precedente. Per quanto riguarda la seconda parte, il
fatto che Y sia non vuoto significa che in passato sono stati osservati parametri
il cui argomento aveva valore in relazione con il valore dell’argomento corrente.
3.3. Costruzione del modello
45
Tuttavia questo non è sufficiente a dire che X è in relazione con i parametri
in Y e quindi CurRels[R][X][V ] rimane vuoto. Prima di poter aggiungere
qualcosa a CurRels[R][X][V ] è necessario escludere da Y i parametri che sono
apparsi prima della precedente occorrenza di X con valore in relazione con
V . Supponiamo di non farlo e di avere le occorrenze x1 , x2 , x3 dello stesso
parametro: in questo caso verrebbero apprese delle relazioni che potrebbero
valere per x1 e x3 ma non per x2 (vedi esempio). Se invece si prendono solo
i parametri “nuovi”, cioè quelli in Yn si può essere certi che un’eventuale
relazione appresa, almeno fino a quel punto della traccia, è valida. Quindi il
passo CurRels[R][X][V ] ∪ Yn è lecito
• CurRels[R][X][V ] 6= ∅ e Y = ∅: Se Y = ∅ significa che il parametro che si sta
analizzando non è più in relazione con nessun parametro visto in passato. In
altre parole, i parametri con cui X era in relazione hanno cambiato valore, per
cui non è più possibile stabilire una relazione che valga globalmente sull’intera
traccia. Quindi, tutte le relazioni finora imparate relativamente ad X vanno
scartate e dunque CurRels[R][X][V ] = ∅
• CurRels[R][X][V ] 6= ∅ e Y 6= ∅: Questo caso è semplicemente l’unione dei due
precedenti: vengono conservate soltanto le relazioni che continuano a valere
globalmente sulla traccia vista finora e vengono scartate tutte le altre
I quattro casi appena visti riguardano la prima parte, prendiamo ora in considerazione la seconda parte, ovvero quella delineata nella formula 3.1.
Sia dato CurRels[R][X][V ] = {X1 , . . . , Xn }. Questo risultato viene generato
dall’algoritmo soltanto se su tutte le tracce osservate valgono X R X1 , . . . , X R Xn ,
ovvero
^
X R Xi
(3.2)
i∈{1,...,n}
che è il vincolo a cui deve sottostare X quando ha valore V . Si supponga ora di
aver osservato più valori per X, quindi {v1 , . . . , vk }. Si avrà che per ogni 1 ≤ j ≤ n
esiste un insieme Pj tale che CurRels[R][X][vj ] = Pj e una corrispondente formula
Fj del tipo della 3.2. I vari valori osservati sono possibili alternative, quindi varrà
banalmente che
_
j∈{1,...,k}
Fj
(3.3)
46
3. Un modello che integra control flow e data flow
Si noti che se uno qualunque dei Pj è un insieme vuoto significa che per quel valore
di X non si è osservata alcuna relazione, che equivale a dire che deve sottostare al
vincolo sempre vero >. La 3.3 diventa quindi banalmente sempre vera e quindi non
si può dire niente su X.
3.4
L’algoritmo completo per la costruzione del modello
Algoritmo 5: Algoritmo per la costruzione del modello.
for T ∈ T do
for e ∈ T do
(Ecall , Ecrs , Ertn ) = egBaseCase(stack(e));
learnU nary(arg(e), value(e), stack(e));
learnBinary(arg(e), value(e));
end
end
egInduction(Ecall , Ecrs , Ertn );
for R ∈ R do
for X ∈ X do
apply 3.1 to CurRels[R][X]
end
end
In questa sezione viene dato l’algoritmo completo per la costruzione del modello.
Esso consiste di due fasi, una “online” e una di post-processing. Durante la fase
“online” le tracce vengono processate evento per evento. Ogni evento provoca l’inserimento di nuovi archi nell’execution graph o l’apprendimento di nuove relazioni.
La fase successiva di post-processing invece si occupa di lanciare la parte induttiva
della costruzione dell’execution graph (Definizione 2) e di costruire le disgiunzioni
che il nuovo algoritmo data flow è in grado di apprendere.
3.4.1
Relazione con l’algoritmo originale rispetto ai falsi positivi
Come già notato in precedenza la questione dei falsi positivi è molto delicata perché
è legata direttamente alla qualità del training svolto. Un training scadente provoca
una elevata quantità di falsi positivi, qualunque sia il modello utilizzato. Nel modello
presentato in questa tesi i falsi positivi del modello possono essere dovuti alla parte
control flow e alla parte data flow. Siccome la parte control flow del modello è
costituita dagli execution graphs senza modifiche, non c’è alcun peggioramento in
falsi positivi da questo punto di vista.
3.5. L’algoritmo per la verifica delle tracce rispetto al modello
47
Dal lato data flow succede che i vincoli a cui deve sottostare il programma con
il nuovo modello sono più stretti, dunque sembrerebbe che questo possa portare ad
un incremento dei falsi positivi. All’atto pratico questo però non succede perché
comunque, anche nel modello proposto, vengono apprese tutte e sole le relazioni che
valgono su tutte le tracce osservate durante il training. È chiaro che se vengono
osservate relazioni che durante il training non erano state apprese, queste saranno segnalate. Purtroppo non c’è stato il tempo di condurre una sperimentazione
estensiva su software reali relativa a questo aspetto.
3.4.2
Una possibile variante
Una possibile variante dell’algoritmo consiste nel tenere conto dello stack anche nell’apprendimento delle relazioni binarie: CurRels per questo avrebbe un ulteriore
indice, da CurRels[R][X][V ] diventerebbe CurRels[R][S][X][V ]. La cosa è equivalente a giustapporre lo stack al nome degli eventi X, quindi differenziandoli rispetto
al percorso che è stato seguito per giungere ad eseguire quella chiamata di sistema.
Con questa variante diventerebbe possibile capire da dove arrivano i dati che entrano nelle procedure del programma, permettendo quindi analisi potenzialmente più
fini. Tuttavia non si è investigata questa possibilità, quindi non è ben chiaro quanto
possa essere utile e dove possa portare. In particolare è necessario capire a fondo se
i benefici giustificano l’ulteriore dispendio di risorse di calcolo.
3.5
L’algoritmo per la verifica delle tracce rispetto al
modello
L’algoritmo per la verifica del modello è tutto sommato molto semplice. Esso deve
controllare che valgano contemporaneamente le proprietà control flow e data flow
apprese durante la fase di training. Come già accennato, il metodo per verificare
le proprietà control flow deriva direttamente dalla Definizione 5: dati due stack
0
0
0
0
s = hr1 , r2 , . . . , rn i e s0 = hr1 , r2 , . . . , rn0 i consecutivi e un k tale che ri = ri per
1 ≤ i ≤ k, deve essere sempre possibile confermare che s0 sia un successore di s, in
caso contrario si deve segnalare la cosa. Affinché s0 sia successore di s è necessario
rtn
call
verificare che valgano → e → , che si riduce a controllare l’effettiva presenza di
alcuni archi negli insiemi appresi, inoltre è necessario controllare anche la presenza
0
dell’arco (rk , rk ).
Per quanto riguarda la parte data flow bisogna verificare sia le relazioni unarie
sia quelle binarie. Per le relazioni unarie la questione è molto semplice: ad ogni
48
3. Un modello che integra control flow e data flow
parametro P durante il training può venir associato un insieme di valori ammissibili
o un range. Durante la verifica non si deve far altro che accertarsi della presenza del
valore attuale di P nell’insieme o nel range appresi. Per quanto riguarda le relazioni
binarie, come si è visto l’algoritmo è in grado di apprendere delle formule logiche per
ognuno dei parametri monitorati. Si tratta quindi di fare il lookup di quella formula
e di verificarne la verità. Questo implica la necessità di dover mantenere una tabella
con tutti gli ultimi valori visti per i vari parametri.
Algoritmo 6: Algoritmo di verifica.
unaryOk(s, {p1 , . . . , pn })
begin
for i ∈ {1, . . . , n} do
if value(pi ) 6∈ V als[s][pi ] then
return false
end
end
return true
end
binaryOk(s, {p1 , . . . , pn })
begin
for i ∈ {1, . . . , n} do
if 3.1 does not hold then
return false
end
end
return true
end
verif y(Stack s, Stack s0 , P arams {p01 , . . . , p0n0 })
begin
if !successor(s, s0 ) then
alert(“Control flow anomaly”)
end
if !unaryOk(s0 , {p01 , . . . , p0n0 }) then
alert(“Data flow anomaly on unary relations”)
end
if !binaryOk(s0 , {p01 , . . . , p0n0 }) then
alert(“Data flow anomaly on binary relations”)
end
end
3.5. L’algoritmo per la verifica delle tracce rispetto al modello
3.5.1
49
Gestione delle anomalie
Il focus di questa tesi è centrato sulla costruzione di un modello tramite l’osservazione
di eventi e, successivamente, sull’uso del modello appreso per monitorare dei processi.
Una volta rilevata un’anomalia però è necessario decidere che azione intraprendere
per gestirla, tenendo presente che le varie anomalie che si possono riscontrare hanno
“pesi” differenti, legati sia alla chiamata di sistema stessa [16], sia ai suoi parametri.
Certamente, per fare un esempio, una open() di un file mai visto durante il training
è meno sospetta di una exec() che ha come parametro /bin/sh e quindi si potrebbe
decidere che nel primo caso si segnala semplicemente la cosa mentre nel secondo si
uccide il processo o si impedisce la system call. Queste però sono decisioni fortemente
dipendenti dal contesto in cui si colloca il sistema di cui si vuole monitorare i processi
per cui, in un sistema reale, è verosimile pensare alla presenza di un modulo tramite
il quale sia possibile inserire questo tipo di regole.
50
3. Un modello che integra control flow e data flow
4
L’implementazione
L’implementazione del prototipo dell’IDS basato sul nuovo modello proposto è stata
fatta sfruttando DTrace, il framework di tracing dinamico di Solaris. Creato inizialmente da Sun Microsystems per il suo sistema operativo, in seguito è stato portato
su altri sistemi Unix, principalmente FreeBSD e Mac OS X. Sembra essere in corso
un port anche verso Linux anche se su questa piattaforma c’è la concorrenza di un
sistema analogo denominato SystemTap.
DTrace permette il monitoring di migliaia di parametri e di operazioni del sistema operativo, comprese le system calls, senza la necessità di scrivere programmi
che girano in kernel space e che potrebbero quindi compromettere la stabilità e la
sicurezza del sistema. L’overhead imposto inoltre è più che accettabile, il che disincentiva ulteriormente a passare ad un’implementazione in kernel space, almeno per
quanto riguarda i prototipi. Interfacciandosi low-level a DTrace è possibile ottenere
tutte le informazioni necessarie alla costruzione del modello.
I sistemi operativi che implementano DTrace infatti hanno inserite all’interno
del kernel un gran numero di sonde (probes nel linguaggio di DTrace) e, tramite uno
script apposito, è possibile attivarle e collezionare i loro dati, o nel formato messo
a disposizione dal framework o, intervenendo a basso livello, in formato grezzo.
Le sonde che ci interessano ai fini di questa tesi sono quelle denominate entry e
return messe a disposizione dal provider denominato syscall. Le sonde citate,
come facilmente intuibile, si attivano appena si entra in una chiamata di sistema nel
caso di entry e alla sua uscita (subito prima del ritorno in user space) nel caso di
return. I dati da esse riportati sono cruciali per la costruzione del modello.
4.1
Struttura generale del sistema
Il sistema è composto da un programma di training e da un programma di anomaly
detection. Il primo è responsabile della costruzione del modello, il secondo della
52
4. L’implementazione
Alert
Learning
Modello
libdtrace(3LIB)
libdtrace(3LIB)
Processo in
osservazione
int 0x80
DTrace
Anomaly
Detection
Engine
User space
Kernel space
DTrace entry
probe
Processo in
osservazione
int 0x80
DTrace
User space
Kernel space
DTrace entry
probe
Syscall kernel code
DTrace return
probe
Training offline in
ambiente sicuro
Syscall kernel code
DTrace return
probe
Monitoring online
Figura 4.1: Struttura generale del sistema.
sua verifica. Entrambi i programmi si appoggiano a libdtrace(3LIB) per ottenere
i dati di cui necessitano. Di seguito verranno descritti alcuni dettagli di DTrace e
successivamente verrà mostrato come sia possibile interfacciarvisi tramite la libreria.
4.2
Introduzione a DTrace
Per monitorare un processo è sufficiente scrivere un programma nel linguaggio messo
a disposizione da DTrace, che consente di specificare quali parametri monitorare e
come.
Volendo ottenere lo user space stack di un processo (ad esempio ls) ad ogni
chiamata di sistema è sufficiente lanciare in esecuzione lo script di Figura 4.4.
Naturalmente tracciare cosa succede durante l’esecuzione di un processo impone
#!/usr/sbin/dtrace -s
syscall:::entry
/execname == "ls"/
{
ustack();
}
Figura 4.2: Script di DTrace per monitorare lo stack.
4.2. Introduzione a DTrace
53
un certo overhead. Un banalissimo test con ls mostra che, in questo caso, è del
tutto rispettabile (circa il 6.8%). L’output di DTrace è estremamente informati$ time ls -lR > /dev/null
$ time ls -lR > /dev/null
real
user
sys
real
user
sys
0m2.527s
0m0.326s
0m2.174s
0m2.680s
0m0.329s
0m2.322s
Figura 4.3: Tempi di esecuzione di ls senza e con tracing.
vo ma è poco “machine readable”. Ai fini dell’implementazione dell’IDS questo è
un problema: si vorrebbe evitare di aggiungere inutile complessità per riformattare
l’output, inoltre l’uso di tool come sed e awk probabilmente sarebbe inaccettabile
dal punto di vista computazionale, soprattutto nella fase di detection online. Per
questo motivo si è scritto un consumer (nella terminologia di DTrace è il programma
che si interfaccia low-level alla libreria) ad-hoc per prelevare direttamente i dati raw
necessari all’IDS.
4.2.1
Interfacciamento a DTrace tramite libdtrace(3LIB)
Una delle principali difficoltà con DTrace è quella dell’interfacciamento a basso livello. Le API sono private e la documentazione è scarsa, inoltre non sono stabili
e dunque potrebbero cambiare in una release futura del sistema operativo. Tutte
le informazioni in merito sono state ottenute da corrispondenza con i membri della
mailing list [email protected], in particolare da Chad Mynhier.
Per poter ottenere delle informazioni da DTrace in un custom consumer è necessario di nuovo impiegare uno script, questa volta leggermente differente da quello di
Figura 4.4. Il nuovo script deve inserire i dati di interesse all’interno di un aggregato:
#!/usr/sbin/dtrace -s
syscall:::entry
/execname == "ls"/
{
@[probefunc,ustack()];
}
Figura 4.4: Script di DTrace per monitorare lo stack.
54
4. L’implementazione
L’aggregato poi verrà copiato in un buffer in user space, che potrà essere letto ed
elaborato dai consumer. La sintassi per specificare un aggregato è @[record1 , record2 , . . .]
e, nel nostro caso, è stato inserito il nome della chiamata di sistema (probefunc)
e lo stack (ustack()). Procedendo in questo modo DTrace, ad ogni ingresso nella
chiamata di sistema, costruisce una struttura dati apposita (Figura 4.5) alla quale si
può accedere da un consumer scritto in C. L’intero aggregato è rappresentato da una
struttura denominata dtrace aggdata la quale contiene un array (dtrace aggdesc)
che punta ai vari record. Ogni record ha un particolare tipo di dati, nel nostro caso
probefunc è un char * mentre ustack() è un array di uint64 t.
dtrace_aggdata
dtrace_aggdesc
dtrace_recdesc
dtada_desc
dtagd_rec[1]
dtrd_offset
dtada_data
dtagd_rec[2]
dtrace_recdesc
dtada_percpu[0]
dtrd_offset
dtada_percpu[1]
probename
ustack
...
Figura 4.5: Strutture dati usate da un consumer DTrace.
Un consumer ha la funzione di prendere uno script DTrace, compilarlo e passare
l’oggetto risultante al framework DTrace. Questi dettagli sono gestiti con poche
chiamate alla libreria. Fatto questo, si può entrare in un loop e iniziare a consumare
i dati che arrivano dal kernel.
All’interno di un loop infinito si chiama inizialmente la funzione dtrace work(),
la quale si occupa di collezionare tutti i dati necessari. All’uscita da dtrace work()
sono disponibili gli aggregati che sono stati collezionati nell’ultimo ciclo. Questi
vengono processati uno ad uno tramite una funzione walk() definita dall’utente che
lavora sui singoli record. All’interno di walk(), per accedere ai valori probefunc e
4.2. Introduzione a DTrace
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
55
static int
walk(const dtrace_aggdata_t *data, void *arg)
{
dtrace_aggdesc_t *aggdesc = data->dtada_desc;
dtrace_recdesc_t *ustackrec, *datarec, *namerec, *argrec;
uint64_t *ustack, *pc, timestamp;
char *name;
/* [...] */
/* Nome system call */
namerec = &aggdesc->dtagd_rec[1];
name = data->dtada_data + namerec->dtrd_offset;
/* Stack */
ustackrec = &aggdesc->dtagd_rec[2];
if (ustackrec->dtrd_action != DTRACEACT_USTACK)
fatal("aggregation key is not a ustack\n");
ustack = (uint64_t *)(data->dtada_data + ustackrec->dtrd_offset);
/* [...] */
}
Figura 4.6: Snippet di walk().
ustack(), si procederà come in Figura 4.6. A questo punto è possibile processarli
in qualsiasi modo si voglia.
4.2.2
Note riguardo a DTrace
I primi esperimenti con i consumer custom sono stati fatti in ambiente Mac OS X,
fino a quando non è emerso che, per motivi ancora non del tutto chiari, la funzione
ustack() “perdeva per strada” dei frame. Non solo, i frame persi non erano gli
stessi su piattaforma Intel e su piattaforma PowerPC. Inizialmente si è sospettato
che questo accadesse a causa di ottimizzazioni introdotte dal compilatore (del tipo
di -fomit-frame-pointer), ma andando a guardare il codice macchina generato
si è visto che non era questo il caso. Si è dunque spostato lo sviluppo su Solaris
(OpenIndiana 151a), dove nessun stack frame viene perso. Questo comportamento
verrà discusso a breve con gli sviluppatori di DTrace.
56
4.3
4. L’implementazione
Implementazione del sistema
I punti chiave dell’implementazione non sono molti e di seguito verranno descritti.
Uno di essi è lo script per la collezione dei dati, l’altro è l’implementazione della
funzione NewArgs().
4.3.1
Lo script di data collection
In base a quanto specificato nello script verranno raccolti più o meno dati che verranno processati dal modello. I dati raccolti sono in ogni caso lo stack del processo
al momento della system call e il suo nome. Eventualmente vengono raccolti anche
uno o più parametri e il valore di ritorno. Lo script inizia con:
string pname;
BEGIN
{
pname = "gpserver";
}
In questa parte viene inizializzata la variabile pname che consente di specificare
il nome del processo da monitorare. Successivamente vengono inizializzate alcune
variabili che conterranno i valori collezionati.
syscall:::entry
/execname == pname/
{
self->arg0 =
self->arg1 =
self->arg2 =
self->arg3 =
self->arg4 =
self->arg5 =
}
0;
0;
0;
0;
0;
0;
Si noti l’uso di self, necessario a memorizzare il valore localmente rispetto al thread.
Le variabili inizializzate conterranno fino a 5 parametri delle system call. In questa
definizione si può notare anche l’espressione /execname == pname/, che fa attivare
la sonda solo se il nome del processo è quello specificato. Infine prendiamo in esame
la parte che effettivamente raccoglie i dati.
4.3. Implementazione del sistema
57
syscall::accept:entry
/execname == pname/
{
self->arg0 = arg0;
}
syscall::accept:return
/execname == pname/
{
@accept[probefunc, arg0, self->arg0, ustack()] = max(timestamp);
}
L’esempio è relativo alla chiamata di sistema accept(). Nella sonda entry i valori
dei parametri sono disponibili nelle variabili arg0, ..., argN. Nella sonda return
invece in arg0 e in arg1 è disponibile il valore di ritorno. Quando la sonda entry
viene attivata il primo parametro di accept() (un file descriptor restituito precedentemente molto probabilmente da socket()) viene semplicemente salvato nella
variabile self->arg0. Quando viene attivata la sonda return invece viene costruito
l’aggregato con:
• Nome della system call
• Valore di ritorno
• Primo parametro, salvato dalla entry
• Stack corrente del processo
L’aggregato cosı̀ composto viene messo in un buffer in user space a disposizione del
consumer che, nel nostro caso, è l’implementazione del modello proposto.
4.3.2
Implementazione di NewArgs()
Nell’implementazione della fase di training del sistema è particolarmente importante
avere una struttura dati che permetta a NewArgs() di restituire velocemente il suo
risultato. Ricordiamo che data una traccia T formata dagli eventi e1 , . . . , en , possibilmente ripetuti, e dato un evento ek la funzione NewArgs() deve restituire tutti gli
eventi apparsi tra ek e la sua occorrenza immediatamente precedente, esclusi quelli
che appaiono anche prima di essa. Nell’esempio di Figura 4.7 gli eventi registrati
sono:
(A = 1); (B = 1); (C = 1); (B = 2); (C = 2); (A = 3); (D = 1)
58
4. L’implementazione
ed è appena arrivato l’evento B = 2. NewArgs() deve restituire {D}.
A
B
C
D
B=2
A=1
B=1
C=1
B=2
C=2
A=3
D=1
Figura 4.7: Struttura dati di supporto all’implementazione di NewArgs().
Non è pensabile scorrere la lista all’indietro fino alla precedente occorrenza di B
e nel frattempo verificare, elemento per elemento, che esso non compaia prima. Nel
peggiore dei casi se le due occorrenze di B sono distanti k e gli eventi processati sono
stati finora n sarà necessario cercare k volte tra n elementi, per un costo di O(nk).
La soluzione adottata è quindi stata quella di tenere traccia dell’ultima occorrenza di
ogni evento. Inoltre ogni evento è marcato con un numero progressivo (non mostrato
in figura) e punta al precedente. NewArgs(), sull’esempio di Figura 4.7, funziona nel
seguente modo:
• L’ultimo elemento della lista non è la precedente occorrenza di B, inoltre non
ha predecessori, quindi necessariamente appare dopo la precedente occorrenza
di B, indipendentemente dal fatto che tale occorrenza esista o meno. Viene
aggiunto agli elementi che NewArgs() deve restituire.
• A ha un predecessore, il cui numero progressivo è inferiore a quello dell’istanza
precedente di B, quindi viene scartato.
• La stessa cosa succede per C, che viene scartato.
• Si è giunti alla precedenza occorrenza di B, NewArgs() termina restituendo
{D}.
Va notato che avrebbero potuto esserci più occorrenze di A o di C prima di B:
il numero progressivo associato ad ogni evento permette di sapere immediatamente se occorre continuare a risalire all’indietro la catena dei precedenti o meno. La
NewArgs() qui presentata è, per semplicità descrittiva, una possibile implementazione della NewArgs() “originale” (in [2] non viene data alcuna implementazione),
4.4. Costo computazionale del modello
59
ma la modifica per adattarla agli scopi di questa tesi è immediata. Assumendo di
implementare la tabella delle ultime occorrenze con una hash table e assumendo
(in modo sicuramente azzardato) anche di fermarsi dopo il primo passo nel risalire
all’indietro le catene, si ha una complessità di O(k). Tale complessità, dato che il
training avviene offline, è un compromesso del tutto ragionevole tra semplicità e
velocità.
4.4
Costo computazionale del modello
Il modello presenta due tipologie di costi computazionali: il costo relativo alla costruzione e il costo relativo alla verifica. In entrambi i casi i costi sono dovuti
principalmente alle strutture dati utilizzate. Diverse implementazioni potrebbero
usare strutture dati differenti, che ottimizzano certe operazioni a scapito di altre. Ci
si limiterà quindi soltanto ad un’analisi sommaria della questione della complessità.
4.4.1
Costo del learning
Per quanto riguarda gli execution graphs e quindi la parte control flow del modello,
durante il training devono essere inseriti degli archi all’interno di insiemi (Definizione 2, caso base). Gli insiemi usati sono quelli della libreria standard del C++ e
l’operazione di insert() ha complessità logaritmica nella dimensione dell’insieme.
Alternativamente è possibile utilizzare una hash table che consente operazioni molto
più efficienti.
Per quanto riguarda la parte data flow del modello, riguardo a learnU nary si
può dire che essa gira in tempo logaritmico in quanto:
• si deve fare il lookup di V als[S], che costa O(log(|V als|))
• si deve fare il lookup di V als[S][X], che costa O(log(|V als[S]|))
• si devono fare l’unione e l’assegnamento, che collassano nell’operazione insert()
di complessità O(log(|V als[S][X]|))
Complessivamente quindi il costo è logaritmico e la cardinalità maggiore è quella di V als, che contiene tutti gli stack visti. Questo vale nel caso del prototipo,
ovviamente utilizzando una hash table è possibile fare molto meglio.
Per quanto riguarda learnBinary si possono fare considerazioni simili, tenendo
però presente il costo della chiamata a N ewArgs. La complessità della parte di
60
4. L’implementazione
apprendimento delle alternative infine è direttamente legata alla cardinalità degli
insiemi su cui opera.
4.4.2
Costo della verifica
Negli stessi insiemi creati con il training, durante la fase di verifica del modello
(e quindi online) è necessario cercare gli archi relativi agli stack che si osservano
e anche l’operazione find() ha complessità logaritmica. In un prototipo questa
complessità è accettabile ma in un sistema vero è più opportuno usare una hash
table. Supponiamo, in riferimento alla Definizione 5, di dover verificare due stack di
0
altezza n = n e con i primi k elementi a partire dal basso uguali: per verificare se vale
rtn
call
0
→ serve O(n − k), come pure per verificare se vale → . Verificare se (rk , rk ) ∈ Ecrs
0
ha complessità O(1) e verificare se ri = ri per 1 ≤ i < k richiede O(k). Per cui
la verifica costa all’incirca O(n) per chiamata di sistema, costo valido se si usa una
hash table.
Per quanto riguarda la parte unaria del data flow del modello è necessario cercare se un valore è presente in un insieme o se è compreso in un certo range. Nel
primo caso l’operazione ha costo logaritmico nella dimensione dell’insieme, mentre
nel secondo caso ha costo costante. La parte binaria invece presenta il problema
di dover cercare le formule apprese durante il training e verificare che valgano. In
base a quanto ampie sono le relazioni di un dato parametro le formule avranno
dimensioni più o meno grandi, quindi il costo della valutazione è legato alla loro
dimensione. Naturalmente è possibile valutarle con strategie furbe (short circuit) e
questo migliora i tempi di esecuzione.
4.4.3
Alcuni dati sperimentali
Di seguito vengono riportate alcune misure effettuate su software reali relative alle
dimensioni medie degli stack. Queste misure permettono di avere un’idea dei dati
che devono essere raccolti ad ogni chiamata di sistema. Supponendo di lavorare
su una macchina a 64 bit e di avere un’altezza media di n, ad ogni system call si
raccoglieranno 8n byte. Questo valore non considera i dati necessari alla parte data
flow del modello. Per costruire la parte control flow infatti è necessario registrare
ogni chiamata di sistema, mentre per la costruzione del modello dataflow questo
non è necessario, anzi, potrebbe essere controproducente: in base al programma da
monitorare infatti certe system call hanno poco a che fare con la sua sicurezza e
dunque è superfluo monitorarle [16].
4.4. Costo computazionale del modello
61
Server web Apache (httpd)
Altezza stack
7
10
11
14
15
16
17
18
19
Occorrenze
2 (0.12%)
10 (0.6%)
8 (0.48%)
4 (0.24%)
1 (0.06%)
3 (0.18%)
16 (0.96%)
8 (0.48%)
10 (0.6%)
Altezza stack
20
22
23
24
25
26
28
30
31
Occorrenze
18 (1.1%)
1348 (81%)
132 (8%)
1 (0.06%)
5 (0.3%)
2 (0.12%)
18 (1.1%)
1 (0.06%)
73 (4.4%)
I dati sono stati ottenuti monitorando per una decina di minuti un server web
Apache con lieve carico. Sono stati osservati 1660 differenti stack, con altezza media
di 22 elementi.
MySQL (mysqld)
Altezza stack
3
5
6
7
8
9
10
11
12
13
Occorrenze
1 (0.32%)
10 (3.2%)
9 (2.9%)
10 (3.2%)
14 (4.5%)
6 (1.9%)
4 (1.3%)
6 (1.9%)
3 (0.96%)
3 (0.96%)
Altezza stack
14
15
16
17
18
19
20
22
31
Occorrenze
6 (1.9%)
6 (1.9%)
8 (2.6%)
27 (8.7%)
24 (7.7%)
21 (6.8%)
25 (8%)
120 (39%)
8 (2.6%)
In questo caso i dati si riferiscono al server MySQL a cui si appoggia il web server
dei dati precedenti. In questo caso il processo è stato monitorato per 10 minuti, nei
quali si sono osservati 311 differenti stack con altezza media di 18 elementi.
62
4. L’implementazione
Syslog server (syslogd)
Altezza stack
4
5
6
7
8
9
10
Occorrenze
1 (1.5%)
1 (1.5%)
3 (4.5%)
3 (4.5%)
6 (9%)
3 (4.5%)
4 (6%)
Altezza stack
11
12
13
14
15
16
Occorrenze
8 (12%)
14 (21%)
9 (13%)
11 (16%)
3 (4.5%)
1 (1.5%)
Il processo syslogd, per via della sua “tranquillità” legata alla bassa attività del
sistema di test è stato monitorato per circa un’ora. Si sono osservati 67 differenti
stack, con un’altezza media di 11 elementi.
5
Conclusioni e sviluppi futuri
La sempre maggiore pervasività dei sistemi di calcolo, siano essi computer veri e
propri o dispositivi embedded, impone una sempre maggiore necessità di garantire
la loro sicurezza. Esistono diversi meccanismi e diverse tecniche per raggiungere
questo scopo anche se in ogni caso dare un livello di sicurezza totale non è possibile.
Quello che però è possibile è cercare di creare sistemi di difesa sempre più efficaci e
ad ampio spettro.
I sistemi che implementano le difese si possono classificare in due categorie, quelli
che si occupano di misuse detection, ovvero tramite pattern matching verificano la
presenza di signature di un attacco e quelli che fanno anomaly detection, ovvero che
cercano di capire se sono in atto comportamenti che si discostano dalla norma.
Il monitoraggio delle chiamate di sistema va sotto la classe dell’anomaly detection: se un processo deve aprire un file e spedirlo via rete, nel momento in cui questo
processo fa una chiamata ad execve() il comportamento esce dalla norma ed è sospetto perché ha poco senso che per svolgere suo il compito sia necessario lanciare
un altro processo. L’osservazione delle system call come tecnica di rilevamento delle
intrusioni è nota fin dagli anni ’90 e i modelli che sono stati costruiti sono i più
svariati. In questa tesi sono stati considerati vari modelli esistenti in letteratura che
solo osservando le system call e i loro parametri consentono di apprendere informazioni relative al control flow e al data flow del programma. Dopo aver discusso le
loro caratteristiche sono state messe in luce alcune debolezze, che hanno portato allo
sviluppo di un nuovo modello che tratta in modo integrato control flow e data flow.
Il nuovo modello risolve le debolezze dei precedenti apprendendo le informazioni in
modo più fine.
64
5.1
5.1.1
5. Conclusioni e sviluppi futuri
Riepilogo del lavoro svolto
Il modello
Lo studio dei modelli esistenti ha messo in luce vari casi in cui essi sono ciechi
a certi tipi di attacchi e partendo da tre scenari e da una semplice osservazione
sono venute alla luce le possibili migliorie. L’osservazione è che l’apprendimento del
control flow richiede l’acquisizione di informazioni relative allo stack al momento
della chiamata di sistema, informazioni di cui però può beneficiare anche il modello
data flow. Il modo in cui questo può beneficiarne appare dai primi due scenari
presentati, che riguardano le relazioni unarie: si è visto che l’uso dello stack permette
di classificare i parametri delle system call in base al percorso che il control flow ha
seguito per arrivare a fare una certa chiamata. Nel caso delle relazioni binarie invece
l’utilità dell’uso dello stack rimane poco chiara e deve essere ancora investigata ma
si è comunque proposta una miglioria, la cui motivazione appare nel terzo scenario
analizzato. L’osservazione in questo caso è stata che non è assolutamente garantito
che le relazioni che coinvolgono i parametri delle chiamate di sistema siano invarianti
rispetto al loro valore e dunque si è proposto un nuovo schema di apprendimento
che tiene conto di questo fatto.
5.1.2
L’implementazione
Nel quarto capitolo sono stati presentati alcuni dei dettagli dell’implementazione,
in particolare è stato mostrato come DTrace abbia permesso di non scrivere nemmeno una riga di codice in kernel space. Certamente in un’implementazione reale
questo è inevitabile ma per un proof-of-concept l’overhead imposto da DTrace è di
tutto rispetto. Inizialmente si è quindi vista la struttura di DTrace per poi passare
all’interfacciamento al framework tramite l’apposita libreria. Successivamente si è
visto come la data collection ruoti interamente attorno ad uno script nel linguaggio
di DTrace. Infine, dopo aver presentato una possibile struttura dati per l’esecuzione
efficiente di un’operazione necessaria al modello, è stata discussa la complessità in
modo del tutto informale, essendo questa dettata prevalentemente dalle strutture
dati utilizzate.
5.2
Sviluppi futuri
In questa sezione verranno presentate alcune idee che si vorrebbe investigare ed
eventualmente integrare nel modello.
5.2. Sviluppi futuri
5.2.1
65
L’uso dello stack nell’apprendimento delle relazioni binarie
Nel capitolo 3 è già stata accennata la possibilità di considerare lo stack anche
nell’apprendimento di relazioni binarie. Questo è equivalente a giustapporre lo stack
al nome del parametro preso in considerazione, differenziandolo quindi rispetto al
percorso che è stato seguito per eseguire una data chiamata di sistema. L’idea di
usare lo stack in questo modo potrebbe permettere di apprendere una sorta di “data
flow procedurale”: invece di guardare finemente il data flow tra una chiamata e
l’altra in questo modo potrebbe diventare fattibile di apprendere delle informazioni
più high-level rispetto al flusso che seguono i dati. Tuttavia non è ancora ben
chiaro quanto utile possa essere l’aggiunta di questo tipo di informazioni e la prima
evoluzione che verrà investigata sarà proprio questa.
5.2.2
Una dimensione statistica per il modello
Control flow e data flow sono due dimensioni in qualche modo ortogonali e la loro
osservazione combinata consente di raggiungere un buon livello di dettaglio nella
comprensione del comportamento di un programma, che si traduce nella capacità di
impedire un gran numero di attacchi. Tuttavia sembra perfettamente possibile immaginare dei casi in cui pur rimanendo esattamente all’interno del comportamento
prescritto dal modello diventa possibile un attacco denial of service. Immaginiamo
un ciclo al cui interno viene eseguita una malloc() (ad esempio si sta allocando
memoria per una matrice): ad ogni chiamata si osserverà lo stesso stack, quindi lo
stack s sarà successore di se stesso. Se in qualche modo si riesce a modificare l’esecuzione del ciclo è possibile eseguire un numero indefinito di malloc(), saturando
la memoria. In questo caso si potrebbe pensare di far entrare in gioco un’ulteriore
dimensione ortogonale alle precedenti due, cioè quella statistica. L’idea, che sarà
mostrata su un FSA, è la seguente: durante il training si osserva le frequenze delle
transizioni da uno stato all’altro, dando un peso agli archi. Finito il training e passati alla verifica online si controlla che il processo monitorato rispetti le frequenze delle
transizioni. Nel caso non le rispetti si è di fronte ad un comportamento anomalo che
va segnalato.
In Figura 5.1 si può vedere un esempio estremamente semplificato dell’idea. Supponiamo che in un ciclo una venga eseguita una malloc() 10 volte e infine venga
eseguita una close(): questo nei run di training verrà osservato e si otterrà che le
due transizioni etichettate malloc e close avranno un peso rispettivamente di circa
66
5. Conclusioni e sviluppi futuri
malloc
p1
open
p2
read
p3
close
p4
(a)
malloc,w3
p1
open,w1
p2
read,w2
p3
close,w4
p4
(b)
Figura 5.1: Automa senza e con peso sugli archi.
w3 = 91% e w4 = 9%. A runtime poi, nel momento in cui si arriva in p4 , si deve
verificare che questo valga.
Quella presentata è soltanto un’idea senza la pretesa di essere corretta, ma sulla
quale si vuole ragionare. È necessario infatti capire come costruire un modello che
sia affidabile e che statisticamente sia valido.
5.2.3
Costruzione statica del modello
L’execution graph, come già visto, viene costruito dinamicamente osservando le
esecuzioni di un programma. Si vorrebbe provare a far si che sia il compilatore
a costruire l’execution graph, magari sfruttando la notevole modularità e semplicità
di interfacciamento di LLVM. L’idea è quella di far in modo che il compilatore,
oltre a produrre l’eseguibile, produca anche un file contenente il modello control
flow. Al momento dell’esecuzione verrebbero caricati entrambi i file in modo che
contestualmente al lancio del programma venga lanciata anche la verifica.
5.2.4
Applicazione del modello a sistemi virtualizzati
Analogamente a quanto fatto in [15] è possibile applicare il modello presentato in
questa tesi a sistemi virtualizzati, permettendone il monitoraggio invisibile. Il problema principale da risolvere in questo caso è quello di riuscire, dal gestore di macchine virtuali, a risalire ai processi che stanno girando nelle istanze virtualizzate dei
5.2. Sviluppi futuri
67
sistemi. In altre parole è necessario, data una macchina virtuale, poter separare
per PID le varie system call che si osservano. Questa tuttavia non dovrebbe essere
un’operazione che presenta particolari difficoltà.
68
5. Conclusioni e sviluppi futuri
Bibliografia
[1] System V Application Binary Interface - Intel386 Architechture Processor
Supplement. 4th edition, 1997.
[2] Sandeep Bhatak, Abhishek Chaturvedi, and R. Sekar.
Dataflow anomaly
detection. 2006.
[3] Dan Boneh, Richard A. DeMillo, and Richard J. Lipton. On the importance of
eliminating errors in cryptographic computations. 1997.
[4] Shuo Chen, Jun Xu, Emre C. Sezer, Prachi Gauriar, and Ravishankar K. Iyer.
Non control-data attacks are realistic threats.
[5] Mihai Christodorescu, Somesh Jha, Sanjit A. Seshia, Dawn Song, and Randal E.
Bryant. Semantics-aware malware detection.
[6] Henry H. Feng, Oleg M. Kolesnikov, Prahlad Fogla, Wenke Lee, and Weibo
Gong. Anomaly detection using call stack information. 2003.
[7] Debin Gao, Michael K. Reiter, and Dawn Song.
Gray-box extraction of
execution graphs for anomaly detection. 2004.
[8] Jin Han, Qiang Yan, Robert H. Deng, and Debin Gao. On detection of erratic
arguments.
[9] Steven A. Hofmeyr, Stephanie Forrest, and Anil Somayaji. Intrusion detection
using sequences of system calls. 1998.
[10] M. Kearns and L. Valiant.
Cryptographic limitations on learning boolean
formulae and finite automata. ACM STOC, 1989.
[11] Johannes Kinder, Florian Zuleger, and Helmut Veith.
An abstract
interpretation-based framework for control flow reconstruction from binaries.
2009.
[12] A. Kosoresow and S. Hofmeyr. Intrusion detection via system call traces. IEEE
Software, 1997.
70
5. Bibliografia
[13] Christopher Kruegel, Darren H. Mutz, Fredrik Valeur, and Giovanni Vigna. On
the detection of anomalous system call arguments.
[14] Cullen Linn and Saumya Debray. Obfuscation of executable code to improve
resistance to static disassembly. 2003.
[15] Carlo Maiero and Marino Miculan. Unobservable intrusion detection based on
call traces in paravirtualized systems.
[16] Xu Ming, Chen Chun, and Ying Jing. Anomaly detection based on system call
classification. 2003.
[17] Aleph One. Smashing the stack for fun and profit. Phrack #49, 1996.
[18] L. Pitt and M. Warmuth. The minimum consistency dfa problem cannot be
approximated within any polynomial. ACM STOC, 1989.
[19] Mila Dalla Preda, Mihai Christodorescu, Somesh Jha, and Saumya Debray. A
semantics-based approach to malware detection. 2007.
[20] Mila Dalla Preda, Matias Madou, Koen De Bosschere, and Roberto Giacobazzi.
Opaque predicates detection by abstract interpretation.
[21] R. Sekar, M. Bendre, D. Dhurjati, and P.Bollineni. A fast automaton-based
method for detecting anomalous program behaviors. 2001.
[22] Gaurav Tandon and Philip Chan. Learning rules from system call arguments
and sequences for anomaly detection.
[23] theo detristan, tyll ulenspiegel, yann malcom, and mynheer superbus von underduk. Polymorphic shellcode engine using spectrum analysis. Phrack #61,
2003.
[24] David Wagner and Drew Dean. Intrusion detection via static analysis.
[25] Richard Wartell, Yan Zhou, Kevin W. Hamlen, Murat Kantarcioglu, and
Bhavani Thuraisingham. Differentiating code from data in x86 binaries. 2011.