·6m leestijd·1,066 woorden·

Ze pushten geen nieuwe versie. Ze verschoven de jouwe.

Op 22 mei 2026 herschreef iemand elke git-tag in vier Laravel-Lang-pakketten. Zo'n 700 historische versies wijzen nu naar kwaadaardige commits. Een versie pinnen is iets anders dan een commit pinnen, en dat verschil heeft het PHP-ecosysteem zijn weekend gekost.

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/lang begon 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.json en src/helpers.php.
  • De wijziging in composer.json registreert src/helpers.php onder autoload.files. Composers autoloader voert elk bestand in autoload.files uit op het moment dat vendor/autoload.php wordt geladen. Wat elke Laravel- en Symfony-app op elke request doet.
  • src/helpers.php maakt verbinding met flipboxstudio.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.

EcosysteemLost constraint op viaMuteerbaar?
ComposerGit-tagJa
npmVersie + dist-tag in registerDist-tag is muteerbaar. Versies heten onveranderlijk, maar het register is de bron van waarheid
Go modules (vóór Go 1.16)Git-tagJa
Go modules (met sumdb)Tag + sum-databaseNee, mits sumdb aanstaat
PyPIVersie-artefact-uploadVersie is onveranderlijk, maar maintainers kunnen yanken en heruploaden als nieuwe versie
CargoRegisterversieOnveranderlijk

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.lock en 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-update is je vriend.
  • In CI installeer je vanuit de lock. Daar draai je nooit een update tijdens de build. Draait je pipeline composer update om 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.info voordat 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.