On 22 May 2026, at 22:32 UTC, someone with push access to the Laravel-Lang GitHub organisation started rewriting git tags. By midnight they had done about 700 of them across four widely used Composer packages: laravel-lang/lang, laravel-lang/http-statuses, laravel-lang/attributes, and laravel-lang/actions. lang alone had 502 tags. All of them now point at malicious commits.
If you ran composer update on Friday night, you ran malware.
That's not the part that should worry you, though. The part that should worry you is what the attacker didn't do.
They didn't publish a new version. They didn't bump SemVer. They didn't make you opt into anything. They reached back into your dependency graph, found v1.0.0 from three years ago, and made it mean something different.
The case for pinning is real
For half a decade the gospel of supply chain hygiene has been: pin your dependencies.
It's good advice. It caught left-pad. It caught event-stream. It's why every onboarding doc says "commit your lock file."
It's why your CI fails when package-lock.json disagrees with package.json. It's the foundation of how we think about reproducible builds.
I said in One in Four that pinning is one of the few hygiene rules we collectively get right. I still think that.
I'm not here to tell you to stop pinning. Pinning is the floor, not the ceiling.
I'm here to tell you what most teams missed: a version pin is not a pin. It's a pointer.
The pin is a pointer
Here's what composer require laravel-lang/http-statuses:^3.4 actually does.
Composer talks to Packagist. Packagist tells it "v3.4.5 is the latest matching tag." Composer asks the source repo for the contents at refs/tags/v3.4.5. Git hands back whatever commit that tag currently points at. Composer hashes the result, writes the hash to composer.lock, and you're done.
That last hand-off is the whole problem.
v3.4.5 is not a thing. v3.4.5 is a label on a thing. A git tag is a mutable reference. If you have push access to the repo, you can take a tag that has been quietly serving the same commit for three years and make it serve a different commit this afternoon. No signature breaks. No checksum complains. The tag just means something else now.
This is what happened. The attacker didn't break SemVer. The attacker broke its anchor.
The commit SHA in your existing composer.lock is fine. That file still knows the hash you used to install. But the moment a fresh install resolves the constraint, or a new CI runner builds from scratch, or a teammate clones the repo and runs composer install against a slightly different platform, you're back to the registry, back to the tag, back to whatever the tag points at right now.
That gap has been there the whole time.
What actually happened
The forensic details, from StepSecurity and Socket:
- The rewrite window for
laravel-lang/langstarted at 22:32 UTC on 22 May and ran for about 15 minutes. All 502 tags rewritten in series. - The other three repos followed, finishing by 00:00 UTC on 23 May.
- Every malicious commit modifies exactly two files:
composer.jsonandsrc/helpers.php. - The
composer.jsonchange registerssrc/helpers.phpinautoload.files. Composer's autoloader executes every file inautoload.filesthe momentvendor/autoload.phpis loaded. Which every Laravel and Symfony app does, on every request. src/helpers.phpreaches out toflipboxstudio.info, drops a PHP loader and an ELF binary into/tmp, scrapes environment variables, exfiltrates them, and deletes its own artefacts.- Author identity on every poisoned commit:
Your Name <you@example.com>. The default git placeholder. Either the attacker didn't bother to set it, or they did bother and chose something invisible.
StepSecurity detonated laravel-lang/http-statuses@v3.4.5 in an isolated GitHub Actions runner with Harden-Runner in audit mode and confirmed the full chain. The other three packages share identical commit structure.
The payload targets CI runners specifically because that's where the secrets live. Your local dev box probably leaked a few env vars. Your CI leaked everything.
Bigger than Laravel-Lang
The interesting thing about this attack is that nothing about it is PHP-specific.
| Ecosystem | Resolves constraint via | Mutable? |
|---|---|---|
| Composer | Git tag | Yes |
| npm | Registry version + dist tag | Dist tag is mutable. Versions are nominally immutable but the registry is the source of truth |
| Go modules (pre Go 1.16) | Git tag | Yes |
| Go modules (with sumdb) | Tag + sum database | No, if sumdb is on |
| PyPI | Version artefact upload | Version is immutable but maintainers can yank and reupload as new versions |
| Cargo | Registry version | Immutable |
The Laravel-Lang attack worked because Composer trusts the repository to be honest about what v3.4.5 means today. That's not a Composer bug. That's the protocol. The only ecosystems with a real answer to this are the ones that decoupled the version label from the source of truth (Go's sumdb, Cargo's registry semantics).
Everywhere else, "pin your version" is a comfort blanket. The attacker doesn't need to publish a new version when they can rewrite an old one.
What to actually do
- Commit
composer.lockand restore from it. Your existing lock file has hashes for the commits you originally installed. As long as nothing forces a re-resolve, you stay on those commits.composer install --no-updateis your friend. - In CI, install from lock. Never
updateon the build. If your pipeline runscomposer updateto fetch the latest patches, the attacker's revised tags are the latest patches. - Pin to commit SHA for anything critical. Composer supports
"laravel-lang/lang": "dev-main#abc1234". Ugly. Worth it for the handful of dependencies you would actually rebuild from in an incident. - Add Harden-Runner, Socket, or StepSecurity to CI. Audit mode catches outbound traffic to typosquats like
flipboxstudio.infobefore they finish exfiltrating. - If you touched
laravel-lang/*in the last 72 hours, rotate everything. Yes, all of it. CI tokens, deploy keys, cloud creds, the lot. The payload read your environment, and you don't get to negotiate with what it already saw. - Stop using "but we pinned it" as a closing statement in incident reviews.
We've been treating versions as receipts
The mental model most of us carry for a version pin is: I picked this thing, and now I have a guarantee of what I get.
We've been treating versions as receipts. They're promises. And the person at the other end of the promise just had their credentials compromised.
The pin didn't fail. The pin moved.