Acme Corp (naam gewijzigd). Werk van de oprichters, Q3 2026. Alle cijfers gemeten.
De servers waren schoon. De code niet.
Elders gestolen wachtwoorden, een endpoint dat nooit controleerde van wie een record was, en vijf root causes in de code en de webserver. Hoe we het aanpakten, eerst met alleen leesrechten, en wat we hebben gemeten.
Wat er gebeurde
Acme Corp draait een multi-tenant webplatform: een PHP-applicatie achter nginx en load balancers. Toen onze sessie begon, liepen er al drie dagen geautomatiseerde scans vanaf commerciële VPN- en hostingadressen.
Na die scans probeerde een aanvaller elders gestolen wachtwoorden uit op de inlogpagina van het platform: van de 908 pogingen slaagden er 374, op minstens 10 echte gebruikersaccounts. Een eigen account aanmaken lukte niet; een CAPTCHA hield de registratiepogingen tegen. Vanuit de overgenomen sessies haalde de aanvaller op grote schaal records van andere tenants op, via een endpoint dat nooit controleerde van wie een record was.
Hoe we werkten
We werkten in één Claude Code-sessie met één subagent, eerst met alleen leesrechten. Claude Code las de logs, de code en de servers; wij stelden de vragen en beoordeelden elk antwoord. Elke wijziging had een back-up en een weg terug.
De eerste 36 minuten
We vroegen de logs waar de diefstal vandaan kwam. Vanaf het eerste request kwam de signatuur van de aanval uitsluitend uit commerciële VPN- en hostingranges. Na 36 minuten werk blokkeerden we de adressen van de aanvaller in de applicatie en op de load balancers, en zetten we de lekkende endpoints tijdelijk dicht. Daarna stuurde de aanvaller nog ruim 94.000 requests; er kwam er niet één door.
We verdeelden alle adressen in de logs over vier groepen: klant, bot, VPN/datacenter en thuisaansluiting. De thuisaansluitingen controleerden we één voor één; elk daarvan hoorde bij een echte klant van het platform.
De servers waren schoon
We scanden alle 16 VM's van Acme Corp met alleen leesrechten: geen nieuwe gebruikers, geen webshell, geen onverwachte bestandswijzigingen of processen. Broncode, databasewachtwoorden en bestandssysteem waren niet uitgelekt. De inbraak zat in de applicatielaag.
Vijf root causes
We vroegen de code wat de aanvaller had binnengelaten. Het antwoord: vijf root causes.
- Geen controle op eigenaarschap. Het endpoint waarlangs de records weglekten, en tientallen andere, gaven een record terug zonder te controleren bij welke tenant het hoorde.
- Schijnbeveiliging. 50 endpoints haalden de tenant-ID uit de request body, zodat elke gebruiker de ID van een andere tenant kon meesturen.
- Een router-bypass. Interne module- en core-paden reageerden op directe aanroepen, zonder login.
- Geen rate limiting. Niets beperkte hoe vaak iemand kon proberen in te loggen of een verificatiecode in te voeren.
- Publiek bereikbare bestanden. Een phpinfo-pagina, directory listings en lock-bestanden van dependencies waren voor iedereen op te vragen.
Wat we aanpasten
De eerste vier hadden we binnen drie uur na het eerste bericht verholpen, in de code en in nginx.
- Eén ownership guard. Eén PHP-functie die een 403 teruggeeft zodra een sessie een record van een andere tenant benadert. Zonder sessie of zonder record laat hij het request door, zodat cronjobs en webhooks blijven werken. Na een scan van de hele codebase bouwden we hem in 229 bestanden in.
- Een 404 voor interne paden. nginx beantwoordt directe aanroepen van interne paden met een 404.
- Rate limiting. nginx begrenst login- en verificatiecode-POST's per IP-adres; GET-requests merken er niets van.
- Een live nginx-overstap. Voor de rate limiting was een module nodig, maar de draaiende nginx was zonder die module gebouwd. Met USR2 stapten we live over op de officiële package binary van dezelfde versie, en we lieten de oude master netjes leeglopen. Bij die overstap viel geen enkel request weg. We zetten de packages op hold, zodat een automatische update de overstap niet kon terugdraaien.
Elk gewijzigd PHP-bestand kwam door php -l, en elke nginx-wijziging door nginx -t vóór de reload. De vijfde root cause, de publiek bereikbare bestanden, zetten we op de takenlijst voor het eigen team van Acme Corp.
Hoe we het controleerden
- De unit test van de guard. 5 van 5 scenario's slagen: dezelfde tenant komt erdoor, een andere tenant krijgt een 403, en zonder sessie, zonder record of met een lege ID gaat het request door.
- In productie, na de patch. 219 legitieme requests kregen een 200: 0 false positives. De logtag van de guard bleef leeg voor echte gebruikers.
- De rate limiting. 10 snelle login-POST's: 7 keer een 200, daarna een 429.
- De blokkade. Van de ruim 94.000 requests die de aanvaller daarna nog stuurde, kwam er niet één door.
Wat Acme Corp van ons kreeg
Het team van Acme Corp kreeg een volledig rapport: de echte diffs, de tijdlijn, de herkomst van elk adres en een takenlijst. Het rapport wees ook het volgende spoor aan: een TLS-fingerprint.
- 36 minvan het begin van onze sessie tot de blokkade van de aanvaller
- 0 van 94.000+requests van de aanvaller die na de blokkade nog doorkwamen
- 16 van 16VM's schoon na een scan met alleen leesrechten: geen nieuwe gebruikers, geen webshell, geen onverwachte bestandswijzigingen
- 4 van 5root causes binnen drie uur verholpen, in de code en in nginx; de vijfde droegen we over aan het eigen team van Acme Corp
- 374 van 908inlogpogingen met gestolen wachtwoorden geslaagd, op minstens 10 echte gebruikersaccounts
- 50endpoints haalden de tenant-ID uit de request body
- 229bestanden achter één ownership guard
- 0 van 219legitieme requests na de patch door de guard tegengehouden
- 5 van 5unit-testscenario's van de guard geslaagd
- 0requests weggevallen tijdens de live nginx-overstap
In uw omgeving beginnen we net zo, met alleen leesrechten, en elke wijziging wacht op uw akkoord. De health check leest uw infrastructuur; voor code of een incident plant u een gesprek.