Cancellazione sicura dei dati personali

Cancellazione sicura dei dati personali

Spesso abbiamo bisogno di archiviare dati privati ​​nei programmi, ad esempio password, chiavi segrete e loro derivati, e di solito abbiamo bisogno di cancellare le loro tracce nella memoria dopo averli usati in modo che un potenziale intruso non possa accedere a questi dati. In questo articolo discuteremo del motivo per cui non puoi cancellare i dati privati ​​usando la funzione memset().

memset()

Potresti aver già letto l'articolo che parla delle vulnerabilità nei programmi in cui memset() serve per cancellare la memoria. Tuttavia, quell'articolo non copre completamente tutti i possibili scenari di uso non corretto di memset() . Potresti avere problemi non solo con la cancellazione dei buffer allocati nello stack, ma anche con la cancellazione dei buffer allocati dinamicamente.

La pila

Per cominciare, discutiamo un esempio tratto dall'articolo sopra menzionato che tratta dell'utilizzo di una variabile allocata allo stack.

Ecco un frammento di codice che gestisce una password:

#include <string>
#include <functional>
#include <iostream>

//Private data
struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

//Function performs some operations on password
void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

//Function for password entering and processing
int funcPswd()
{
  PrivateData data;
  std::cin >> data.m_pswd;

  doSmth(data);
  memset(&data, 0, sizeof(PrivateData));
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Questo esempio è piuttosto convenzionale e completamente sintetico.

Se creiamo una versione di debug di quel codice e la eseguiamo nel debugger (stavo usando Visual Studio 2015), vedremo che funziona bene:la password e il relativo valore hash calcolato vengono cancellati dopo che sono stati usati.

Diamo un'occhiata alla versione assembler del nostro codice nel debugger di Visual Studio:

.... 
    doSmth(data);
000000013F3072BF  lea         rcx,[data]  
000000013F3072C3  call        doSmth (013F30153Ch)  
  memset(&data, 0, sizeof(PrivateData));
000000013F3072C8  mov         r8d,70h  
000000013F3072CE  xor         edx,edx  
000000013F3072D0  lea         rcx,[data]  
000000013F3072D4  call        memset (013F301352h)  
  return 1;
000000013F3072D9  mov         eax,1  
....

Vediamo la chiamata di memset() funzione, che cancella i dati privati ​​dopo l'uso.

Potremmo fermarci qui, ma andremo avanti e cercheremo di costruire una versione di rilascio ottimizzata. Ora, questo è ciò che vediamo nel debugger:

....
000000013F7A1035  call
        std::operator>><char,std::char_traits<char> > (013F7A18B0h)  
000000013F7A103A  lea         rcx,[rsp+20h]  
000000013F7A103F  call        doSmth (013F7A1170h)  
    return 0;
000000013F7A1044  xor         eax,eax   
....

Tutte le istruzioni associate alla chiamata al memset() la funzione è stata cancellata. Il compilatore presuppone che non sia necessario chiamare una funzione di cancellazione dei dati poiché non sono più in uso. Non è un errore; è una scelta legale del compilatore. Dal punto di vista linguistico, un memset() la chiamata non è necessaria poiché il buffer non viene utilizzato ulteriormente nel programma, quindi la rimozione di questa chiamata non può influire sul suo comportamento. Quindi, i nostri dati privati ​​rimangono non cancellati ed è pessimo.

L'heap

Ora scaviamo più a fondo. Vediamo cosa succede ai dati quando li allochiamo nella memoria dinamica usando il malloc funzione o il nuovo operatore.

Modifichiamo il nostro codice precedente per lavorare con maloc :

#include <string>
#include <functional>
#include <iostream>

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = (PrivateData*)malloc(sizeof(PrivateData));
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  free(data);
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Testeremo una versione di rilascio poiché la versione di debug ha tutte le chiamate dove vogliamo che siano. Dopo averlo compilato in Visual Studio 2015, otteniamo il codice assembler seguente:

.... 
000000013FBB1021  mov         rcx,
        qword ptr [__imp_std::cin (013FBB30D8h)]  
000000013FBB1028  mov         rbx,rax  
000000013FBB102B  lea         rdx,[rax+8]  
000000013FBB102F  call
        std::operator>><char,std::char_traits<char> > (013FBB18B0h)  
000000013FBB1034  mov         rcx,rbx  
000000013FBB1037  call        doSmth (013FBB1170h)  
000000013FBB103C  xor         edx,edx  
000000013FBB103E  mov         rcx,rbx  
000000013FBB1041  lea         r8d,[rdx+70h]  
000000013FBB1045  call        memset (013FBB2A2Eh)  
000000013FBB104A  mov         rcx,rbx  
000000013FBB104D  call        qword ptr [__imp_free (013FBB3170h)]  
    return 0;
000000013FBB1053  xor         eax,eax  
....

Visual Studio ha fatto bene questa volta:cancella i dati come previsto. Ma per quanto riguarda gli altri compilatori? Proviamo gcc , versione 5.2.1 e clang , versione 3.7.0.

Ho modificato un po' il nostro codice per gcc e clan e aggiunto del codice per stampare il contenuto del blocco di memoria allocato prima e dopo la pulizia. Stampo il contenuto del blocco a cui punta il puntatore dopo che la memoria è stata liberata, ma non dovresti farlo nei programmi reali perché non sai mai come risponderà l'applicazione. In questo esperimento, però, mi permetto di usare questa tecnica.

....
#include "string.h"
....
size_t len = strlen(data->m_pswd);
for (int i = 0; i < len; ++i)
  printf("%c", data->m_pswd[i]);
printf("| %zu \n", data->m_hash);
memset(data, 0, sizeof(PrivateData));
free(data);
for (int i = 0; i < len; ++i)
  printf("%c", data->m_pswd[i]);
printf("| %zu \n", data->m_hash);
....

Ora, ecco un frammento del codice assembler generato da gcc compilatore:

movq (%r12), %rsi
movl $.LC2, %edi
xorl %eax, %eax
call printf
movq %r12, %rdi
call free

La funzione di stampa (printf ) è seguito da una chiamata a free() durante la chiamata a memset() la funzione è sparita. Se eseguiamo il codice e inseriamo una password arbitraria (ad esempio "MyTopSecret"), vedremo stampato sullo schermo il seguente messaggio:

MyTopSecret| 7882334103340833743

MyTopSecret| 0

L'hash è cambiato. Immagino sia un effetto collaterale del lavoro del gestore della memoria. Per quanto riguarda la nostra password "MyTopSecret", rimane intatta nella memoria.

Verifichiamo come funziona con clang :

movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq free

Proprio come nel caso precedente, il compilatore decide di rimuovere la chiamata al memset() funzione. Ecco come appare l'output stampato:

MyTopSecret| 7882334103340833743

MyTopSecret| 0

Quindi, entrambi gcc e clan abbiamo deciso di ottimizzare il nostro codice. Poiché la memoria viene liberata dopo aver chiamato memset() funzione, i compilatori considerano questa chiamata irrilevante e la eliminano.

Come rivelano i nostri esperimenti, i compilatori tendono a eliminare memset() chiama per motivi di ottimizzazione lavorando sia con lo stack che con la memoria dinamica dell'applicazione.

Infine, vediamo come risponderanno i compilatori durante l'allocazione della memoria usando il nuovo operatore.

Modificando nuovamente il codice:

#include <string>
#include <functional>
#include <iostream>
#include "string.h"

struct PrivateData
{
  size_t m_hash;
  char m_pswd[100];
};

void doSmth(PrivateData& data)
{
  std::string s(data.m_pswd);
  std::hash<std::string> hash_fn;

  data.m_hash = hash_fn(s);
}

int funcPswd()
{
  PrivateData* data = new PrivateData();
  std::cin >> data->m_pswd;
  doSmth(*data);
  memset(data, 0, sizeof(PrivateData));
  delete data;
  return 1;
}

int main()
{
  funcPswd();
  return 0;
}

Visual Studio cancella la memoria come previsto:

000000013FEB1044  call        doSmth (013FEB1180h)  
000000013FEB1049  xor         edx,edx  
000000013FEB104B  mov         rcx,rbx  
000000013FEB104E  lea         r8d,[rdx+70h]  
000000013FEB1052  call        memset (013FEB2A3Eh)  
000000013FEB1057  mov         edx,70h  
000000013FEB105C  mov         rcx,rbx  
000000013FEB105F  call        operator delete (013FEB1BA8h)  
    return 0;
000000013FEB1064  xor         eax,eax

Il gcc il compilatore ha deciso di lasciare anche la funzione di cancellazione:

call printf
movq %r13, %rdi
movq %rbp, %rcx
xorl %eax, %eax
andq $-8, %rdi
movq $0, 0(%rbp)
movq $0, 104(%rbp)
subq %rdi, %rcx
addl $112, %ecx
shrl $3, %ecx
rep stosq
movq %rbp, %rdi
call _ZdlPv

L'output stampato è cambiato di conseguenza; i dati che abbiamo inserito non ci sono più:

MyTopSecret| 7882334103340833743

| 0

Ma per quanto riguarda il clang , ha scelto di ottimizzare il nostro codice anche in questo caso e di eliminare la funzione "non necessaria":

movq (%r14), %rsi
movl $.L.str.1, %edi
xorl %eax, %eax
callq printf
movq %r14, %rdi
callq _ZdlPv

Stampiamo il contenuto della memoria:

MyTopSecret| 7882334103340833743 
MyTopSecret| 0

La password rimane, in attesa di essere rubata.

Riassumiamo tutto. Abbiamo scoperto che un compilatore di ottimizzazione può rimuovere una chiamata a memset() funzione indipendentemente dal tipo di memoria utilizzata:stack o dinamica. Sebbene Visual Studio non abbia rimosso memset() chiamate quando si utilizza la memoria dinamica nel nostro test, non puoi aspettarti che si comporti sempre in questo modo nel codice reale. L'effetto dannoso può rivelarsi con altre opzioni di compilazione. Quello che segue dalla nostra piccola ricerca è che non si può fare affidamento su memset() funzione per cancellare i dati privati.

Quindi, qual è un modo migliore per cancellarli?

Dovresti usare speciali funzioni di cancellazione della memoria, che non possono essere cancellate dal compilatore quando ottimizza il codice.

In Visual Studio, ad esempio, puoi utilizzare RtlSecureZeroMemory . A partire da C11, funzione memset_s è anche disponibile. Inoltre, se necessario, puoi implementare una tua funzione sicura; molti esempi e guide si possono trovare in giro per il web. Eccone alcuni.

Soluzione n. 1.

errno_t memset_s(void *v, rsize_t smax, int c, rsize_t n) {
  if (v == NULL) return EINVAL;
  if (smax > RSIZE_MAX) return EINVAL;
  if (n > smax) return EINVAL;
  volatile unsigned char *p = v;
  while (smax-- && n--) {
    *p++ = c;
  }
  return 0;
}

Soluzione n. 2.

void secure_zero(void *s, size_t n)
{
    volatile char *p = s;
    while (n--) *p++ = 0;
}

Alcuni programmatori vanno ancora oltre e creano funzioni che riempiono l'array con valori pseudocasuali e hanno tempi di esecuzione diversi per ostacolare gli attacchi basati sulla misurazione del tempo. Le implementazioni di questi possono essere trovate anche sul web.

Conclusione

L'analizzatore statico PVS-Studio è in grado di rilevare gli errori di cancellazione dei dati di cui abbiamo discusso qui e utilizza la diagnostica V597 per segnalare il problema. Questo articolo è stato scritto come spiegazione estesa del motivo per cui questa diagnostica è importante. Sfortunatamente, molti programmatori tendono a pensare che l'analizzatore "scelga" il loro codice e in realtà non c'è nulla di cui preoccuparsi. Bene, è perché vedono il loro memset() chiamate intatte durante la visualizzazione del codice nel debugger, dimenticando che ciò che vedono è ancora solo una versione di debug.