Skip to content

PIE self-update accepts any historically-attested `pie.phar` (rollback gap)

Moderate
asgrim published GHSA-f67f-c344-cqqr 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 verifies a downloaded pie.phar by running gh attestation verify --owner=php <file> (gh path) or by walking the upstream Sigstore certificate chain and checking three Fulcio OID extensions (OpenSSL fallback). Neither check binds the attestation to the release tag the user asked for. Any historically-attested pie.phar from any php/pie build passes both verifiers, including releases known to contain other security bugs.

The attestation's DSSE payload contains predicate.buildDefinition.externalParameters.workflow.ref (e.g. refs/tags/1.3.9) and the certificate carries sourceRepositoryRef with the same value. PIE has the data on hand; it just doesn't compare it against what the user expected.

Practical exploitation requires an attacker who can deliver bytes from an older release through one of PIE's release-discovery paths: the hardcoded https://php.github.io/pie/pie-nightly.phar URL for the nightly channel, or the browser_download_url field in the GitHub releases API JSON for stable / preview. The CVSS reflects that prerequisite as AC:H. The bug PIE owns is "I won't notice when this happens"; the resulting compromise is "user is rolled back to a PIE release with known security bugs."

Details

What the verifiers check

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

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

gh attestation verify --owner=php <file> looks up attestations for the file's sha256 from the php GitHub org and asserts the attestation chains to Sigstore's trusted root. gh exposes --source-ref, --source-tag, --source-digest, --signer-workflow, and --cert-identity flags for tightening; PIE uses none of them.

The OpenSSL fallback path, src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php:22-26:

private const ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES = [
    FulcioSigstoreOidExtensions::ISSUER_V2 => 'https://token.actions.githubusercontent.com',
    FulcioSigstoreOidExtensions::SOURCE_REPOSITORY_URI => 'https://github.com/php/pie',
    FulcioSigstoreOidExtensions::SOURCE_REPOSITORY_OWNER_URI => 'https://github.com/php',
];

The upstream Fulcio extension class (vendor/thephpf/attestation/src/FulcioSigstoreOidExtensions.php) exposes only three OIDs: ISSUER_V2 (1.3.6.1.4.1.57264.1.8), SOURCE_REPOSITORY_URI (1.3.6.1.4.1.57264.1.12), and SOURCE_REPOSITORY_OWNER_URI (1.3.6.1.4.1.57264.1.16). The OID that would bind the ref, SOURCE_REPOSITORY_REF (1.3.6.1.4.1.57264.1.14), isn't defined, and the verifier has no reference-binding hook to pass it to.

So both paths assert "this artifact was attested by something in github.com/php/" and stop there.

Where the data exists but isn't checked

A pulled attestation for pie-1.3.9.phar carries, in its certificate's verification result:

sourceRepositoryURI:        https://github.com/php/pie
sourceRepositoryRef:        refs/tags/1.3.9
sourceRepositoryDigest:     9f845c2222ae326c462ca106d0be7df83f94c672
buildSignerURI:             https://github.com/php/pie/.github/workflows/build-phar.yml@refs/tags/1.3.9

And in its DSSE payload:

predicate.buildDefinition.externalParameters.workflow.ref       = "refs/tags/1.3.9"
predicate.buildDefinition.resolvedDependencies[0].uri           = "git+https://github.com/php/pie@refs/tags/1.3.9"

Three independent fields name the tag. PIE reads none of them.

Release-discovery paths the attacker can influence

SelfUpdateCommand::execute:123-156 resolves the URL to fetch:

if ($updateChannel === Channel::Nightly) {
    $latestRelease = new ReleaseMetadata(
        'nightly',
        'https://php.github.io/pie/pie-nightly.phar',
    );
} else {
    $latestRelease = $fetchLatestPieRelease->latestReleaseMetadata($updateChannel);
    // ...ReleaseIsNewer check against $latestRelease->tag...
}

Nightly channel. The URL is hardcoded. pie-nightly.phar is served from the php.github.io GitHub Pages site fed by the php/pie repo. Whoever can push to that Pages source can swap the asset. Any historical attested pie.phar (e.g., from a 1.3.9 release) passes attestation verification because the file name and the php owner are all that's enforced.

Stable / preview channel. The fetcher calls latestReleaseMetadata which queries api.github.com/repos/php/pie/releases, picks the newest entry, and uses its assets[].browser_download_url. The asset URL is editable per-release by anyone with write to php/pie. Re-pointing a recent release's pie.phar asset at the bytes of an older release is a one-click change in the GitHub UI. The ReleaseIsNewer::forChannel check runs against $latestRelease->tag, not against the digest, so as long as the JSON's tag_name is monotonic, the swap is invisible to PIE.

In both cases, the prerequisite is some form of write access to PIE's release-distribution surface (Pages source, release assets), or a transient compromise of that surface. Neither is in PIE's direct control. The bug under audit is that PIE doesn't make the surface failure-evident by checking the attestation's ref/tag against what the JSON declares.

Why ReleaseIsNewer doesn't help

