Sangrando corazones criptográficos: Deconstruyendo la vulnerabilidad de OpenSSL

Como ya debe ser bien sabido por nuestros lectores, hace unas semanas la red se inundó con miles de referencias a lo que parece que será el bug del año.

Tanto es así que ya ha habido varias reacciones importantes para intentar que errores de esta magnitud no vuelvan a ocurrir. Por un lado tenemos el fork de OpenSSL por parte de OpenBSD, llamado LibreSSL [1], y por otro lado, tenemos la iniciativa de la Linux Foundation en la que se han asociado varias de las compañías más importantes del sector IT a nivel mundial (Intel, Microsoft, Google, IBM, DELL, Facebook, etc) para proveer de fondos a todos aquellos proyectos open source que sean críticos [2][3].

En esta entrada vamos a analizar técnicamente a qué se debe esta vulnerabilidad y a tratar ciertos aspectos que, generalmente, se han comentado menos como, por ejemplo, la explotación de esta vulnerabilidad en clientes en vez de en servidores.

Versiones vulnerables y versiones corregidas

Lo primero que se debe definir es que esta vulnerabilidad afecta a la versión de OpenSSL 1.0.1, pasando por la  1.0.1a hasta la  1.0.1f. También afecta a la versión 1.0.2-beta1. Los parches han sido liberados a partir de la versión 1.0.1g y en la versión 1.0.2 a partir de la versión 1.0.2-beta2. Por su parte, Debian ha liberado el parche en el paquete 1.0.1e-2+deb7u5. [4]

Heartbeat contra Heartbleed

Estas últimas semanas no ha habido descanso. En la red no se han parado de escuchar los siguientes términos. Heartbleed, heartbeat, heartbeat y de nuevo heartbleed. Pero ¿qué significan?

Heartbleed es el nombre que se le ha dado a una vulnerabilidad que afecta a una extensión del protocolo TLS y DTLS. Esta extensión se llama Heartbeat y se encarga de mantener las conexiones entre cliente y servidor abiertas sin necesidad de llevar a cabo renegociaciones. De ahí que el nombre de la vulnerabilidad haya jugado con el término del latido (beat) al sangrado (bleed), por el efecto de “fuga” que ha implicado esta vulnerabilidad.

El funcionamiento básico de este protocolo pasa por enviar dos tipos de paquetes, HeartbeatRequest y HeartbeatResponse. El paquete HeartbeatRequest se envía con un contenido arbitrario de modo que si la máquina que recibe dicho paquete contesta con un paquete HeartbeatResponse con el mismo contenido, la conexión se mantiene abierta. [5]

Así de simple.

Antes de continuar, es importante destacar lo siguiente. En la mayoría de protocolos es necesario especificar el tamaño de los datos que se van a enviar, de modo que el otro extremo de la comunicación sepa hasta donde debe leer para obtener todo el paquete. Esto también ocurre en el caso que nos concierne.

¿Cuál es la vulnerabilidad?

Conociendo lo anterior, entender la vulnerabilidad es muy simple.

Tenemos dos tipos de mensajes. HeartbeatRequest y HeartbeatResponse. Como ya hemos explicado, cuando se envía un mensaje HeartbeatRequest, el otro extremo debe enviar un mensaje HeartbeatResponse con el mismo contenido (o payload) que tenía el mensaje HeartbeatRequest. Fácil, ¿verdad?

El otro extremo de la comunicación necesita saber el tamaño del mensaje HeartbeatRequest que se le envía. Para ello, dentro del mensaje HeartbeatRequest existe un campo que indica el tamaño del contenido de dicho mensaje. Por ejemplo, si el mensaje HeartbeatRequest tiene como contenido "hola", el campo de tamaño será igual a 4.

Al recibir el mensaje HeartbeatRequest, el otro extremo de la comunicación reservará tanto espacio de memoria como indique el campo de tamaño de dicho mensaje para almacenar su contenido.

