Composer Supply Chain Risk From Packagist Mutable Tags

php2shell patches (CVE-2026-109618 and CVE-2026-109619) for EOL PHP are out now. With sales replies slower than usual due to high demand, the fastest route to patched packages is to buy ELS for Runtimes online (opens in new tab). Read the patch FAQ

Go straight to an AI summary of this blog post instead

  • Summarize this post with Perplexity
  • Summarize this post with Gemini

Key Takeaways

  • Packagist indexes Git tags instead of hosting artifacts, so until July 2026 a published version could be quietly repointed at a different commit.
  • Two 2026 incidents ran that same trick, one registering a rogue Composer plugin and the other hijacking PHP’s autoloader.
  • A committed composer.lock pins the commit that was already resolved, so a later tag rewrite cannot change what composer install pulls.
  • Packagist now rejects retags of published stable versions, but old lockfiles and dev branches still need a look.

The other major registries all made the same call early. Once a version ships, its contents are frozen. npm tarballs are immutable. PyPI won’t let you re-upload a filename. crates.io allows yanking, not deleting. Maven Central won’t delete a published artifact at all.

Packagist made a different call. It doesn’t store packages. It reads a Git repository, indexes whatever tags it finds, and tells Composer where to fetch from. A version like 2.4.1 is a Git tag plus the commit reference Packagist recorded for it, and until July 2026 that tag could be deleted and re-pushed against a different commit, with Packagist quietly following along.

That gap is what the 2026 Composer supply chain attacks exploited, and PHP got two concrete examples three weeks apart.

Why Packagist Is an Index Rather Than a Registry

The architectural difference produces the security difference. When Composer resolves a dependency, Packagist supplies metadata: the package name, its versions, and where each version lives, including source and dist references that are normally a commit hash. The actual code comes from the source repository or from a distribution archive the code host generates on demand from a specific ref. Nothing in that chain is an immutable artifact that Packagist itself stores.

Registry Version identity Can a released version change
npm A published tarball No, versions are immutable
PyPI An uploaded file No, uploads cannot be replaced
crates.io A published archive No, yank only
Maven Central A published artifact No, deletion not permitted
Packagist A Git tag plus an indexed commit reference Not since July 2026, retags were followed before that

That last row is the whole story of PHP supply chain risk, and it isn’t a bug Packagist introduced by accident. Indexing Git keeps publishing friction unusually low, and that has long been one of Packagist’s practical advantages.

Reality Check: Calling this a Packagist vulnerability misses where the mutability actually lives. Git tags have always been movable by design. Packagist inherited that property by choosing to index repositories instead of hosting artifacts, and the same tradeoff is what makes publishing a Composer package comparatively lightweight.

What a Composer Supply Chain Attack Actually Looks Like

The same version tag v5.0.2 repointed from legitimate code to malicious code, with no changelog and no new release

On April 30, 2026, someone holding a compromised service account pushed a malicious commit to intercom/intercom-php and put the existing 5.0.2 tag on it, according to the GitHub advisory. The rewritten composer.json declared the package a Composer plugin, and once approved – either through an interactive prompt or a permissive allow-plugins setting – it ran during composer install as a dropper that fetched the Bun runtime and executed an obfuscated credential stealer. The bad tag was live for under two hours before maintainers pointed it back at clean code. Researchers named it Mini Shai-Hulud and tied it to the campaign that hit Intercom’s npm client in the same window.

Three weeks later the same weakness got used at a much larger scale. Starting at 22:32 UTC on May 22 and running into May 23, an attacker with organization-wide push access rewrote every existing tag across four Laravel-Lang localization packages, pointing them at commits in a fork they controlled, so nothing malicious ever landed in the official repositories. Socket counted more than 700 remapped historical versions, Aikido confirmed 233, and the main burst of rewriting took under 90 minutes. This time the payload hijacked PHP’s autoloader instead of registering a plugin.

Neither incident needed a new release to appear anywhere. No changelog entry, no new version number, nothing that a maintainer or a dependency bot watching version numbers would flag as different.

Compare that with how an attacker has to work on npm. Replacing a legitimate release there means shipping a different version number, because the bytes behind an existing package and version cannot be swapped out underneath you. A new version shows up in release feeds and trips dependency bots. Moving a tag produced none of that.

Attack step npm Packagist before July 2026
Publish malicious code New version number required Existing version could be repointed
New release event Yes No
Caught by version-diff tooling Yes Often missed, the version number never changed
Reverted by upstream Unpublish window applies Tag could simply be moved back

Reverting cut both ways. Recovery was quick – a maintainer could just move the tag back, which is exactly what happened with intercom-php that same night – but forensics got harder for the same reason, because a version number could point to a different commit than it used to. Packagist’s public transparency log recorded those reference changes, which at least leaves a durable record of when a published reference moved.

Two Different Payloads, One Mutable Tag