src/SelfManage/Update/ReleaseIsNewer.php:30-87 enforces tag-level monotonicity: Semver::satisfies($newVersion, '> ' . $currentPieVersion). This is a check against the release JSON's claimed tag, not against the attestation's ref. If the JSON says tag_name=1.5.10 but the asset bytes were built from refs/tags/1.4.4, the semver check passes (1.5.10 > current) and the attestation passes (1.4.4 bytes have valid attestation under php/). Bytes get installed.

Why PIE-002 is amplified by this gap

If PIE-002 (TOCTOU on self-update) gets fixed in a 1.4.5 release, an attacker who can deliver 1.4.4 bytes through either release-discovery path re-exposes the user to PIE-002. Same shape for PIE-001 (sudo rm) and any future PIE CVE: as long as historical PHARs remain attested, this gap is the rollback wedge.

Variant: pie self-verify can't bind to a version either

SelfVerifyCommand::execute (src/Command/SelfVerifyCommand.php:50-70) walks the same code path with the same gap:

$latestRelease = new ReleaseMetadata(PieVersion::get(), 'blah');
$pharFilename  = BinaryFile::fromFileWithSha256Checksum(($this->fullPathToSelf)());
$verifyPiePhar = VerifyPieReleaseUsingAttestation::factory();

try {
    $verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
    // ...abort
}

$this->io->write(sprintf(
    '<info>%s You are running an authentic PIE version %s.</info>',
    Emoji::GREEN_CHECKMARK,
    $latestRelease->tag,
));

The output reads "You are running an authentic PIE version X" where X is PieVersion::get(), the version baked into the running PHAR. The verifier never compares X against the attestation's ref. So a pie.phar swapped on disk to a different attested release still passes self-verify; the command prints the swapped PHAR's self-claimed version and a green checkmark.

The misleading bit is the output text: it implies the command verified "version X" specifically. What it verified is "this artifact is in the set of all artifacts ever attested under the php/ org." That set includes every historical PIE release. self-verify is therefore not a useful tamper-detection tool against the threat model "an attacker swapped my pie.phar with a different php-attested pie.phar," which is the exact threat model PIE-003's main finding describes.

Honest behaviour for the current implementation would be to drop the version from the output. The right fix is to bind to an expected ref, which means asking the user (or api.github.com) what version they expected and asserting against that.

PoC

Three runs against real PIE releases, using PIE's actual gh invocation and the proposed fix:

mkdir -p /tmp/pie-poc-003 && cd /tmp/pie-poc-003

# Download a 2-month-old release and the latest.
gh release download 1.3.9 --repo php/pie --pattern 'pie.phar' --output pie-1.3.9.phar
gh release download 1.4.4 --repo php/pie --pattern 'pie.phar' --output pie-1.4.4.phar

Run 1. PIE's exact invocation against the old PHAR:

$ gh attestation verify --owner=php pie-1.3.9.phar
$ echo "exit=$?"
exit=0

The old, 2-month-stale PHAR passes. Same exit code as the latest PHAR.

Run 2. Same invocation but hardened with --source-ref (the proposed fix), asking for 1.4.4 while the bytes are from 1.3.9:

$ gh attestation verify --owner=php --source-ref refs/tags/1.4.4 pie-1.3.9.phar
Error: expected SourceRepositoryRef to be refs/tags/1.4.4, got refs/tags/1.3.9
$ echo "exit=$?"
exit=1

The downgrade is caught. The error message reads the attestation's bound ref directly.

Run 3. Sanity check that the hardened invocation doesn't false-positive against a matching PHAR:

$ gh attestation verify --owner=php --source-ref refs/tags/1.3.9 pie-1.3.9.phar
$ echo "exit=$?"
exit=0

$ gh attestation verify --owner=php --source-ref refs/tags/1.4.4 pie-1.4.4.phar
$ echo "exit=$?"
exit=0

Each genuine ref-vs-bytes pairing passes; the mismatch is the only thing that fails.

The OpenSSL fallback is equivalent on the source side: the upstream verifier (vendor/thephpf/attestation/src/Verification/VerifyAttestationWithOpenSsl.php) checks only the three OIDs defined in FulcioSigstoreOidExtensions.php, none of which name a ref. An OpenSSL-path PoC would require staging a local trusted-root and stubbing the attestation HTTP call, but the static-source claim is unambiguous: there is no code path that compares the cert's sourceRepositoryRef against the expected tag.

Verified on:

PIE PHP gh OS Outcome
commit 5c3fa89c5f63728f60fda04756b4044c01855ff7 (1.5.x) 8.4.21 (NTS) 2.92.0 Linux x86_64 (WSL2) gh path PoC reproduces against real php/pie attestations
1.4.4 release tag (verifier code identical) n/a n/a yes (by code inspection)

Impact

Vulnerability class: Insufficient verification of data authenticity (CWE-345); rollback / downgrade attack against a signed-update flow.