A continuación, responderá con un mensaje HeartbeatResponse enviando los datos para los que ha reservado memoria que, en principio, deberían ser los mismos que los que se han enviado con el mensaje HeartbeatRequest.

La mayoría ya entenderéis donde está el problema. ¿Qué ocurre si el contenido del mensaje HeartbeatRequest es igual a x, pero el campo de tamaño es igual a x+n? Pues que el mensaje de respuesta HeartbeatResponse enviará de vuelta x+n bytes, leídos todos ellos de la propia memoria del proceso openSSL.

Todo esto lo han resumido perfectamente en una imagen los maestros de XKCD: http://xkcd.com/1354/

Talk is cheap, show me the code

El código que se muestra a continuación se ha extraído de la versión 1.0.1e de openSSL. La función vulnerable se encuentra en la línea 2481 del archivo ssl/t1_lib.c y se llama tls1_process_heartbeat. A continuación se muestra la función al completo que después vamos a ir analizando:

int tls1_process_heartbeat(SSL *s)
 {
 unsigned char *p = &s->s3->rrec.data[0], *pl;
 unsigned short hbtype;
 unsigned int payload;
 unsigned int padding = 16; /* Use minimum padding */

 /* Read type and payload length first */
 hbtype = *p++;
 n2s(p, payload);
 pl = p;

 if (s->msg_callback)
  s->msg_callback(0, s->version, TLS1_RT_HEARTBEAT,
   &s->s3->rrec.data[0], s->s3->rrec.length,
   s, s->msg_callback_arg);

 if (hbtype == TLS1_HB_REQUEST)
  {
  unsigned char *buffer, *bp;
  int r;

  /* Allocate memory for the response, size is 1 bytes
   * message type, plus 2 bytes payload length, plus
   * payload, plus padding
   */
  buffer = OPENSSL_malloc(1 + 2 + payload + padding);
  bp = buffer;
  
  /* Enter response type, length and copy payload */
  *bp++ = TLS1_HB_RESPONSE;
  s2n(payload, bp);
  memcpy(bp, pl, payload);
  bp += payload;
  /* Random padding */
  RAND_pseudo_bytes(bp, padding);

  r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer, 3 + payload 
                + padding);

  if (r >= 0 && s->msg_callback)
   s->msg_callback(1, s->version, TLS1_RT_HEARTBEAT,
    buffer, 3 + payload + padding,
    s, s->msg_callback_arg);

  OPENSSL_free(buffer);

  if (r < 0)
   return r;
  }
 else if (hbtype == TLS1_HB_RESPONSE)
  {
  unsigned int seq;
  
  /* We only send sequence numbers (2 bytes unsigned int),
   * and 16 random bytes, so we just try to read the
   * sequence number */
  n2s(pl, seq);
  
  if (payload == 18 && seq == s->tlsext_hb_seq)
   {
   s->tlsext_hb_seq++;
   s->tlsext_hb_pending = 0;
   }
  }

 return 0;
 }
                           [ssl/t1_lib.c:2481]

Esta función es corta y simple. Comprueba si se recibe un mensaje HeartbeatRequest o HeartbeatResponse e implementa el comportamiento definido anteriormente. La variable 'p' apuntará al mensaje. En la variable 'hbtype' se almacenará el tipo de mensaje (HeartbeatRequest o HeartbeatResponse) y en 'payload', por engañoso que parezca, se almacenará el tamaño del payload, no el payload en sí. Con el siguiente código se establecen estos datos.
 
/* Read type and payload length first */
hbtype = *p++;
n2s(p, payload);
pl = p;
                            [ssl/t1_lib.c:2489]

La macro 'n2s' está definida en el archivo ssl/ssl_loc1.h, en la línea 249. Simplemente se encarga de recibir los dos bytes del campo de tamaño de un mensaje Heartbeat y los almacena en una variable de tipo unsigned int. Además, avanza el puntero que apunta al mensaje dos bytes, para que éste apunte al contenido del mensaje.


