Bağlama Göre XSS: Beş Sink, Escaping ve CSP için Tarayıcıda Doğrulanmış Bir Lab
Beş XSS bağlamı olan küçük bir zafiyetli sunucu, gerçek tarayıcıda test edildi: hangi payload çalışır, innerHTML neden script etiketini yok sayar, bağlama duyarlı escaping neyi düzeltir ve nonce tabanlı CSP'nin durduramadığı tek durum.
Bağlama Göre XSS: Beş Sink, Escaping ve CSP için Tarayıcıda Doğrulanmış Bir Lab
"Reflected, stored, DOM-based" sana payload'ın nerede saklandığını söyler. Hangi payload'ın çalışacağını söylemez. Bu, bağlama bağlıdır: girdinin ulaştığı HTML, öznitelik ya da script yeri. Bu yazı, beş bağlamı olan tek bir küçük sunucu kuruyor ve her payload'ı headless Chromium'da çalıştırıyor. Bir payload, yalnızca tarayıcı gerçekten bir alert penceresi açtıysa "çalıştı" olarak işaretleniyor.
Çoğu XSS kopya kâğıdının yanlış yaptığı ya da atladığı üç şey lab'dan çıkıyor:
innerHTMLüzerinden eklenen<script>parse edilir ama asla çalışmaz. Event-handler payload'ları çalışır.- Escaping bağlamla eşleşmek zorunda. HTML escaping,
javascript:URL'sine hiçbir şey yapmaz. - Katı, nonce tabanlı bir CSP, hiç escaping olmadan 9 payload'ın 8'ini durdurdu. Kaçırdığı tek durum, sayfanın zaten güvendiği bir script bloğunun içine enjeksiyon.
Sadece kendi makinende çalıştır. 127.0.0.1'e bağlanır.
Lab
"""Deliberately vulnerable XSS lab. Binds to 127.0.0.1 only."""
import html, json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
MODE = sys.argv[1] if len(sys.argv) > 1 else "vuln"
SAFE = MODE == "safe"
CSP_ONLY = MODE == "csp" # vulnerable code + nonce-based CSP
def esc(s): # HTML body and quoted attributes
return html.escape(s, quote=True)
def js_str(s): # inside a <script> block
return json.dumps(s).replace("<", "\\u003c")
def safe_url(s): # href: only http(s) and relative URLs
return s if s.startswith(("http://", "https://", "/")) else "#"
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
u = urlparse(self.path); q = parse_qs(u.query)
v = lambda k: q.get(k, [""])[0]
if u.path == "/body":
out = f"<p>Results for: {esc(v('q')) if SAFE else v('q')}</p>"
elif u.path == "/attr":
out = f'<input name="q" value="{esc(v("q")) if SAFE else v("q")}">'
elif u.path == "/js":
out = (f"<script>var user = {js_str(v('name'))};</script>" if SAFE
else f"<script>var user = '{v('name')}';</script>")
elif u.path == "/link":
out = f'<a id="site" href="{esc(safe_url(v("url"))) if SAFE else v("url")}">website</a>'
elif u.path == "/dom":
sink = "textContent" if SAFE else "innerHTML"
out = ('<div id="greeting"></div><script>'
'const name = decodeURIComponent(location.hash.slice(1));'
f'document.getElementById("greeting").{sink} = "Hello " + name;</script>')
else:
self.send_response(404); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
if CSP_ONLY:
# The app's own script blocks carry the nonce; injected markup cannot know it.
out = out.replace("<script>", '<script nonce="r4nd0m">', 1) if u.path in ("/js", "/dom") else out
self.send_header("Content-Security-Policy", "script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'")
self.end_headers()
self.wfile.write(f"<!doctype html><html><body>{out}</body></html>".encode())
HTTPServer(("127.0.0.1", {"vuln": 8090, "safe": 8091, "csp": 8092}[MODE]), H).serve_forever()python3 xss_lab.py # zafiyetli :8090
python3 xss_lab.py safe # bağlama duyarlı escaping :8091
python3 xss_lab.py csp # zafiyetli + CSP :8092Nonce burada, lab okunur kalsın diye sabit bir string. Production'da rastgele ve her yanıt için farklı olmak zorunda.
Denetleyici
Bir payload yalnızca Chromium bir pencere açtıysa sayılır. HTML'i okuyup tahmin etmek, raporlara yanlış pozitiflerin böyle girdiği yoldur.
const { chromium } = require('playwright');
const cases = [
['/body', 'q', '<script>alert(1)</script>'],
['/body', 'q', '<img src=x onerror=alert(1)>'],
['/attr', 'q', '"><script>alert(1)</script>'],
['/attr', 'q', '" autofocus onfocus="alert(1)'],
['/js', 'name', "';alert(1);//"],
['/js', 'name', '</script><script>alert(1)</script>'],
['/link', 'url', 'javascript:alert(1)', 'click'],
['/dom', null, '<script>alert(1)</script>'],
['/dom', null, '<img src=x onerror=alert(1)>'],
];
(async () => {
const b = await chromium.launch();
for (const port of [8090, 8091, 8092]) {
for (const [path, param, payload, action] of cases) {
const p = await b.newPage();
let fired = false;
p.on('dialog', async d => { fired = true; await d.dismiss(); });
const url = param
? `http://127.0.0.1:${port}${path}?${param}=${encodeURIComponent(payload)}`
: `http://127.0.0.1:${port}${path}#${encodeURIComponent(payload)}`;
await p.goto(url); await p.waitForTimeout(400);
if (action === 'click') { await p.click('#site').catch(() => {}); await p.waitForTimeout(400); }
console.log(port, fired ? 'FIRES ' : 'blocked', path, payload);
await p.close();
}
}
await b.close();
})();Zafiyetli sunucuda sonuçlar
FIRES /body <script>alert(1)</script> | <p>Results for: <script>alert(1)</script></p>
FIRES /body <img src=x onerror=alert(1)> | <p>Results for: <img src="x" onerror="alert(1)"></p>
FIRES /attr "><script>alert(1)</script> | <input name="q" value=""><script>alert(1)</script>">
FIRES /attr " autofocus onfocus="alert(1) | <input name="q" value="" autofocus="" onfocus="alert(1)">
FIRES /js ';alert(1);// | <script>var user = '';alert(1);//';</script>
FIRES /js </script><script>alert(1)</script> | <script>var user = '</script><script>alert(1)</script>';
FIRES /link javascript:alert(1) | <a id="site" href="javascript:alert(1)">website</a>
blocked /dom <script>alert(1)</script> | <div id="greeting">Hello <script>alert(1)</script></div>
FIRES /dom <img src=x onerror=alert(1)> | <div id="greeting">Hello <img src="x" onerror="alert(1)"></div>Sağdaki sütun, payload geldikten sonraki DOM. Bağlam bağlam incele.
HTML gövdesi
Girdi etiketlerin arasına düşer. Element oluşturan her şey işe yarar. <script> bariz seçim. <img src=x onerror=...> daha kullanışlı olanı; çünkü aşağıdaki DOM durumunda da çalışır ve sadece script kelimesine bakan basit filtreleri atlatır.
Öznitelik değeri
Girdi value="..." içine düşer. Çıkmak için özniteliği kapatan karakter, burada ", gerekir. İki çıkış yolu var:
- Özniteliği ve etiketi kapatıp yeni markup yaz (
"><script>...). - Etiketin içinde kalıp öznitelik ekle (
" autofocus onfocus="...). Bu,<ve>'den tamamen kaçınır; bir filtre açılı parantezleri silip tırnakları unuttuğunda önemli olan budur.
Öznitelik tırnaksızsa (value=INPUT), bir boşluk çıkmaya yeter. Payload seçmeden önce özniteliğin nasıl tırnaklandığını hep kontrol et.
JavaScript string'i
Girdi bir script bloğunda '...' içine düşer. İki farklı çıkış var:
';alert(1);//string'i kapatıp bir ifade enjekte eder. Bu, JavaScript seviyesinde bir çıkış.</script><script>...JavaScript'i tamamen yok sayar. HTML parser'ı, string'in içinde bile olsa ilk</script>'te script bloğunu bitirir. Bu, HTML seviyesinde bir çıkış.
İkincisi, "JS string'lerinde tırnakları escape et"in neden yetmediğini gösterir. <'yi de etkisizleştirmen gerekir.
href içindeki URL
javascript:alert(1) içinde <, >, " ya da ' yok. HTML escaping onu değiştirmeden geçirir ve biri linke tıklayınca payload çalışır. URL sink'leri escaping değil, bir şema beyaz listesi ister.
DOM sink'i: script etiketi neden engellendi
innerHTML, <script>alert(1)</script> aldı ve etiket DOM'da görünüyor. Çalışmadı. HTML standardı, innerHTML üzerinden eklenen scriptlerin çalıştırılmadığını söyler. <img onerror> payload'ı tam olarak aynı sink'te çalıştı; çünkü event handler'ları bu kuraldan etkilenmez.
Bir DOM sink'ini test edip klasik script payload'ı "çalışmadığında" onu güvenli işaretleme. Bir event handler dene.
Düzeltme 1: bağlama duyarlı escaping
Güvenli mod her bağlama farklı bir kodlayıcı uygular:
| Bağlam | Lab'daki kodlayıcı | Neden |
|---|---|---|
| HTML gövdesi, tırnaklı öznitelik | html.escape(s, quote=True) | <, >, &, ", ' entity'ye döner |
| Script bloğundaki JS string'i | json.dumps(s) + < → < | geçerli JS string literali ve HTML parser </script> görmez |
| href | http://, https://, / beyaz listesi, sonra HTML-escape | javascript: ve data: engellenir |
| DOM | innerHTML yerine textContent | tarayıcı değeri markup değil, metin olarak işler |
blocked /body <script>alert(1)</script> | <p>Results for: <script>alert(1)</script></p>
blocked /body <img src=x onerror=alert(1)> | <p>Results for: <img src=x onerror=alert(1)></p>
blocked /attr "><script>alert(1)</script> | <input name="q" value=""><script>alert(1)</script>">
blocked /attr " autofocus onfocus="alert(1) | <input name="q" value="" autofocus onfocus="alert(1)">
blocked /js ';alert(1);// | <script>var user = "';alert(1);//";</script>
blocked /js </script><script>alert(1)</script> | <script>var user = "</script><script>alert(1)</script>";</script>
blocked /link javascript:alert(1) | <a id="site" href="#">website</a>
blocked /dom <script>alert(1)</script> | <div id="greeting">Hello <script>alert(1)</script></div>
blocked /dom <img src=x onerror=alert(1)> | <div id="greeting">Hello <img src=x onerror=alert(1)></div>Dokuzunun da önü kesildi ve her girdi hâlâ tam olarak yazıldığı gibi görünüyor. Escaping veriyi silmez, tarayıcının onu nasıl yorumladığını değiştirir.
Gerçek bir uygulamada, framework'ün varsayılan kodlamasını kullan (React JSX, Jinja2 autoescape, Razor, Go html/template) ve insanların bundan çıktığı yerleri denetle: dangerouslySetInnerHTML, |safe, v-html, [innerHTML], Html.Raw ve template.HTML.
Düzeltme 2: escaping olmadan tek başına CSP
Port 8092, zafiyetli kodu tek bir header'la çalıştırıyor:
Content-Security-Policy: script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'Sayfanın kendi script blokları nonce taşır. Enjekte edilen markup nonce'ı bilemez.
blocked /body <script>alert(1)</script>
blocked /body <img src=x onerror=alert(1)>
blocked /attr "><script>alert(1)</script>
blocked /attr " autofocus onfocus="alert(1)
FIRES /js ';alert(1);//
blocked /js </script><script>alert(1)</script>
blocked /link javascript:alert(1)
blocked /dom <script>alert(1)</script>
blocked /dom <img src=x onerror=alert(1)>Dokuzdan sekizinin önü kesildi: nonce'suz yeni script elementleri, inline event handler'ları ve javascript: URL'leri. Çalışan tek payload, JS bağlamındaki ';alert(1);//. O payload yeni bir script oluşturmaz. Sayfanın zaten güvendiğini işaretlediği bir scripti düzenler, bu yüzden tarayıcı onu nonce'la çalıştırır.
(DOM'u kendin incelersen, nonce özniteliği boş görünür. Tarayıcılar, enjekte edilen CSS ya da scriptlerin okuyamaması için değeri DOM'dan gizler.)
Ders, savunmaların sırası. Escaping asıl düzeltme. CSP, bir escaping hatası sızdığında hasarı sınırlayan güvenlik ağı ve güvenilir bir scriptin içine koyduğun veriyi koruyamaz. Kullanıcı verisini inline scriptlere yazma. data- özniteliğine ya da type="application/json" bloğuna koy ve oradan oku.
Test kontrol listesi
- Her yansıma için tam bağlamı yaz: gövde, tırnaklı ya da tırnaksız öznitelik, JS string'i, URL, CSS ya da bir DOM sink'i.
- O bağlam için payload seç. Aynı
<script>'i her yerde çalıştırma. - Çalıştığını HTML okuyarak değil, gerçek tarayıcıda doğrula.
- DOM sink'leri için, sink'i güvenli saymadan önce bir event-handler payload'ı dene.
- CSP header'ını oku;
unsafe-inline,unsafe-eval, joker host ya da eksikbase-uriara. - Stored XSS için, değerin sonradan render edildiği her yeri kontrol et: admin panelleri, dışa aktarımlar, e-postalar, bildirimler.
- Raporda zafiyetli bağlamı, en küçük payload'ı, tarayıcı kanıtını ve düzelten kodlayıcıyı göster.
Aynı mantığın veritabanı tarafı için NoSQL injection serisi hedefini aynı şekilde kuruyor.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver