Borrado seguro de datos privados

Borrado seguro de datos privados

A menudo necesitamos almacenar datos privados en programas, por ejemplo, contraseñas, claves secretas y sus derivados, y generalmente necesitamos borrar sus rastros en la memoria después de usarlos para que un posible intruso no pueda acceder a estos datos. En este artículo discutiremos por qué no puede borrar datos privados usando la función memset().

conjunto de memoria()

Es posible que ya haya leído el artículo sobre vulnerabilidades en programas donde memset() se utiliza para borrar la memoria. Sin embargo, ese artículo no cubre completamente todos los posibles escenarios de uso incorrecto de memset() . Es posible que tenga problemas no solo con la limpieza de los búfer asignados por la pila, sino también con la limpieza de los búfer asignados dinámicamente.

La pila

Para empezar, analicemos un ejemplo del artículo mencionado anteriormente que trata sobre el uso de una variable asignada por pila.

Aquí hay un fragmento de código que maneja una contraseña:

#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;
}

Este ejemplo es bastante convencional y completamente sintético.

Si creamos una versión de depuración de ese código y lo ejecutamos en el depurador (estaba usando Visual Studio 2015), veremos que funciona bien:la contraseña y su valor hash calculado se borran después de que se hayan utilizado.

Echemos un vistazo a la versión de ensamblador de nuestro código en el depurador de 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  
....

Vemos la llamada de memset() función, que borra los datos privados después de su uso.

Podríamos detenernos aquí, pero continuaremos e intentaremos crear una versión de lanzamiento optimizada. Ahora, esto es lo que vemos en el depurador:

....
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   
....

Todas las instrucciones asociadas con la llamada al memset() la función ha sido eliminada. El compilador asume que no hay necesidad de llamar a una función para borrar datos ya que ya no están en uso. No es un error; es una elección legal del compilador. Desde el punto de vista del lenguaje, un memset() la llamada no es necesaria ya que el búfer no se usa más en el programa, por lo que eliminar esta llamada no puede afectar su comportamiento. Entonces, nuestros datos privados permanecen sin aclarar, y es muy malo.

El montón

Ahora profundicemos. Veamos qué sucede con los datos cuando los asignamos en memoria dinámica usando el malloc función o la nueva operador.

Modifiquemos nuestro código anterior para que funcione con malloc :

#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;
}

Estaremos probando una versión de lanzamiento ya que la versión de depuración tiene todas las llamadas donde queremos que estén. Después de compilarlo en Visual Studio 2015, obtenemos el siguiente código ensamblador:

.... 
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 lo ha hecho bien esta vez:borra los datos según lo planeado. Pero, ¿qué pasa con otros compiladores? Probemos gcc , versión 5.2.1 y clang , versión 3.7.0.

He modificado un poco nuestro código para gcc y sonido y agregó algo de código para imprimir el contenido del bloque de memoria asignado antes y después de la limpieza. Imprimo el contenido del bloque al que apunta el puntero después de liberar la memoria, pero no deberías hacerlo en programas reales porque nunca sabes cómo responderá la aplicación. En este experimento, sin embargo, me tomo la libertad de usar esta técnica.

....
#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);
....

Ahora, aquí hay un fragmento del código ensamblador generado por gcc compilador:

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

La función de impresión (printf ) va seguido de una llamada a free() mientras la llamada a memset() la función se ha ido. Si ejecutamos el código e ingresamos una contraseña arbitraria (por ejemplo, "MyTopSecret"), veremos el siguiente mensaje impreso en la pantalla:

Mi alto secreto | 7882334103340833743

Mi alto secreto | 0

El hash ha cambiado. Supongo que es un efecto secundario del trabajo del administrador de memoria. En cuanto a nuestra contraseña "MyTopSecret", permanece intacta en la memoria.

Veamos cómo funciona con clang :

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

Al igual que en el caso anterior, el compilador decide eliminar la llamada al memset() función. Así es como se ve la salida impresa:

Mi alto secreto | 7882334103340833743

Mi alto secreto | 0

Entonces, tanto gcc y sonido decidimos optimizar nuestro código. Dado que la memoria se libera después de llamar a memset() función, los compiladores tratan esta llamada como irrelevante y la eliminan.

Como revelan nuestros experimentos, los compiladores tienden a eliminar memset() llamadas en aras de la optimización trabajando tanto con la pila como con la memoria dinámica de la aplicación.

Finalmente, veamos cómo responderán los compiladores al asignar memoria usando el nuevo operador.

Modificando el código de nuevo:

#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 borra la memoria como se esperaba:

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

El CCG el compilador también decidió dejar la función de limpieza:

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

La salida impresa ha cambiado en consecuencia; los datos que hemos introducido ya no están:

Mi alto secreto | 7882334103340833743

| 0

Pero en cuanto a clang , optó por optimizar nuestro código también en este caso y eliminó la función "innecesaria":

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

Imprimimos el contenido de la memoria:

MyTopSecret| 7882334103340833743 
MyTopSecret| 0

La contraseña permanece, esperando ser robada.

Resumámoslo todo. Hemos encontrado que un compilador de optimización puede eliminar una llamada a memset() funcione sin importar qué tipo de memoria se use:pila o dinámica. Aunque Visual Studio no eliminó memset() llamadas al usar la memoria dinámica en nuestra prueba, no puede esperar que siempre se comporte de esa manera en el código de la vida real. El efecto dañino puede revelarse con otros conmutadores de compilación. Lo que se desprende de nuestra pequeña investigación es que uno no puede confiar en el memset() función para borrar datos privados.

Entonces, ¿cuál es una mejor manera de eliminarlos?

Debe usar funciones especiales de borrado de memoria, que el compilador no puede eliminar cuando optimiza el código.

En Visual Studio, por ejemplo, puede usar RtlSecureZeroMemory . A partir de C11, función memset_s también está disponible. Además, puede implementar una función segura propia, si es necesario; Se pueden encontrar muchos ejemplos y guías en la web. Estos son algunos de ellos.

Solución 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;
}

Solución nº 2.

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

Algunos programadores van más allá y crean funciones que llenan la matriz con valores pseudoaleatorios y tienen diferentes tiempos de ejecución para dificultar los ataques basados ​​en la medición del tiempo. Las implementaciones de estos también se pueden encontrar en la web.

Conclusión

El analizador estático de PVS-Studio puede detectar los errores de borrado de datos que hemos discutido aquí y utiliza el diagnóstico V597 para señalar el problema. Este artículo fue escrito como una explicación ampliada de por qué este diagnóstico es importante. Desafortunadamente, muchos programadores tienden a pensar que el analizador "se mete" con su código y que en realidad no hay nada de qué preocuparse. Bueno, es porque ven su memset() llamadas intactas al ver el código en el depurador, olvidando que lo que ven es solo una versión de depuración.