#define n2s(c,s)        ((s=(((unsigned int)(c[0]))<< 8)| \
                            (((unsigned int)(c[1]))    )),c+=2)
                            [ssl/ssl_locl.h:249]


Además, tenemos la instrucción 'pl = p'. Esta instrucción es muy importante porqué 'pl' se utilizará a continuación. Como ya hemos dicho, 'p' apunta al mensaje. Después establecer el tipo del mensaje y el tamaño del contenido, 'p' apuntará directamente al contenido del mensaje, con lo que 'pl' apuntará al mismo sitio.

A continuación se evalúa si el mensaje recibido es del tipo Request o Response. La vulnerabilidad se produce si el mensaje es de tipo Request, y este es el código que se ejecuta:

if (hbtype == TLS1_HB_REQUEST)
    {
    unsigned char *buffer, *bp;
    int r;

    /* Allocate memory for the response, size is 1 bytes
     * message type, plus 2 bytes payload length, plus
     * payload, plus padding
     */
    buffer = OPENSSL_malloc(1 + 2 + payload + padding);
    bp = buffer;
    
    /* Enter response type, length and copy payload */
    *bp++ = TLS1_HB_RESPONSE;
    s2n(payload, bp);
    memcpy(bp, pl, payload);
    bp += payload;
    /* Random padding */
    RAND_pseudo_bytes(bp, padding);

    r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer, 3 + payload + padding);

    if (r >= 0 && s->msg_callback)
        s->msg_callback(1, s->version, TLS1_RT_HEARTBEAT,
            buffer, 3 + payload + padding,
            s, s->msg_callback_arg);

    OPENSSL_free(buffer);

    if (r < 0)
        return r;
    }
                            [ssl/ssl_locl.h:2499]

Como se puede ver, una de las primeras instrucciones que se ejecuta es un malloc en el que se reserva espacio para una variable  de un tamaño de 3 bytes más el padding y, además, se le añaden tantos bytes como se le haya especificado en el campo de tamaño del mensaje. Como se puede ver, ¡en ningún momento se ha comprobado este tamaño!

buffer = OPENSSL_malloc(1 + 2 + payload + padding);
bp = buffer;
                            [ssl/ssl_locl.h:2508]

A continuación, se hace que el puntero 'bp' apunte al inicio del búfer que se ha reservado.

Con las siguientes líneas, en este búfer se almacena lo que se enviará como respuesta. Primero el tipo de mensaje (HeartbeatResponse), después el tamaño del contenido, a continuación el contenido en sí y, por último, cierto padding. Este búfer será el que se envié de vuelta como HeartbeatResponse.

Estas líneas son las que construyen lo que se enviará de respuesta.

/* Enter response type, length and copy payload */
*bp++ = TLS1_HB_RESPONSE;
s2n(payload, bp);
memcpy(bp, pl, payload);
bp += payload;
/* Random padding */
RAND_pseudo_bytes(bp, padding);
                            [ssl/ssl_locl.h:2511]

Con la función 'memcpy' se copian tantos bytes como se especifique en la variable 'payload', en la dirección donde apunte 'bp' como destino cogiendo los datos de la dirección a la que apunte 'pl'.

'bp' apunta al búfer que se ha reservado con 'malloc', o sea, es lo que se enviará como respuesta.

'pl' apunta al mensaje HeartbeatRequest que se ha recibido, que se habrá almacenado en algún punto de la memoria. Específicamente, la estructura en la que se almacena este mensaje se pasa como parámetro de esta función (SSL *s). Lo primero que se hace es asignar el puntero 'p' a esta estructura y, después, 'pl' apunta a cierto offset de 'p'. Esto se puede ver a continuación.

unsigned char *p = &s->s3->rrec.data[0], *pl;
(...)
pl = p;
                            [ssl/ssl_locl.h:2584,2592]

