Op 22 mei 2026, om 22:32 UTC, begon iemand met pushrechten op de Laravel-Lang-organisatie git-tags te herschrijven. Tegen middernacht waren er zo'n 700 herschreven, verdeeld over vier veelgebruikte Composer-pakketten: laravel-lang/lang, laravel-lang/http-statuses, laravel-lang/attributes en laravel-lang/actions. lang had er in z'n eentje al 502. Allemaal wijzen ze nu naar kwaadaardige commits.
Heb je vrijdagavond composer update gedraaid, dan heb je malware gedraaid.
Maar dat is niet wat zorgen baart. Wat zorgen baart is wat de aanvaller juist niet deed.
Geen nieuwe versie. Geen SemVer-bump. Niets om aan te zetten. De aanvaller reikte terug in je dependency-graaf, vond v1.0.0 van drie jaar terug, en zorgde dat die iets anders ging betekenen.
Pinnen is goed advies
Al een half decennium luidt de doctrine: pin je dependencies.
Het is goed advies. Het hield left-pad tegen. Het hield event-stream tegen. Het is de reden dat in elk onboarding-document staat "commit je lockfile." Het is de reden dat je CI faalt als package-lock.json afwijkt van package.json. Het is het fundament onder reproduceerbare builds.
In Eén op de vier schreef ik dat pinnen een van de weinige hygiëneregels is die we collectief goed doen. Dat vind ik nog steeds.
Ik kom je niet vertellen dat je moet stoppen met pinnen. Pinnen is de vloer, niet het plafond.
Ik kom je vertellen wat de meeste teams over het hoofd zagen: een versie-pin is geen pin. Het is een pointer.
De pin is een pointer
Dit is wat composer require laravel-lang/http-statuses:^3.4 daadwerkelijk doet.
Composer praat met Packagist. Packagist zegt: "v3.4.5 is de hoogst passende tag." Composer vraagt de source repo om de inhoud op refs/tags/v3.4.5. Git geeft de commit terug waar die tag op dat moment naar wijst. Composer hasht het resultaat, schrijft de hash weg in composer.lock, en je bent klaar.
Die laatste stap is het hele probleem.
v3.4.5 is geen ding. v3.4.5 is een label op een ding. Een git-tag is een muteerbare referentie. Heb je pushrechten op de repo, dan kun je een tag die drie jaar lang braaf naar dezelfde commit verwees, vanmiddag laten verwijzen naar een hele andere commit. Geen handtekening die alarm slaat. Geen checksum die klaagt. De tag betekent gewoon iets anders.
Dat is wat hier gebeurde. De aanvaller heeft SemVer niet gebroken. De aanvaller heeft het anker van SemVer gebroken.
De commit-SHA in je bestaande composer.lock is prima. Dat bestand kent de hash waarmee je eerder installeerde. Maar op het moment dat een verse install de constraint opnieuw oplost, of een nieuwe CI-runner from scratch bouwt, of een collega de repo kloont en composer install op een net iets ander platform draait, ga je terug naar het register, terug naar de tag, terug naar waar die tag op dat moment naar wijst.
Dat gat zit er al die tijd al in.
Wat er feitelijk gebeurde
De forensische details, via StepSecurity en Socket:
- Het herschrijfvenster voor
laravel-lang/langbegon om 22:32 UTC op 22 mei en duurde zo'n 15 minuten. Alle 502 tags op de schop in serie. - De andere drie repos volgden en waren rond 00:00 UTC op 23 mei klaar.
- Elke kwaadaardige commit raakt precies twee bestanden:
composer.jsonensrc/helpers.php. - De wijziging in
composer.jsonregistreertsrc/helpers.phponderautoload.files. Composers autoloader voert elk bestand inautoload.filesuit op het moment datvendor/autoload.phpwordt geladen. Wat elke Laravel- en Symfony-app op elke request doet. src/helpers.phpmaakt verbinding metflipboxstudio.info, dropt een PHP-loader en een ELF-binary in/tmp, schraapt environment-variabelen bij elkaar, stuurt die door, en wist zijn eigen sporen.- Auteur op elke besmette commit:
Your Name <you@example.com>. De standaard git-placeholder. Of de aanvaller had geen zin om die te zetten, of had wél zin en koos voor iets onzichtbaars.
StepSecurity heeft laravel-lang/http-statuses@v3.4.5 tot ontploffing gebracht in een geïsoleerde GitHub Actions-runner met Harden-Runner in audit-modus en de hele keten bevestigd. De andere drie pakketten hebben een identieke commit-structuur.
De payload mikt op CI-runners, want daar zitten de secrets. Je lokale dev-bak heeft waarschijnlijk een paar env-variabelen gelekt. Je CI heeft alles gelekt.
Groter dan Laravel-Lang
Het interessante aan deze aanval is dat er niets PHP-specifieks aan is.
| Ecosysteem | Lost constraint op via | Muteerbaar? |
|---|---|---|
| Composer | Git-tag | Ja |
| npm | Versie + dist-tag in register | Dist-tag is muteerbaar. Versies heten onveranderlijk, maar het register is de bron van waarheid |
| Go modules (vóór Go 1.16) | Git-tag | Ja |
| Go modules (met sumdb) | Tag + sum-database | Nee, mits sumdb aanstaat |
| PyPI | Versie-artefact-upload | Versie is onveranderlijk, maar maintainers kunnen yanken en heruploaden als nieuwe versie |
| Cargo | Registerversie | Onveranderlijk |
De Laravel-Lang-aanval slaagde omdat Composer erop vertrouwt dat de repo eerlijk is over wat v3.4.5 vandaag betekent. Dat is geen Composer-bug. Dat is het protocol. De enige ecosystemen die hier een echt antwoord op hebben, zijn de systemen die het versielabel hebben losgekoppeld van de bron van waarheid (Go's sumdb, Cargo's register-semantiek).
Verder is "pin je versie" een dekentje. De aanvaller hoeft geen nieuwe versie te publiceren als hij een oude kan herschrijven.
Wat je nu écht moet doen
- Commit
composer.locken herstel daaruit. Je bestaande lockfile bevat de hashes van de commits die je ooit installeerde. Zolang niets een re-resolve forceert, blijf je op die commits zitten.composer install --no-updateis je vriend. - In CI installeer je vanuit de lock. Daar draai je nooit een
updatetijdens de build. Draait je pipelinecomposer updateom de laatste patches binnen te halen, dan zijn de herschreven tags van de aanvaller de laatste patches. - Pin op commit-SHA voor de kritieke dependencies. Composer ondersteunt
"laravel-lang/lang": "dev-main#abc1234". Lelijk. De moeite waard voor de handvol pakketten waar je tijdens een incident écht op zou willen terugvallen. - Zet Harden-Runner, Socket of StepSecurity in CI. Audit-modus betrapt uitgaand verkeer naar typosquats zoals
flipboxstudio.infovoordat ze klaar zijn met exfiltreren. - Heb je in de afgelopen 72 uur iets van
laravel-lang/*aangeraakt, roteer alles. Ja, alles. CI-tokens, deploy keys, cloud-credentials, de hele rits. De payload heeft je environment uitgelezen, en je gaat niet onderhandelen met wat hij al heeft gezien. - Stop met "maar we hadden 'm gepind" als slotzin in een incident review.
We behandelden versies als bonnetjes
Het mentale model dat de meesten van ons hebben bij een versie-pin: ik heb dit ding gekozen, en nu heb ik garantie op wat ik krijg.
We behandelden versies als bonnetjes. Het zijn beloftes. En de persoon aan de andere kant van die belofte heeft net zijn credentials laten compromitteren.
De pin faalde niet. De pin verschoof.