Contenuti

Risoluzione dei sovraccarichi del buffer

Perché esistono i sovraccarichi del buffer

Vi sono un certo numero di elementi che concorrono a causare un sovraccarico del buffer, fra cui:

  • L’utilizzo di un linguaggio indipendente dai tipi, ad esempio C/C++.
  • L’accesso o la copia di un buffer in maniera non sicura.
  • Il compilatore posiziona i buffer vicino a strutture di dati di importanza cruciale nella memoria.

Esaminiamo ora ciascun punto approfondendolo.

I sovraccarichi del buffer sono problemi correlati principalmente a C e C++, in quanto questi linguaggi non eseguono alcun controllo dei limiti della matrice e dell’indipendenza dai tipi. C/C++ consente agli sviluppatori di creare programmi eseguiti molto vicino ai componenti hardware, programmi che permettono l’accesso diretto alla memoria e ai registri del computer. Il risultato sono le prestazioni; è difficile rendere un programma veloce quanto un’applicazione ben scritta in C/C++. I sovraccarichi del buffer esistono anche in altri linguaggi, sebbene siano rari. L’esistenza di un simile errore non è imputabile allo sviluppatore, ma piuttosto all’ambiente runtime.

Inoltre se un’applicazione riprende dati da un utente (o pirata informatico!) e li copia in un buffer gestito dall’applicazione, a prescindere dalla dimensione del buffer di destinazione, può verificarsi un sovraccarico. In altre parole il codice effettua l’allocazione di N-byte ed è sempre il codice a copiare più di N-byte nel buffer allocato. Immaginate di avere a disposizione un recipiente in grado di contenere mezzo litro d’acqua e di versarvene 8 dl. Dove vanno a finire i 3 dl in eccesso? Si rovesciano dappertutto!

Infine, fattore nient’affatto trascurabile, i buffer vengono spesso posizionati dal compilatore accanto a strutture di dati “interessanti”. Ad esempio, nel caso di una funzione che dispone di un buffer sullo stack, l’indirizzo di risposta della funzione viene inserito nella memoria dopo il buffer. Pertanto, se un pirata informatico riuscisse a sovraccaricare il buffer, potrebbe sovrascrivere l’indirizzo di risposta della funzione e fare in modo che la risposta venga inoltrata a un suo indirizzo. Altre strutture di dati interessanti sono le v-table di C++, gli indirizzi di gestione delle eccezioni, i puntatori delle funzioni, e così via.

Ok, ho blaterato abbastanza, vediamo qualche esempio.

Che cosa c’è di sbagliato nel codice seguente?

1
2
3
4
5
6
7
8
void CopyData(char \*szData) {
   char cDest\[32\];
   strcpy(cDest,szData);


   // usa cDest
   ...
}

In realtà potrebbe essere tutto giusto! Tutto dipende da come viene chiamato CopyData. Il codice seguente, ad esempio, è sicuro:

1
2
char \*szNames\[\] = {"Michele","Cristina","Bruno"};
CopyData(szName\[1\]);

È sicuro perché i nomi sono hard-coded ed è noto che tutte le stringhe non superano i 32 caratteri di lunghezza, quindi la chiamata a strcpy è sempre sicura. Se però il solo argomento per CopyData, szData, proviene da un’origine non affidabile, ad esempio un socket o un file, strcpy continua a copiare i dati finché arriva a un carattere nullo e, se i dati superano i 32 caratteri di lunghezza, il buffer cDest viene sovraccaricato e i dati che precedono il buffer nella memoria vengono sovrascritti. Purtroppo in questo caso i dati sovrascritti corrispondono all’indirizzo di risposta da CopyData il che significa che quando CopyData termina, l’esecuzione continua in una posizione che può essere dettata da un pirata informatico. Male!

Anche altre strutture di dati sono sensibili. Immaginate che la v-table di una classe C++ sia danneggiata, come nel codice che segue:

1
2
3
4
5
6
7
8
void CopyData(char \*szData) {
   char cDest\[32\];
   CFoo foo;
   strcpy(cDest,szData);


   foo.Init();
}

In questo esempio si presuppone che la classe CFoo disponga di metodi virtuali, oltre che di una v-table o di un elenco di indirizzi per i metodi della classe comuni a tutte le classi C++. Se la v-table è danneggiata dalla sovrascrittura del buffer cDest tutti i metodi virtuali della classe, Init() nell’esempio, potrebbero chiamare un indirizzo imposto dal pirata informatico anziché Init(). A proposito, se pensate che il vostro codice sia sicuro se non vengono chiamati metodi C++, vi sbagliate: c’è un metodo che viene sempre chiamato: il distruttore virtuale della classe! Ovviamente se impiegate una classe che non effettua chiamate ai metodi dovreste anche chiedervi perché esiste.