Así pues, se están copiando tantos bytes como especifique la variable 'payload', de donde apunta 'pl' a donde apunta 'bp'.

Si todo funcionara con normalidad, 'pl' apuntaría al contenido del mensaje HeartbeatRequest y sólo se copiarían tantos bytes como largo fuera dicho mensaje. Sin embargo, ¿qué ocurre si como tamaño se le envía un número mucho más grande? Pues que la función 'memcpy' empezará a copiar desde la dirección a la que apunte 'pl' tantos bytes como se le hayan especificado en el campo de tamaño (ahora almacenado en la variable 'payload').

Este comportamiento hará que en el mensaje de respuesta HeartbeatResponse se envíen tantos bytes como se le especifique en el mensaje HeartbeatRequest, consiguiendo así filtrar todos los datos que haya en la memoria del proceso a partir de la dirección a la que apunte 'pl'.

Y esta es, al fin, la vulnerabilidad Heartbleed.

Limitaciones

La pregunta más obvia una vez definida la vulnerabilidad es ¿Qué puedo leer? Esta pregunta se puede dividir, básicamente, en dos preguntas. La primera de ellas sería ¿De dónde puedo leer? Y la segunda sería ¿Cuánto puedo leer?

Empecemos por la segunda.

Tal y como se puede extraer del RFC [5], una estructura Heartbeat se define tal que así:

struct {
    HeartbeatMessageType type;
    uint16 payload_length;
    opaque payload[HeartbeatMessage.payload_length];
    opaque padding[padding_length];
} HeartbeatMessage;

Parece que esta estructura no se define en el código fuente de openSSL. Al menos, yo no he sido capaz de encontrarla. Pero para lo que necesitamos explicar, tenemos más que suficiente.
Como se puede ver, el campo 'payload_length' está definido como 'uint16', que viene a signifcar que esta variable será un 'unsigned int' de 16 bits, o sea, dos bytes.
Con una variable de 16 bits sin signo, el máximo valor que se puede especificar es 2^16 - 1, que es igual a 65535.

Esto significa que, como máximo, al explotar esta vulnerabilidad podremos leer 64Kb de la memoria del proceso vulnerable, o sea, openSSL.

La siguiente pregunta es ¿a partir de qué dirección de memoria podremos leer esos 64Kb?

Como ya se ha explicado, los datos de memoria que se leen parten desde la dirección origen apuntada por el puntero 'pl'. Este puntero apunta a un offset de la dirección a la que apunta el puntero 'p', que a su vez, apunta a la siguiente estructura de memoria &s->s3->rrec.data[0].

La variable 's' es de tipo 'SSL', que está definido del siguiente modo:

                    typedef struct ssl_st SSL;
                    [include/openssl/ossl_typ.h:172]

A su vez, la estructura 'ssl_st' se define del siguiente modo:
struct ssl_st
        {
        (...)
        struct ssl3_state_st *s3; /* SSLv3 variables */
        (...)
        }
                     [include/openssl/ssl.h:1105]

La estructura 'ssl_st' es muy grande, pero en este caso sólo nos interesa la variable 's3', que es la que se utiliza para leer el mensaje Heartbeat. Más específicamente nos interesa la variable 'rrec' que forma parte de la estructura 'ssl3_state_st', que se define de la siguiente manera:

typedef struct ssl3_state_st
        {
        (...)
        SSL3_RECORD rrec;       /* each decoded record goes in here */
        (...)
        }
                      [include/openssl/ssl3.h:405]


