
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.
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 |
|---|
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).
The repo ships a containerized lab that replicates the environment exactly: a PHP CLI runtime plus the two pinned Composer releases at the paths the PoC expects, fully network-isolated at runtime.
docker compose run --rm poc
This builds cve-2026-40176-lab:latest (downloading Composer 2.9.5 and 2.9.6 and verifying each --version during the build) and runs the differential test inside an unprivileged, egress-free container.
What the lab guarantees:
poc service runs on an internal bridge network (no host/internet egress), with cap_drop: ALL and no-new-privileges. The injection payload stays contained.Repin the versions (must stay in sync with the two constructor paths in the PoC) via build args:
docker compose build --build-arg COMPOSER_AFFECTED_VERSION=2.9.5 --build-arg COMPOSER_FIXED_VERSION=2.9.6
Optional — live Perforce server. A p4d service is available under the full-lab profile (docker compose --profile full-lab up). The marker-based proof does not need it; it exists for researchers who want a live p4:// endpoint. Note the PoC payload targets 127.0.0.1:1666, so routing through a separate p4d container requires pointing the PoC URL at host p4d.
Honest result: against the real, published Composer
2.9.5and2.9.6, the PoC does not currently fire, and the lab reportsINCONCLUSIVE / FAIL.
Running the affected Composer (2.9.5) against the PoC's malicious manifest throws, inside Composer, before any p4/shell command is built:
In PerforceDriver.php line 40:
[ErrorException]
Undefined array key "depot"
PerforceDriver::initialize() reads $this->repoConfig['depot'] first thing, but the PoC's repository entry supplies only type and url (no depot key). The driver aborts at that point, so the injected ;touch <marker> payload in the URL is never reached and no marker is created. Network isolation is not the cause — the same error occurs with full egress.
What this means:
INCONCLUSIVE outcome is a property of the PoC payload, not the environment.depot key (and realistically a live p4d endpoint under the full-lab profile). Refining the payload to that point is exploit development beyond "stand up the lab" and is intentionally left out of scope here.The "Expected Output" below is the PoC's intended/idealized result, retained for reference; it is not what the current payload produces against the real driver.
A successful demonstration looks roughly like this (paths and IDs will vary):
=== CVE-2026-40176 PoC started ===
- Composer 2.9.5 version: 2.9.5
- Composer 2.9.6 version: 2.9.6
Prepared temp dir: /tmp/cve20264176_5_20260610_142233
Written malicious composer.json to /tmp/cve20264176_5_20260610_142233
Running Composer in /tmp/cve20264176_5_20260610_142233…
- Parsed Composer version: 2.9.5
- Marker /tmp/cve20264176_5_.../poc_marker_5.txt created with expected ID.
- Output shows Perforce driver activity.
- Affected run exit code: 1
Prepared temp dir: /tmp/cve20264176_6_20260610_142233
Written malicious composer.json to /tmp/cve20264176_6_20260610_142233
Running Composer in /tmp/cve20264176_6_20260610_142233…
- Parsed Composer version: 2.9.6
✘ Marker file /tmp/cve20264176_6_.../poc_marker_6.txt not found.
- Output shows Perforce driver activity.
- Fixed run exit code: 1
=== CVE-2026-40176 PoC finished ===
=== TEST RESULT: PASS (affected succeeded, fixed failed) ===
A non-zero Composer exit code is normal — composer update ultimately fails to fetch the (fake) package. The proof is the marker file, not Composer's exit status.
| Result | Meaning |
|---|---|
| PASS (affected succeeded, fixed failed) | Confirmed: 2.9.5 executed the injected command, 2.9.6 did not. The vulnerability and its fix are both reproduced. |
| INCONCLUSIVE / FAIL | One or more checks didn't line up. Inspect the per-run ✓/✗ lines: wrong binary path, version mismatch, the affected marker missing (environment/escaping difference), or the fixed run unexpectedly creating a marker. |
Common causes of an inconclusive result:
PerforceDriver.php:40 with Undefined array key "depot" — the repository config lacks a depot key, so Composer never reaches the p4 command-construction path. This is what happens with the PoC's current payload against the real 2.9.5/2.9.6 (see Reproduction Status).2.9.5 / 2.9.6.exec() is sandboxed/disabled.p4 (driver path not reached).CVE2026-40176/
├── CVE202640176Test.php # The PoC: CVE202640176Test class + entry point
├── composer.json # Base manifest the PoC reads/restores at runtime
├── Dockerfile # Lab image: PHP CLI + pinned Composer 2.9.5 & 2.9.6
├── docker-compose.yml # `poc` runner (+ optional `p4d` under full-lab profile)
├── .dockerignore # Trims the build context
├── README.md # This file
└── .gitignore # Excludes local agent/tooling state
The entire PoC is one file:
__construct() — stores the two binary paths, mints a timestamped run ID, snapshots the original composer.json.run() — orchestrates preflight, the affected run, the fixed run, restoration, and the verdict.prepareTempDir() — creates an isolated working directory per run.writeComposerJson() — builds the malicious manifest with the injected p4:// URL.runComposer() — executes composer update (arguments escaped with escapeshellarg()) and captures output + exit code.validateRun() — checks version, marker file, and Perforce-driver activity.preflightVersion() — reads --version from a given binary.composer.json is restored in a finally block regardless of outcome.escapeshellarg() (so the PoC doesn't accidentally inject into its own exec() calls). The vulnerability lives one layer deeper — in how Composer itself builds the p4 command — which is exactly what the payload targets.touch/echo into a unique temp marker, making the PoC safe to run repeatedly without side effects to the host.exec("cd … && php …") pattern and ;/&& payload assume a Unix-like shell; Windows is not supported as-is.mkdir race / permissions. Temp directories are created with mode 0777; tighten if running in a shared environment.Composer X.Y.Z from output; unusual Composer banners could defeat the match.This repository exists to understand and defend against CVE-2026-40176.
composer install/update on untrusted composer.json files (e.g., in CI pipelines that process third-party project source) without isolation.2.9.5 / 2.9.6 PHARs)2.9.6 changelog (consult your distribution's security tracker / the GitHub Advisory Database for the authoritative fix details)Author: Ikarolaborda · PoC dated 2026-06-10.
| 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) |
| 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. |