Exploiting CVE-2014-0160, also known as Heartbleed
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.
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
.
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.
The changelog for openssl_1.0.1e-2+deb7u14
shows that on April the 7th,
Heartbleed was fixed incrementing the release to deb7u5.
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
.
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
.
It is possible to revert to the old clean package by passing a specific version to apt.
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
.
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.
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:
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:
After restarting nginx, gdb can be attached to the worker process.
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:
gdb should also be pointed to the location of the source code with the directory
command.
A breakpoint on tls1_process_heartbeat
can be set and the execution resumed.
Now, upon receiving a heartbeat message, the code will hit the breakpoint, allowing step by step execution.
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
.
The buffer_write
function is defined in crypto/bio/bf_buf.c
.
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.
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
.
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.
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.
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.