Por último, la estructura SSL3_RECORD tiene la siguiente definición:
typedef struct ssl3_record_st
        {
/*r */  int type;               /* type of record */
/*rw*/  unsigned int length;    /* How many bytes available */
/*r */  unsigned int off;       /* read/write offset into 'buf' */
/*rw*/  unsigned char *data;    /* pointer to the record data */
/*rw*/  unsigned char *input;   /* where the decode bytes are */
/*r */  unsigned char *comp;    /* only used with decompression - malloc()ed */
/*r */  unsigned long epoch;    /* epoch number, needed by DTLS1 */
/*r */  unsigned char seq_num[8]; /* sequence number, needed by DTLS1 */
        } SSL3_RECORD; 
 
                       [include/openssl/ssl3.h:348]

De esta estructura sólo nos interesa el puntero 'data', ya que es en éste en el que se almacenará el mensaje Heartbeat y al que apuntarán 'p' y 'pl'. Desde la dirección a la que apunte este puntero hacia adelante se podrán leer 64Kb. Por tanto, para saber qué datos se van a poder leer, es necesario tener una idea de hacia qué dirección apuntará este puntero.

Lo cierto es que descubrir el origen de esta variable no fue nada fácil. No os vamos a aburrir con la media hora de indirecciones que se tuvieron que hacer para llegar al origen. Basta comentar que los datos que se almacenan en esta variable se leen en la función ssl3_setup_read_buffer que se llama, a su vez, desde ssl3_read_bytes.

int ssl3_read_bytes(SSL *s, int type, unsigned char *buf, int len, int peek)
        {
 (...)
        if (s->s3->rbuf.buf == NULL) /* Not initialized yet */
                if (!ssl3_setup_read_buffer(s))
                        return(-1);
 (...)
 }
                        [ssl/s3_pkt.c:941]


int ssl3_setup_read_buffer(SSL *s)
        {
 (...)
 if (s->s3->rbuf.buf == NULL)
 {
 (...)
 if ((p=freelist_extract(s->ctx, 1, len)) == NULL)
  goto err;
 s->s3->rbuf.buf = p;
 s->s3->rbuf.len = len;
 }

 s->packet= &(s->s3->rbuf.buf[0]);
 return 1;
 (...)
 }
                       [ssl/s3_both.c:736]

Es de la variable 's->packet' de la que se lee el contenido del mensaje Heartbeat, y esta variable apunta a 'p' que, a su vez, se obtiene a través de la función 'freelist_extract'.

La función 'freelist_extract' se encarga de obtener espacio de memoria para almacenar una variable. Dependiendo de cómo se haya compilado openSSL, este espacio se obtiene de un modo u otro, pero dicho espacio siempre se acabará obteniendo a través de una llamada a 'OPENSSL_malloc'. La diferencia está en que, por defecto, openSSL mantiene una lista de fragmentos de memoria libres de manera interna para no tener que depender de los algoritmos de la LIBC del sistema operativo. Y por lo visto, esto ha tenido su polémica estos días [7], pero eso ya es otro tema...

Básicamente OPENSSL_malloc es una macro que llama a la función CRYPTO_malloc. Esta función, después de dar unas cuantas vueltas, acaba llamando al malloc de toda la vida.


¡Todo este camino para descubrir que la región de memoria desde la que se pueden leer datos es el heap!  Es cierto que hace ya días que la gente comenta que los datos que se pueden leer son del heap. Sin embargo, en Internet Security Auditors nos gusta hacer las cosas bien. Y para hacerlas bien no queda otra que hundirte en el código y no creer a ciegas lo que se comenta.

Esto, aparte de robarnos algunas horas, nos ha permitido descubrir varias cosas. La primera es que los desarrolladores de openSSL implementan su propio sistema de gestión de memoria dinámica, no carente de vulnerabilidades [8].

Además, lo más importante es que hemos descubierto que la memoria se obtiene usando malloc. La función malloc, internamente, utiliza brk() o mmap() dependiendo del tamaño de la memoria a reservar. A grandes rasgos, la diferencia entre ambas funciones es que brk() reserva memoria de manera incremental a partir de la reserva de memoria anterior y mmap() reserva memoria a partir de una dirección que tú le especifiques.

