| Severity | Critical |
| File | src/Installer.php:40 |
| Impact | Arbitrary file write via symlink attack |
| CVSS | 7.8 (AV:L/AC:L/PR:L/UI:N/S:U:C/H:I/H:A:H) |
| Status | Open |
Problem
Installer::install() creates a temporary file using tempnam() then appends .tar.gz:
$tmpFile = tempnam(sys_get_temp_dir(), 'zvec_ffi_') . '.tar.gz';
This creates two paths:
/tmp/zvec_ffi_ABC123 (created atomically by tempnam(), permissions 0600)
/tmp/zvec_ffi_ABC123.tar.gz (NOT created atomically — doesn't exist yet)
An attacker with local filesystem access can predict the name (random, but tempnam() output is guessable) and place a symlink at the .tar.gz path between the tempnam() call and the file_put_contents() call. The download then writes the archive to the attacker-controlled target.
Additionally, the empty file created by tempnam() (/tmp/zvec_ffi_ABC123 without .tar.gz) is never cleaned up, leaking a file descriptor and disk space.
Affected Code
// src/Installer.php:40-49
$tmpFile = tempnam(sys_get_temp_dir(), 'zvec_ffi_') . '.tar.gz';
try {
self::download($url, $tmpFile); // Writes to symlink target
self::extract($tmpFile, $libDir);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile); // Removes symlink, not the real file
}
// NOTE: tempnam() empty file is NEVER cleaned up
}
Attack Scenario
- Attacker runs
lsof or inotify to detect when the PHP process creates temp files
- PHP calls
tempnam() → file /tmp/zvec_ffi_ABC123 created
- Attacker creates:
ln -s /etc/cron.d/backdoor /tmp/zvec_ffi_ABC123.tar.gz
- PHP downloads archive → writes to
/etc/cron.d/backdoor via symlink
- Cleanup
unlink() removes the symlink, not the payload
Solution
Use a secure temporary directory created with mkdir() and a cryptographically random name instead of tempnam():
// Replace src/Installer.php:40-49
$tmpDir = sys_get_temp_dir() . '/zvec_ffi_' . bin2hex(random_bytes(8));
if (!mkdir($tmpDir, 0700, true)) {
throw new RuntimeException("Failed to create temporary directory");
}
$tmpFile = $tmpDir . '/download.tar.gz';
try {
self::download($url, $tmpFile);
self::extract($tmpFile, $libDir);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
exec("rm -rf " . escapeshellarg($tmpDir));
}
Rationale
This approach (Option C from the original analysis) creates a dedicated directory with mkdir() under a random name. Key advantages:
- Defense-in-depth:
mkdir() with 0700 permissions creates a directory only the current user can access. An attacker cannot create symlinks inside a directory they cannot traverse.
- Atomic creation:
mkdir() either succeeds or fails — there is no gap between existence check and use.
- Single random name: Only one random value (
random_bytes(8) = 128 bits via bin2hex) is needed, vs tempnam() + .tar.gz which created two names.
- No stale files: The
finally block does rm -rf on the entire temp directory, eliminating the orphaned tempnam() file.
- No external dependencies: Uses only PHP built-in functions.
Option A (use tempnam() result raw) was rejected because it removes .tar.gz suffix which may confuse tar auto-detection. Option B (fopen(x)) was rejected because it still leaves the orphaned tempnam() file and doesn't provide directory-level isolation.
Implementation Steps
- In
src/Installer.php:40, replace $tmpFile = tempnam(...) with the mkdir() + random_bytes(8) + bin2hex() pattern shown above
- In the
finally block at line 46, add exec("rm -rf " . escapeshellarg($tmpDir)) after the file cleanup
- Remove the unused
tempnam() result — the empty file at /tmp/zvec_ffi_ABC123 will no longer be created
Verification
- Run
php vendor/bin/zvec-install v0.4.0 — should download and extract correctly
- Run
ls -la /tmp/zvec_* before and after — no left-over .tar.gz or temp files
- Verify the temp directory has permissions 0700:
ls -la /tmp/ | grep zvec_ffi_ → drwx------
Acceptance Criteria
| Severity | Critical |
| File |
src/Installer.php:40|| Impact | Arbitrary file write via symlink attack |
| CVSS | 7.8 (AV:L/AC:L/PR:L/UI:N/S:U:C/H:I/H:A:H) |
| Status | Open |
Problem
Installer::install()creates a temporary file usingtempnam()then appends.tar.gz:This creates two paths:
/tmp/zvec_ffi_ABC123(created atomically bytempnam(), permissions 0600)/tmp/zvec_ffi_ABC123.tar.gz(NOT created atomically — doesn't exist yet)An attacker with local filesystem access can predict the name (random, but
tempnam()output is guessable) and place a symlink at the.tar.gzpath between thetempnam()call and thefile_put_contents()call. The download then writes the archive to the attacker-controlled target.Additionally, the empty file created by
tempnam()(/tmp/zvec_ffi_ABC123without.tar.gz) is never cleaned up, leaking a file descriptor and disk space.Affected Code
Attack Scenario
lsoforinotifyto detect when the PHP process creates temp filestempnam()→ file/tmp/zvec_ffi_ABC123createdln -s /etc/cron.d/backdoor /tmp/zvec_ffi_ABC123.tar.gz/etc/cron.d/backdoorvia symlinkunlink()removes the symlink, not the payloadSolution
Use a secure temporary directory created with
mkdir()and a cryptographically random name instead oftempnam():Rationale
This approach (Option C from the original analysis) creates a dedicated directory with
mkdir()under a random name. Key advantages:mkdir()with 0700 permissions creates a directory only the current user can access. An attacker cannot create symlinks inside a directory they cannot traverse.mkdir()either succeeds or fails — there is no gap between existence check and use.random_bytes(8)= 128 bits viabin2hex) is needed, vstempnam()+.tar.gzwhich created two names.finallyblock doesrm -rfon the entire temp directory, eliminating the orphanedtempnam()file.Option A (use
tempnam()result raw) was rejected because it removes.tar.gzsuffix which may confusetarauto-detection. Option B (fopen(x)) was rejected because it still leaves the orphanedtempnam()file and doesn't provide directory-level isolation.Implementation Steps
src/Installer.php:40, replace$tmpFile = tempnam(...)with themkdir()+random_bytes(8)+bin2hex()pattern shown abovefinallyblock at line 46, addexec("rm -rf " . escapeshellarg($tmpDir))after the file cleanuptempnam()result — the empty file at/tmp/zvec_ffi_ABC123will no longer be createdVerification
php vendor/bin/zvec-install v0.4.0— should download and extract correctlyls -la /tmp/zvec_*before and after — no left-over.tar.gzor temp filesls -la /tmp/ | grep zvec_ffi_→drwx------Acceptance Criteria
tests/test_installer_tempdir.phpt— verifymkdir()withrandom_bytes(8)creates a directory with 0700 permissions; verify no leftover files in/tmp/after installation; verify symlink attack fails (pre-create symlink at predicted path, confirmmkdir()fails or writes to isolated directory)tests/test_installer_tempdir_cleanup.phpt— kill process mid-install, verify no orphaned temp files; run concurrent installations and verify each uses a unique temp directorysrc/Installer.phpPHPDoc oninstall()— document the secure temp directory strategy;SECURITY.md— document the TOCTOU/symlink mitigationCHANGELOG.mdentry under### Security— "Replace predictable tempnam() with cryptographically random temp directory to prevent symlink race"