Risoluzione dei sovraccarichi del buffer

Passiamo ora a qualcosa di più confortante, ossia come rimuovere ed evitare i sovraccarichi del buffer nel codice.

Passare al codice gestito

Qualche anno fa, si è tenuta una grande conferenza sulla protezione in Microsoft Windows® (Security Push). Nel corso di questo evento, si è tenuto un corso di aggiornamento sui requisiti della progettazione, scrittura, test e documentazione delle funzioni di protezione a cui hanno partecipato più di 8.500 persone. Un suggerimento esteso a tutti gli sviluppatori è stato di prevedere il passaggio di determinati strumenti e applicazioni dal codice C++ Win32® al codice gestito. E’ stato dato questo consiglio per diverse ragioni, ma la principale era quella di ridurre i sovraccarichi del buffer. Se si impiega il codice gestito è molto più difficile incorrere in un sovraccarico del buffer, perché il codice che si scrive non ha accesso diretto ai puntatori, ai registri del computer o alla memoria. È importante pertanto prendere in considerazione, o almeno prevedere, di effettuare la migrazione di determinate applicazioni e strumenti al codice gestito. Gli strumenti di gestione, ad esempio, sono dei perfetti candidati per la migrazione. Ovviamente i vostri piani d’azione devono essere realistici: non è possibile effettuare la migrazione di tutte le applicazioni da C++ a C#, o altro linguaggio gestito, in una sera.

Seguire le regole d’oro

Quando viene scritto codice in C e C++, si deve sempre prestare attenzione al modo in cui si gestiscono i dati provenienti dagli utenti. Se è presente una funzione che riprende un buffer da un’origine non affidabile, bisogna seguire queste regole:

  • Richiedere che il codice superi la lunghezza del buffer
  • Sondare la memoria
  • Stare sulla difensiva

Approfondiamo i punti sopra esposti uno alla volta.

Richiedere che il codice superi la lunghezza del buffer

È presente un errore se una chiamata a una funzione ha una firma simile alla seguente:

1
2
3
4
5
void Function(char \*szName) {
   char szBuff\[MAX\_NAME\];
   // Copia e usa szName
   strcpy(szBuff,szName);
}

Il problema in questo segmento di codice è che la funzione non dispone di informazioni sulla lunghezza di szName, vale a dire che non è possibile copiare i dati in tutta sicurezza. La funzione dovrebbe reperire la dimensione di szName come segue:

1
2
3
4
5
6
void Function(char \*szName, DWORD cbName) {
   char szBuff\[MAX\_NAME\];
   // Copia e usa szName
   if (cbName < MAX\_NAME)
      strncpy(szBuff,szName,MAX\_NAME-1);
}

Non è tuttavia necessario concedere l’affidabilità a cbName. L’eventuale pirata informatico potrebbe impostare il nome e la dimensione del buffer, per cui dovete stare all’erta!

Sondare la memoria

Come fare per sapere se szName e cbName sono validi? L’utente da cui provengono i valori può essere considerato affidabile? In generale la risposta è no. Un modo semplice per verificare se la dimensione del buffer è valida consiste nel sondare la memoria. Il seguente stralcio di codice illustra come sia possibile effettuare questa operazione in una versione di debug del codice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void Function(char \*szName, DWORD cbName) {
   char szBuff\[MAX\_NAME\];

#ifdef \_DEBUG

   // Controlla la memoria
   memset(szBuff, 0x42, cbName);
#endif


   // Copia e usa szName
   if (cbName < MAX\_NAME)
      strncpy(szBuff,szName,MAX\_NAME-1);
}

Questo codice tenterà di scrivere il valore 0x42 nel buffer di destinazione.

Vi starete probabilmente chiedendo perché eseguire questa operazione anziché copiare semplicemente il buffer. Scrivendo un valore fisso, noto, al termine del buffer di destinazione, è possibile indurre un errore del codice se il buffer di origine è troppo grande ed è inoltre possibile individuare gli errori a uno stadio precoce del processo di sviluppo. È meglio che si verifichi un errore piuttosto che lasciare che venga eseguito del codice dannoso, per questo non si deve copiare il buffer di un pirata informatico.

Nota bene:  Questa operazione va effettuata soltanto in una generazione di debug per agevolare l’individuazione dei sovraccarichi del buffer durante il test.

Stare sulla difensiva

