
Differential proof-of-concept for CVE-2026-40176, demonstrating OS command injection in Composer's Perforce driver via a malicious repository URL, with automated A/B testing against affected and fixed versions.
A self-contained, OOP-style PHP proof of concept that demonstrates and differentially verifies a command-injection vulnerability in Composer's Perforce repository driver.
The PoC runs the same malicious composer.json against two Composer binaries — an affected release (2.9.5) and a fixed release (2.9.6) — and proves the bug by observing a side effect (a marker file written by an injected shell command) that fires on the affected version but not on the fixed one.
⚠️ For authorized security research and defensive testing only. See Responsible Use.
| CVE | CVE-2026-40176 |
| Component | Composer — Perforce (perforce) repository/VCS driver |
| Class | OS command injection via attacker-controlled repository URL |
| Attack surface | A composer.json containing a crafted repositories entry of type: perforce |
| Affected | Composer 2.9.5 |
| Fixed | Composer 2.9.6 |
| Trigger | Resolving/updating dependencies (composer update) against the malicious manifest |
| Impact | Arbitrary command execution on the machine running Composer |
| PoC language | PHP (single file, no external dependencies) |
Composer can resolve packages from several version-control systems. For Perforce, the repository is identified by a p4:// URL that encodes the host, port and user/stream. When Composer's Perforce driver builds the underlying p4 command line, fields taken from the attacker-controlled URL are not sufficiently sanitized before being handed to a shell.
Because the manifest author fully controls the repository URL, an attacker who can get a victim to run composer update/composer install against a malicious composer.json (for example, a poisoned dependency, a hostile repository, or a CI job processing untrusted project files) can break out of the intended p4 invocation and execute arbitrary OS commands with the privileges of the Composer process.
This belongs to the same family as historical Composer VCS-driver argument-injection issues, where URL/branch/stream values flow into shell commands without escaping. Composer 2.9.6 hardens the Perforce driver so the injected payload no longer executes.
The authoritative description of the behavior demonstrated here is the PoC source itself (
CVE202640176Test.php); consult the official advisory and Composer changelog for the upstream fix details.
The PoC is a single class, CVE202640176Test, that performs a controlled A/B (differential) experiment:
--version on both the affected (2.9.5) and fixed (2.9.6) Composer binaries and aborts early if either cannot be invoked.composer.json whose repositories section contains a perforce entry with a malicious p4:// URL carrying an injected shell payload.composer update in that directory.finally block always restores the original composer.json in the project directory.PASS only when the affected run shows the side effect and the fixed run does not.For each run, validateRun() checks three things:
| Check | What it proves |
|---|---|
| Marker file exists & contains the run ID | The injected touch/echo payload actually executed — i.e. command injection succeeded. |
Composer output mentions p4 | The Perforce driver code path was reached (the payload was processed by the right component, not some unrelated step). |
| Parsed Composer version == expected | The correct binary (2.9.5 vs 2.9.6) was the one that ran. |
A run is "OK" only when all three pass. The overall test passes when the affected run is OK and the fixed run is not — the precise signature of a real vulnerability that was subsequently patched.
The malicious repository URL is built in writeComposerJson():
p4://127.0.0.1:1666:attacker_user;touch <marker> && echo '<runId>' > <marker>:client_test
Breaking it down:
p4://127.0.0.1:1666:attacker_user — a well-formed-looking Perforce URL (host, port 1666, user).;touch <marker> && echo '<runId>' > <marker> — the injected shell commands. The leading ; terminates the intended p4 command; touch creates the marker file, and echo '<runId>' > <marker> writes the unique run ID into it so the PoC can confirm the payload (and not some unrelated process) produced the file.:client_test — trailing text to keep the rest of the URL parsing plausible.On the affected driver the shell metacharacters are honored and the marker file is created. On the fixed driver the value is properly escaped/quoted, so the same string is treated as inert data and no marker appears.
Note: the PoC uses a unique, timestamped run ID and writes its marker inside an isolated temp directory, so the payload is benign and self-cleaning rather than destructive.
2.9.5 (affected)2.9.6 (fixed)exec() runs cd … && php …). Designed for Linux/macOS.composer.json in the project directory (it is read at startup, copied into each temp run, and restored afterward).You generally do not need a live Perforce server: the vulnerability is in how Composer builds the
p4command line, and the injected payload runs before/around any realp4connection. Composer may log a Perforce connection error — that is expected and does not affect the marker-file proof.
Clone / place the PoC in a working directory.
Provide a composer.json in the same directory as the PoC. A minimal one is enough:
{
"name": "research/cve-2026-40176-poc",
"description": "Base manifest for the CVE-2026-40176 differential PoC",
"require": {}
}
Obtain the two Composer binaries and place them where the PoC expects them (defaults shown):
/usr/local/bin/composer-2.9.5.phar # affected
/usr/local/bin/composer-2.9.6.phar # fixed
You can download specific Composer releases from the official archive, e.g.:
curl -Lo /usr/local/bin/composer-2.9.5.phar https://getcomposer.org/download/2.9.5/composer.phar
curl -Lo /usr/local/bin/composer-2.9.6.phar https://getcomposer.org/download/2.9.6/composer.phar
If your paths differ, edit the two constructor arguments at the bottom of
CVE202640176Test.php.
php CVE202640176Test.php
The harness runs both Composer versions in turn and prints a final verdict. The original composer.json is restored automatically even if a run fails (the work happens in throwaway temp directories).