Capability granted: An attacker who can substitute the bytes served at https://php.github.io/pie/pie-nightly.phar (nightly channel) or the browser_download_url returned by api.github.com/repos/php/pie/releases (stable / preview channel) can deliver an older legitimately-attested pie.phar to any user running pie self-update. Such substitution requires write access to the Pages source or to a release's assets; both are within reach of a transient php/pie maintainer compromise, a GH-account hijack, a Pages-source repository compromise, or a CDN/cache poisoning event upstream of the user.

The rolled-back PIE is whatever historical release the attacker delivers, including any release with a known CVE. PIE-001 (sudo-elevated rm) and PIE-002 (TOCTOU self-update) both ship in 1.4.4 and earlier. Until the user installs a fully-patched version, both remain exploitable. The rollback is sticky: once an older PIE is installed, the user has to manually notice their version regressed and pull a newer release.

Affected versions: All PIE releases through 1.4.4 and the 1.5.x development branch up to commit 5c3fa89c5f63728f60fda04756b4044c01855ff7. The --owner=php-only invocation has been in place since the self-update verification path landed; no prior fix exists.

Affected configurations: Default. Triggered by pie self-update on any channel. Nightly is more exposed because the URL is fixed and Pages publish is a single workflow run; stable/preview requires release-asset substitution, which is auditable via GitHub but not enforced by PIE.

Affected users: Anyone running pie self-update against a PIE release distribution surface they don't fully trust to remain pinned to its original bytes. In practice this includes every PIE user, because the distribution surface is operated by php/pie maintainers and the bug fails open to "any historical attestation."

Required user action: Run pie self-update. The verification message reads as normal because the attestation verifies cleanly; nothing surfaces the ref mismatch.

Fix direction

Bind the verifier to the release the user asked for. Two changes, one per verifier path:

  1. gh path. Add --source-ref (or --source-tag once gh 2.x exposes that flag explicitly) to the invocation at src/SelfManage/Verify/GithubCliAttestationVerification.php:48:

    $verificationCommand = [
        $gh,
        self::GH_ATTESTATION_COMMAND,
        'verify',
        '--owner=php',
        '--source-ref', 'refs/tags/' . $releaseMetadata->tag,
        $pharFilename->filePath,
    ];

    gh attestation verify already extracts the bound ref and prints a precise error when it mismatches. Nightly needs a separate strategy because there's no "tag": bind it to refs/heads/<default-branch> instead, or to the workflow path via --signer-workflow=php/pie/.github/workflows/build-phar.yml.

  2. OpenSSL fallback. Add a fourth Fulcio OID (SOURCE_REPOSITORY_REF, 1.3.6.1.4.1.57264.1.14) to FulcioSigstoreOidExtensions.php, pass the expected refs/tags/<tag> through the existing $extensionsToVerify mechanism in FallbackVerificationUsingOpenSsl::verify. The DER-decoding code at assertCertificateExtensionClaims already handles arbitrary OID extensions, so the wiring is small. Alternatively, parse predicate.buildDefinition.resolvedDependencies[0].uri in the DSSE payload and assert it starts with git+https://github.com/php/pie@refs/tags/.

  3. self-verify command. Accept an optional version argument from the user and bind to it: pie self-verify 1.5.0 verifies the running PHAR is genuinely the 1.5.0 release. Drop in --source-ref refs/tags/$argument to the underlying gh invocation; reject the run if no argument is supplied, or fall back to "I'll fetch the latest stable ref from api.github.com and check against that." The current behaviour, accepting any attested PIE while printing whatever version the running PHAR self-claims, is worse than no command at all because the green checkmark gives users false confidence.

    Concretely at src/Command/SelfVerifyCommand.php:42:

    public function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...PHAR-build guard...
    
        $expected = $input->getArgument('expected-version');
        if ($expected === null) {
            $this->io->writeError('<error>self-verify requires an expected version: pie self-verify <version></error>');
            return Command::FAILURE;
        }
    
        $expectedRelease = new ReleaseMetadata($expected, 'n/a');
        $pharFilename    = BinaryFile::fromFileWithSha256Checksum(($this->fullPathToSelf)());
        $verifyPiePhar   = VerifyPieReleaseUsingAttestation::factory();
    
        // ...with the fix from change 1, the verifier now passes --source-ref through.
        $verifyPiePhar->verify($expectedRelease, $pharFilename, $this->io);
        // ...success message printed against $expected, not PieVersion::get().
    }

    The output text should reference $expected rather than the version baked into the running PHAR. Once that's done, a swapped older PHAR fails the verify step because the attestation's ref won't match the user-supplied expectation.

As independent hardening, persist the last-installed PIE digest in Settings (already used for the channel choice) and refuse to install a release whose digest matches any historical digest in the persistence file. This closes the rollback even if both verifier paths above are bypassed.

For the nightly URL specifically, consider rotating away from a fixed-name asset. A signed manifest published alongside pie-nightly.phar (e.g. pie-nightly.manifest.json with the expected digest and ref) lets PIE assert the bytes are what the publish workflow intended, even without a Sigstore round-trip.

Severity

Moderate

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
High
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

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

CVE ID

No known CVE

Weaknesses

No CWEs

Credits