In tutta onestà, controllare è buona norma, ma non è certo la panacea contro gli attacchi. L’unico modo per essere sicuri è scrivere il codice stando sulla difensiva. Noterete che il codice riportato di seguito è già improntato in questo senso: controlla che i dati che arrivano alla funzione non abbiano una lunghezza superiore a quella del buffer interno, szBuff. Alcune funzioni tuttavia potenzialmente pongono gravi problemi di protezione se non sono utilizzate correttamente quando si copia o si lavora con dati non affidabili.
In questo caso il punto critico sono i dati non affidabili. Quando rivedete il codice alla ricerca di errori di sovraccarico del buffer, dovete seguire il flusso dei dati attraverso il codice e metterne in discussione i presupposti.
Gli errori che riuscirete a individuare scoprendo che alcuni dei presupposti non sono corretti sono incredibili.

Fra le funzioni di cui diffidare vi sono quelle classiche, ad esempio strcpy, strcat, gets e così via. Ma non sottovalutate le famigerate versioni sicure con la “n” di strcpy e strcat: strncpy e strncat. Di solito si ritiene che queste funzioni siano più sicure perché consentono allo sviluppatore di limitare le dimensioni dei dati copiati nel buffer di destinazione. Ebbene, anche gli sviluppatori sbagliano! Esaminate il codice che segue. Dov’è l’errore?

1
2
3
4
5
6
7
8
#define SIZE(b) (sizeof(b))
char buff\[128\];

strncpy(buff,szSomeData,SIZE(buff));

strncat(buff,szMoreData,SIZE(buff));

strncat(buff,szEvenMoreData,SIZE(buff));

Ecco un indizio: guardate gli ultimi argomenti di ciascuna funzione di gestione delle stringhe. Rinunciate? Prima di dare la risposta, spesso scherzo sul fatto che, se avete additato le funzioni di gestione delle stringhe “non sicure” e consigliereste di adottare le versioni con la “n”, più sicure, potreste trascorrere il resto della vita a risolvere gli errori che avrete introdotto così facendo. Ecco perché:

  1. l’ultimo argomento non corrisponde alla dimensione totale del buffer di destinazione, si tratta invece dello spazio rimasto nel buffer e, ogni volta che il codice effettua un’aggiunta a buff, buff essenzialmente si riduce.
  2. Il secondo problema si verifica quando viene passata la dimensione del buffer, di solito la gente sbaglia di uno. Nel calcolo della dimensione della stringa lo includete o no il valore null finale?
  3. In alcuni casi la versione con la n può dare luogo a una stringa che non termina con un valore null, quindi leggete la documentazione.

Se utilizzate C++ per scrivere il codice, provate a impiegare ATL, STL, MFC o le vostri classi preferite di gestione delle stringhe per agire sulle stringhe stesse anziché direttamente sui byte. Il solo possibile elemento a sfavore potrebbe essere un degrado delle prestazioni ma, in generale, l’utilizzo di queste classi ha come esito un codice più solido e gestibile.

Compilare utilizzando /GS

Questa nuova opzione di Visual C++ .NET, da utilizzare in fase di compilazione, inserisce valori in determinati stack frame delle funzioni, per contribuire ad alleviare la potenziale vulnerabilità dei sovraccarichi del buffer basati su stack. Ricordate però che questa opzione non risolve tutti i problemi e non elimina gli errori, funge semplicemente da rete di salvataggio per ridurre la potenziale sfruttabilità di determinate classi di sovraccarichi del buffer che consentirebbero a un pirata informatico di inserire codice nel processo ed eseguirlo. Consideratela come una piccola polizza assicurativa. Si noti che per i nuovi progetti C++ Win32 nativi, creati con la Creazione guidata applicazioni Win32, questa opzione è attivata per impostazione predefinita.
Inoltre anche Windows Server 2003 è compilato con questa opzione. Per ulteriori informazioni consultate l’articolo Compiler Security Checks In Depth di Brandon Bray.

Individuare i punti di vulnerabilità

Concludiamo con del codice che contiene almeno un errore di protezione. Lo vedete?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
WCHAR g\_wszComputerName\[INTERNET\_MAX\_HOST\_NAME\_LENGTH + 1\];

// Leggi il nome del server e convertilo in una stringa Unicode.
BOOL GetServerName (EXTENSION\_CONTROL\_BLOCK \*pECB) {
   DWORD   dwSize = sizeof(g\_wszComputerName);
   char    szComputerName\[INTERNET\_MAX\_HOST\_NAME\_LENGTH + 1\];

   if (pECB->GetServerVariable (pECB->ConnID,
            "SERVER\_NAME",
            szComputerName,
            &dwSize)) {
   // il resto del codice è stato omesso