On the 5th of October 2024, my team and I attended the TU Delft CTF 2024. Our team won the 3rd price with a total of 5403 points. In total, 51 teams attended the CTF with each 4 members.
It was a fun CTF and organized well too. Props to the organization!

Write up Script Runner

Script Runner is a web challenge that was rated the hardest of the web challenges by the organizers. In total, 4 teams were able to solve this challenge.

Summary

Script Runner hosts a service to run scripts that you upload! Sounds easy right? The catch here is that it checks the hash of your uploaded file, stores it in a static folder and compares it to a bunch of secure hashes, that are stored in a local file. If your uploaded file/script doesn’t match any of the local hashes, it doesn’t execute it.

We are given the source code of the application to analyze any vulnerabilities in the upload and execute process. We find a Race Condition vulnerability between the instruction that uploads our file the instruction to check if the file matches the secure hashes.

We are able to exploit the vulnerability by sending a correct file first that will be stored locally. We have to wait a little bit for the check instruction to be completed and marks our file as safe. Then we overwrite that same file with our own payload, which will then execute the file with our own payload in it.

Understanding the application

In order to find any vulnerabilities, we have to first understand the application and its functionality. We are given the soure code with a Dockerfile. We start by running the application locally.

We can tell that it is a simple upload form. The application tells us that we are only allowed to run the three specified scripts (date.sh, fortune.sh, sus.py). Let’s run date.sh first.

1
2
#!/bin/sh
echo "The current date is: $(date)"


The output shows us the stdout of the executed shell command, good. Let’s try to inject our own command instead of “date”. We will do this by intercepting the request using BurpSuite. We modify the date command to execute id instead.

Then, we forward the request. The result is that we are not allowed to run the script:

Source code analysis

To identify vulnerabilities, we can try all such of things from a blackbox perspective, but if you have any source code at your disposal, it is always a good idea to analyze and understand it. Let’s read the app.py first. The only functionality seems to be the /upload route of the Flask application:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route('/upload', methods=["POST"])
def upload():

# Get the uploaded file from the upload POST request
uploaded_file = request.files['file']
if uploaded_file.filename == '':
return render_template('index.html')

# Sanitize the file and store it
file_path = os.path.join('uploads', secure_filename(uploaded_file.filename))
uploaded_file.save(file_path)

# Calculate file hash and compare it with the utils.is_allowed function
file_hash = utils.get_secure_hash(file_path)
if not utils.is_allowed(file_hash):
return render_template('index.html', output="Script not allowed!")

# Execute script
output = utils.execute_script(file_path)

return render_template('index.html', output=output)

The application seems to calculate a hash of our uploaded file and then accepts/denies it based on the outcome of that hash. Let’s take a look at the is_allowed function of utils.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Calculate hash, accepts argument to filepath
def get_secure_hash(path):
with open(path, 'rb') as f:
content = f.read()
insecure_hash = hashlib.sha256(content).digest()
secure_hash = bcrypt.kdf(password=insecure_hash, salt='NaCl'.encode(), desired_key_bytes=32, rounds=256).hex()
return secure_hash

# Open hashes.txt, read lines, compare to argument hash
def is_allowed(h):
with open('hashes.txt', 'r') as f:
hashes = f.readlines()
return any(h == hsh.strip() for hsh in hashes)

The “insecure_hash” variable calculates a SHA256 hash of our input file. Theoretically, this should already be secure. But then a secure_hash variable is defined that makes it even more secure. There is noway that we can create a hash collision.

Then we should look for other options. What about overwriting the hashes.txt with our own hashes? Well if we remember, our input is being sanitized by this “secure_filename” function:

1
2
3
# Sanitize the file and store it
file_path = os.path.join('uploads', secure_filename(uploaded_file.filename))
uploaded_file.save(file_path)

This function is being imported from werkzeug utils.

1
from werkzeug.utils import secure_filename

This function is considered safe since it correctly removes any “/“ or other characters that lead to path traversal. So the option to overwrite hashes.txt seems to be off the table.

Identifying a Race Condition vulnerability

If we take a closer look at the order of the instructions that upload, check and execute our input script, we can see something is not logical.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Sanitize the file and store it
file_path = os.path.join('uploads', secure_filename(uploaded_file.filename))

# Why save it here before checking if its safe?
uploaded_file.save(file_path)

# Calculate file hash and compare it with the utils.is_allowed function
file_hash = utils.get_secure_hash(file_path)
if not utils.is_allowed(file_hash):
return render_template('index.html', output="Script not allowed!")

# Execute script
output = utils.execute_script(file_path)

The sanitize function saves our input locally before checking if it is secure. In theory, we can upload a safe file (the date.sh for example) and overwrite that same file. At the time that the script is being executed, we must overwrite it, but not before the is_allowed check function is being executed.

The code underneath explains how we can exploit this Race Condition vulnerability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Sanitize the file and store it
file_path = os.path.join('uploads', secure_filename(uploaded_file.filename))

# Our input is always saved with the same filename
uploaded_file.save(file_path)

# Calculate file hash and compare it with the utils.is_allowed function
# Wait for this function to be completed
file_hash = utils.get_secure_hash(file_path)
if not utils.is_allowed(file_hash):
return render_template('index.html', output="Script not allowed!")

# Quickly overwrite the date.sh file before it is being executed

# Execute script
output = utils.execute_script(file_path)

Exploiting the Race Condition vulnerability

Now that we have found the vulnerability, we need to exploit it successfully. In order to do so, we intercept the request of date.sh using Burp Suite and send it to the repeater. We leave the safe file untouched.

Next, in Burp Suite, we create a group of tabs called “Race Condition”:

This results in:

In Burp Repeater, clone the safe request with date.sh to tab 2. Modify the input to your own script, for example, let’s modify it to execute “id”:

1
2
3
4
5
6
7
8
-----------------------------32292399912690620422393530341
Content-Disposition: form-data; name="file"; filename="date.sh"
Content-Type: application/octet-stream

#!/bin/sh
echo "The current date is: $(id)"

-----------------------------32292399912690620422393530341--

In Burp Suite, right click on this modified request tab and duplicate it 10 times:

Finally, select “Send group (parallel)” instead of “Send”:

This will send our entire group of 12 tabs in parallel. Send the requests. The result is that all of the duplicated tabs result in the error code “Script not allowed!” But the request with “date” in it now executed our script:

We have a PoC. Now we can run it remotely and get the flag from /flag.txt