A moved Git tag reaching code execution through either a Composer plugin at install time or autoload.files on every request

Both 2026 incidents were Composer supply chain attacks in the strictest sense, and both started with the same trick. What they did next was completely different, and that distinction matters a lot when you decide what to actually fix.

The compromised tag on intercom/intercom-php pointed at a commit that changed composer.json to declare the package a Composer plugin. That’s the version of this attack most write-ups reach for, because it maps cleanly onto Composer’s plugin system. An allowed plugin runs during composer install and composer update, before any of your application code executes.

The Laravel-Lang compromise never touched plugin registration. It wired a malicious src/helpers.php into the autoload.files entry, so the payload ran on every PHP request from the moment the package was installed. Same moved tag, same trust Composer places in whatever a tag resolves to, two completely different execution paths. A fix for one does nothing for the other.

You can also explore core framework protection strategies in our detailed guide to Laravel security.

Composer Plugin Security Has a Real Gate

A moved tag delivers code. Something has to run it, and for the intercom-style attack that something is the plugin system. A package declaring "type": "composer-plugin" gets loaded and can execute code as part of Composer’s own install and update runs once it is allowed. That’s the intended behavior, since plugins exist to alter how Composer installs things, which means running before installation finishes.

Composer 2.2.0 added the allow-plugins config, and the default today permits nothing at all. In composer.json it looks like this.

{
  "config": {
    "allow-plugins": {
      "composer/package-versions-deprecated": true,
      "phpstan/extension-installer": true
    }
  }
}

With allow-plugins configured, Composer refuses to execute any plugin that isn’t on the list. An unlisted plugin prompts when Composer runs interactively, and in a non-interactive run Composer fails instead of quietly carrying on, unless the plugin declares itself optional. A failed build beats a silent install.

Watch Out: Check whether anyone set "allow-plugins": true just to make a prompt go away. That single line allowlists every plugin, present and future, and it disables the allowlist protection completely. Running composer config allow-plugins shows what a project actually has configured, and false is the value that denies everything.

For a build that needs no plugins at all, the flags are blunt and effective.

composer install --no-plugins --no-scripts --no-dev

If your production build doesn’t need plugins or Composer scripts, switching both off there while leaving them available on developer machines is a reasonable split.

Autoload Files Are the Quieter Path

allow-plugins does nothing for the Laravel-Lang-style attack. autoload.files isn’t an optional feature you can gate behind a permission prompt. It’s a common way PHP packages ship helper functions, and Composer has to trust every package’s autoload configuration for the ecosystem to work at all.

That’s what made this version of the attack land so hard. Composer asks whether you trust a package to run as a plugin. It never asks whether you trust a package to define autoloaded files. Before Composer 2.10’s malware policy and Packagist’s immutability change, the main protection here was never resolving the rewritten tag in the first place, which is what made the lockfile matter so much.

The One Thing Composer Gets Right That npm Does Not

Composer scripts declared by a dependency don’t run. Only the root package’s scripts execute. A malicious post-install-cmd sitting four levels deep in your tree does nothing at all. That’s a meaningfully better default than npm, where any package in the tree can ship a postinstall script that fires during install.

Mechanism Runs for dependencies
npm lifecycle scripts Yes, any package in the tree
Python .pth startup hooks Yes, at interpreter start
Rust build.rs Yes, during compilation
Composer scripts No, root package only
Composer plugins Yes, if allowed

Did You Know: Ignoring dependency scripts is a deliberate Composer design decision, not a hardening measure bolted on after an incident. It’s one of the more overlooked details in how the two ecosystems actually compare.

What Your Composer Lockfile Records and What It Misses

composer.lock is more protective than its reputation suggests, and the protection has a specific boundary, one that happens to cover both the plugin attack and the autoload attack because it operates a level below either one.

For each package, the lockfile records the resolved version along with source and dist references, normally the Git commit hash behind that release. Run composer install and Composer asks for that exact commit rather than whatever the tag points at today, so a later rewrite of the tag never enters the picture. The guarantee is about which commit gets requested, not a checksum on the bytes that come back, since code hosts generate those archives on demand and the lockfile carries no usable hash for them. Operations that re-resolve dependencies were the exposure point, and that includes partial updates like composer update foo -W. The Packagist security write-up makes the same point about lock files, which is worth knowing because a fair amount of Laravel-Lang coverage claimed lockfiles offered no protection at all.

Operation Keeps the locked reference Exposed to a moved tag
composer install with lockfile committed Yes No
composer update No, it re-resolves Yes
Partial update of one package Only for entries it leaves alone Yes, for anything it re-resolves
composer require newpackage Partly Yes, for the new requirement and any dependency it moves
Fresh clone with lockfile gitignored No Yes
composer install in a Docker build without the lock copied No Yes

A Dockerfile that copies only composer.json before running install has no lockfile to work from, and the protection disappears without anyone touching a line of application code. Copying composer.lock alongside it brings the protection back.

