PHP Zip

Creating and extracting archives with ZipArchive, and archive security.

Intermediate
Key PointsExtracting user-uploaded archives must guard against Zip Slip (path traversal).

Creating an archive

PHP
<?php
$zipPath = __DIR__ . "/demo.zip";
$zip = new ZipArchive();

if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
    exit("无法创建压缩包");
}

$zip->addFromString("a.txt", "内容 A\n");
$zip->addFromString("dir/b.txt", "内容 B\n");

$zip->addEmptyDir("empty");
$zip->close();

$check = new ZipArchive();
$check->open($zipPath);
echo "条目数: " . $check->numFiles . "\n";
$check->close();

echo filesize($zipPath) > 0 ? "已生成" : "生成失败", "\n";
Output
条目数: 3
已生成

Reading and extracting

PHP
<?php
$zipPath = __DIR__ . "/demo.zip";
$zip = new ZipArchive();
$zip->open($zipPath);

echo "条目数: " . $zip->numFiles . "\n";

for ($i = 0; $i < $zip->numFiles; $i++) {
    $st = $zip->statIndex($i);
    echo $st["name"] . " " . $st["size"] . "\n";
}

echo $zip->getFromName("a.txt");

$zip->close();
Output
条目数: 4
a.txt 7
dir/b.txt 7
empty/ 0
内容 A

Extract to a directory

PHP
<?php
$zipPath = __DIR__ . "/demo.zip";
$dest = __DIR__ . "/unzip";

$zip = new ZipArchive();
$zip->open($zipPath);
$ok = $zip->extractTo($dest);
$zip->close();

echo $ok ? "解压成功" : "解压失败", "\n";
echo is_file($dest . "/dir/b.txt") ? "目录结构保留" : "结构丢失", "\n";
Output
解压成功
目录结构保留

Zip Slip

Archive entries can be named ../../../etc/passwd. Older PHP extractTo may write outside the target — Zip Slip.

PHP
<?php
function safeExtract(string $zipPath, string $dest): int
{
    $zip = new ZipArchive();
    if ($zip->open($zipPath) !== true) {
        throw new RuntimeException("无法打开压缩包");
    }

    $destReal = realpath($dest) ?: $dest;
    if (!is_dir($destReal)) {
        mkdir($destReal, 0755, true);
    }

    $count = 0;
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = $zip->getNameIndex($i);

        if (str_contains($name, "..") || str_starts_with($name, "/")) {
            continue;
        }

        $target = $destReal . DIRECTORY_SEPARATOR . $name;
        $parent = realpath(dirname($target));

        if ($parent === false || !str_starts_with($parent, $destReal)) {
            continue;
        }

        $zip->extractTo($destReal, $name);
        $count++;
    }

    $zip->close();
    return $count;
}

echo "安全解压完成\n";
Output
安全解压完成
Checking for .. alone is not enough — absolute paths, symlinks and encoding bypass it. The reliable check is that the resolved path starts with the target directory.

Other notes

  • Cap extracted size —— zip bombs fill the disk instantly
  • Cap entry count —— hundreds of thousands of tiny files exhaust inodes
  • Never execute extracted files —— an uploaded .php is a webshell
  • **ZipArchive needs the zip extension** —— otherwise class not found
A classic zip bomb is 42KB expanding to petabytes. **Sum statIndex sizes before extracting and reject over a threshold** — checking afterwards is too late.

Friends & Partners