·6m read time·1,025 words·

They Didn't Push a New Version. They Moved Yours.

On 22 May 2026 someone rewrote every git tag in four Laravel-Lang packages. Around 700 historical versions now resolve to malicious commits. Pinning a version is not the same as pinning a commit, and the difference just cost the PHP ecosystem its weekend.

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/lang started 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.json and src/helpers.php.
  • The composer.json change registers src/helpers.php in autoload.files. Composer's autoloader executes every file in autoload.files the moment vendor/autoload.php is loaded. Which every Laravel and Symfony app does, on every request.
  • src/helpers.php reaches out to flipboxstudio.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.

EcosystemResolves constraint viaMutable?
ComposerGit tagYes
npmRegistry version + dist tagDist tag is mutable. Versions are nominally immutable but the registry is the source of truth
Go modules (pre Go 1.16)Git tagYes
Go modules (with sumdb)Tag + sum databaseNo, if sumdb is on
PyPIVersion artefact uploadVersion is immutable but maintainers can yank and reupload as new versions
CargoRegistry versionImmutable

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.lock and 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-update is your friend.
  • In CI, install from lock. Never update on the build. If your pipeline runs composer update to 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.info before 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.