git ls-files composer.lock

If that returns nothing, composer.lock isn’t tracked in this repository, so every clean build resolves dependencies afresh against whatever the registry metadata says at that moment.

Malware Screening and the July Immutability Change

The May incidents didn’t sit unanswered. Nils Adermann and Igor Benko published a security roadmap for Composer and Packagist on May 27, and Composer 2.10 shipped the next day.

One piece of that release matters here directly. Packagist had already been importing Aikido’s malware detection results since March, and 2.10 is what made that useful in practice. Flagged versions drop out of the resolution pool, so composer update, composer require and composer create-project cannot reach them, and composer install fails loudly if a flagged version is already sitting in your lockfile rather than pulling it in quietly. composer audit reports the same versions and fails by default.

In June, Private Packagist organizations also got plugin restriction controls, letting an org decide up front which packages may act as Composer plugins. With that switched on, a poisoned release that suddenly declares itself a plugin gets stripped out of update metadata and never reaches Composer’s resolver, though installs from an existing lockfile need Private Packagist’s separate download-blocking setting too.

The bigger structural fix landed on July 7, when Packagist rolled out immutable versions. Once a stable version is published, the Git reference behind it no longer changes. Delete, re-create or move the tag upstream and Packagist keeps the original reference, puts a retag blocked badge on the package page, and emails every maintainer.

Dev versions that track branches still move, since that is the point of a branch, and the rule covers the version to commit mapping rather than the archive bytes, because most zips are still generated on demand by the code host. Blocked reference changes, soft deletions and recoveries all land in the public transparency log.

None of that retroactively cleans a lockfile that captured a malicious commit before the protections shipped, which is exactly why composer.lock and allow-plugins still need checking on your own projects, whatever a scanner reports.

Verifying Packages Before a Tag Ever Resolves

SecureChain pipeline from source repository through rebuild, malware screening and a verified package into your application

In both attacks the dangerous reference entered the dependency graph at resolution time, long before a plugin loaded or an autoloader hijack fired. Composer 2.10 now adds a malware check at install time as well, though that check still depends on somebody having flagged the version first.

SecureChain for PHP gives teams a rebuilt-from-source, malware-screened package source instead of resolving straight through upstream VCS references. Every package is rebuilt in an isolated environment and screened before it is available to install, so what you pull is what was verified. Vulnerabilities get patched continuously under SLA, and Endless Lifecycle Support keeps that coverage going once a maintainer stops shipping updates. That matters most on abandoned packages, where the maintainer oversight that would catch an unexpected upstream change is already gone.

None of that replaces composer.lock, allow-plugins or composer audit. It closes the part of the problem that sits upstream of anything your own tooling can see. See how SecureChain for PHP works.

FAQ

What is a Composer supply chain attack?

An attack that compromises a PHP package before it ever reaches your project, usually through a compromised repository, a moved Git tag or a malicious Composer plugin, rather than through a vulnerability in code you wrote. It’s one specific version of the broader supply chain attack pattern that has been hitting open-source registries across every language.

Can a Packagist package version change after release?

It could, historically. Packagist indexed Git tags rather than storing immutable artifacts, so a tag could be deleted and re-pushed against a different commit without the version number changing. Since July 2026, Packagist rejects any attempt to change the source reference of an already-published stable version, a server-side protection that applies no matter which Composer version a developer runs. Dev versions tracking branches still move, and the change cannot make historical installs safe if they resolved a malicious commit earlier.

Do Composer dependency scripts run during install?

No. Only the root package’s scripts execute. A malicious post-install-cmd declared by a dependency is ignored, which is a stronger default than npm’s.

How do Composer plugins execute code?

A package with "type": "composer-plugin" can execute code as part of Composer’s own install and update runs, once it has been installed and permitted. Composer 2.2.0 and later require plugins to be explicitly allowlisted through the allow-plugins config.

Does composer.lock protect against a moved Git tag?

Yes, if the lockfile already holds the legitimate commit, because composer install uses that locked reference instead of resolving the tag again. It does not protect composer update, new composer require calls, or builds where the lockfile is absent.

What does allow-plugins set to true do?

It allowlists every Composer plugin, current and future, which removes the protection entirely. Replace it with an explicit map of the specific plugins your project needs.

Summary
Composer Supply Chain Risk From Packagist Mutable Tags
Article Name
Composer Supply Chain Risk From Packagist Mutable Tags
Description
A Composer supply chain attack doesn't need a typosquat. Here is how Packagist’s mutable Git tags enabled two 2026 incidents.
Author
Publisher Name
TuxCare
Publisher Logo

Join 4,500+ Linux & Open Source Professionals!

2x a month. No spam.

Table of Contents
Get the open-source security answers you need

Join 4,500+ Linux & Open Source Professionals!

2x a month. No spam.