Imagino que la mayoría de vosotros habréis deducido que el mejor modo para explotar esta vulnerabilidad es a través de llamar a mmap() en vez de brk(), ya que sería posible, de un modo u otro, especificar la dirección de memoria que se quiere leer. Siento decepcionaros, pero parece ser que eso no es posible. La función malloc, internamente, utiliza mmap, por defecto, sólo para fragmentos de memoria mayores a 128Kb y, como recordaréis, a través de esta vulnerabilidad sólo se puede pedir 64Kb de memoria.

Así pues, parece que la lectura de memoria va a ser bastante limitada ya que dependemos de las reservas de memoria incrementales a través de brk(). Esto significa que, en principio, no deberíamos ser capaces de leer datos en memoria que estén en una dirección inferior a la que apunta brk() y que aún no se hayan liberado.

¿Qué implica esto? Pues que, por ejemplo, no se deberían poder leer las claves privadas que el proceso openSSL tiene en memoria, ya que, en principio, éstas se almacenan al inicio de la ejecución del proceso y no se liberan en ningún momento.

Esta es nuestra teoría... que desastrosamente se ve tirada por el suelo por el challenge de CloudFlare en el que varias personas han podido obtener su clave privada a través de Heatbleed [9]. Aunque hayan necesitado unos cuantos millones de peticiones.

¿Qué nos enseña esto? Que cuando la lógica no aplique, siempre quedará la fuerza bruta.

¿A qué se puede deber? Pues especulando por especular, pensé que quizá podía ser algún 'corner case' del algoritmo de gestión de memoria dinámica del sistema operativo o del propio openSSL, en el que se mueven de sitio los datos en memoria debido al estrés de peticiones y la falta de espacio, o a la fragmentación interna de la memoria.

Tras unos días la gente de CloudFlare aclaró el misterio: la clave privada no sólo se almacena en memoria una sola vez al iniciarse el proceso, sino que se almacena en memoria varias veces, al realizarse peticiones al servidor web. La lectura de su análisis es altamente recomendado. [10]

En todo caso, hay otros datos críticos que se pueden leer. Estos datos son las peticiones pasadas de otros usuarios que ya se hayan liberado. Llegados a este punto se puede obtener todo lo que se os pase por la cabeza; usuario y contraseñas, identificadores de sesión, peticiones, etc.

Para acabar…


Esperamos poder haber hecho un análisis un poco más a fondo de lo que se ha estilado en la red estos días. Aún faltan cosas interesantes por explicar como, por ejemplo, la explotación de clientes en vez de la explotación típica de servidores. Pero, eso, mejor lo dejamos para otra entrada.

¡Hasta la próxima!

Referencias

[1] http://www.libressl.org/
[2] http://www.linuxfoundation.org/news-media/announcements/2014/04/amazon-web-services-cisco-dell-facebook-fujitsu-google-ibm-intel
[3] http://www.linuxfoundation.org/programs/core-infrastructure-initiative
[4] https://www.openssl.org/news/secadv_20140407.txt
[5] https://tools.ietf.org/html/rfc6520
[6] http://www.openssl.org/source/openssl-1.0.1e.tar.gz
[7] http://www.tedunangst.com/flak/post/analysis-of-openssl-freelist-reuse
[8] http://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2010-5298
[9] http://blog.cloudflare.com/the-results-of-the-cloudflare-challenge
[10] http://blog.cloudflare.com/searching-for-the-prime-suspect-how-heartbleed-leaked-private-keys

Autor: Albert López
Departamento de Auditoría.

2 comentarios:

Santi Balboa dijo...

Me gusta lo de "hundirse en el código y no creer a ciegas". Gran artículo Albert :)

Albert dijo...

Hola Santi!

Gracias por el comentario! Me alegra que lo hayas encontrado interesante.

Por cierto, después de mirar tu perfil... Somos del mismo pueblo! El mundo es un pañuelo :)

Saludos.

Publicar un comentario en la entrada