Skip to content

SEC-002: Symlink Race in Temp File Creation #71

@s2x

Description

@s2x

| 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

  1. Attacker runs lsof or inotify to detect when the PHP process creates temp files
  2. PHP calls tempnam() → file /tmp/zvec_ffi_ABC123 created
  3. Attacker creates: ln -s /etc/cron.d/backdoor /tmp/zvec_ffi_ABC123.tar.gz
  4. PHP downloads archive → writes to /etc/cron.d/backdoor via symlink
  5. 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

  1. In src/Installer.php:40, replace $tmpFile = tempnam(...) with the mkdir() + random_bytes(8) + bin2hex() pattern shown above
  2. In the finally block at line 46, add exec("rm -rf " . escapeshellarg($tmpDir)) after the file cleanup
  3. Remove the unused tempnam() result — the empty file at /tmp/zvec_ffi_ABC123 will no longer be created

Verification

  1. Run php vendor/bin/zvec-install v0.4.0 — should download and extract correctly
  2. Run ls -la /tmp/zvec_* before and after — no left-over .tar.gz or temp files
  3. Verify the temp directory has permissions 0700: ls -la /tmp/ | grep zvec_ffi_drwx------

Acceptance Criteria

  • Unit tests: tests/test_installer_tempdir.phpt — verify mkdir() with random_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, confirm mkdir() fails or writes to isolated directory)
  • Functional tests: 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 directory
  • Documentation: src/Installer.php PHPDoc on install() — document the secure temp directory strategy; SECURITY.md — document the TOCTOU/symlink mitigation
  • Changelog: CHANGELOG.md entry under ### Security — "Replace predictable tempnam() with cryptographically random temp directory to prevent symlink race"

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions