压缩包解压漏洞防御实战:Zip Slip 路径穿越防护

适用场景

压缩包解压漏洞(Zip Slip)指应用程序在解压归档文件时未校验条目路径,导致条目可写入目标目录之外的任意位置,进而覆盖配置文件、写入 WebShell 或覆盖 SSH 授权密钥。它常与文件上传功能组合成完整的远程代码执行链:上传 zip → 解压 → 路径穿越写入 webshell → 访问触发

以下功能模块必须做解压安全处理:

  • WordPress / 各类 CMS 的主题、插件在线安装(后台 zip 包上传后自动解压)
  • 网站”素材包 / 模板包”导入,用户自助上传压缩包
  • 备份恢复功能:管理员上传备份 zip,服务端解压覆盖站点目录
  • CI/CD 制品处理:流水线解压构建产物、第三方 SDK 包
  • 日志归档、批量数据导入等后台批处理任务

本文先说明三类攻击形态,再给出 Python、Java、命令行三条链路的可复制防御代码,并附带恶意压缩包构造方法与自动化验证断言。

前置条件

  • Python 3.8+(成品代码用到 os.path.realpathzipfile),Java 项目需 JDK 8+
  • 掌握待处理压缩包的格式:zip、tar、tar.gz 或 7z,不同格式的校验点不同
  • 可创建专用的低权限解压目录与运行用户
  • 测试环境具备 Python,用于构造恶意样例包做回归验证

原理说明

攻击形态一:相对路径穿越

zip 格式允许条目名中包含 ../。归档中一个名为 ../../var/www/html/shell.php 的条目,在解压时会被许多库直接按路径写入,最终落到站点根目录。tar 格式同理,条目名同样未经限制。

更隐蔽的变体是绝对路径条目,例如 /etc/cron.d/backdoor。部分实现遇到绝对路径时会忽略前导斜杠并按相对路径处理,另一些则直接写入绝对位置。

攻击形态二:符号链接条目

归档内先放一个符号链接条目指向 /etc/,再放一个位于该链接下的普通文件条目。解压时链接先被创建,后续文件写入会跟随链接逃逸到目标目录之外。这类攻击能绕过仅检查”条目名中是否含 ..“的简单过滤,因为条目名本身完全合法。

攻击形态三:解压炸弹与资源耗尽

解压炸弹通过极高压缩比耗尽磁盘。一个 10 MB 的嵌套 zip 可以膨胀到数十 GB,导致磁盘写满、服务不可用,并可能连带影响日志与数据库写入。判断指标是压缩比(解压后总字节 / 压缩包大小),正常业务包很少超过 20:1,而炸弹包可达 1000:1 以上。

另一种变体是”文件数量炸弹”:包内含数十万个小文件,耗尽 inode 而非容量,恢复难度更高。

防御的核心:规范化后的前缀校验

字符串过滤 .. 不可靠,原因是同一路径存在多种等价写法:../..%2f、连续斜杠 a//../、Windows 下的反斜杠 ..\、混合 ..\/。正确做法是先做路径规范化,再判断规范化结果是否仍位于目标目录前缀之内。这是唯一稳健的判定方式。

操作步骤

1. Python:安全解压 zip 的完整实现

import os, zipfile, pathlib

def safe_extract_zip(zip_path, dest_dir,
                     max_total_bytes=512 * 1024 * 1024,   # 解压后总量上限 512MB
                     max_files=20000,                     # 条目数量上限
                     max_ratio=100):                      # 压缩比上限
    dest = pathlib.Path(dest_dir).resolve()
    dest.mkdir(parents=True, exist_ok=True)

    with zipfile.ZipFile(zip_path) as zf:
        infos = zf.infolist()
        if len(infos) > max_files:
            raise ValueError(f"too many entries: {len(infos)}")

        compressed = sum(i.compress_size for i in infos) or 1
        total = sum(i.file_size for i in infos)
        if total > max_total_bytes:
            raise ValueError(f"uncompressed size exceeds limit: {total}")
        if total / compressed > max_ratio:
            raise ValueError(f"compression ratio too high: {total / compressed:.1f}")

        for info in infos:
            name = info.filename

            # 拒绝绝对路径与盘符、反斜杠写法
            if name.startswith(("/", "\\")) or ":" in name:
                raise ValueError(f"absolute path in archive: {name}")

            # 拒绝符号链接条目(zip 中由外部属性标识)
            mode = (info.external_attr >> 16) & 0o170000
            if mode == 0o120000:
                raise ValueError(f"symlink entry rejected: {name}")

            # 关键:规范化后做前缀校验
            target = (dest / name).resolve()
            if target != dest and dest not in target.parents:
                raise ValueError(f"path traversal detected: {name}")

            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
                continue

            target.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(info) as src, open(target, "wb") as out:
                while True:
                    chunk = src.read(65536)
                    if not chunk:
                        break
                    out.write(chunk)
    return dest

要点说明:

  • target != dest and dest not in target.parents 这行是全部安全性的落点。必须用 Path.resolve() 的结果比较,它会消解 .. 与符号链接,比较字符串则会被绕过。
  • 显式调用 zf.open() 逐块读写,而不是用 extractall()extractall() 在 Python 3.12 之前不做穿越校验。
  • Python 3.12+ 的 tarfile 提供了内置过滤,建议同时启用:
import tarfile

# Python 3.12+:使用内置数据过滤器,自动拒绝穿越、绝对路径与危险链接
with tarfile.open("pkg.tar.gz") as tf:
    tf.extractall(path="/srv/unpack", filter="data")

# 若无法确定运行版本,先探测再回退到等价的手工校验
import sys
if sys.version_info >= (3, 12):
    tf.extractall(path="/srv/unpack", filter="data")
else:
    raise RuntimeError("upgrade to Python 3.12+ or use manual validation")

2. Java:ZipEntry 规范化校验

import java.io.*;
import java.nio.file.*;
import java.util.zip.*;

public class SafeUnzip {
    public static void unzip(Path zipFile, Path destDir) throws IOException {
        Path dest = destDir.toAbsolutePath().normalize();
        Files.createDirectories(dest);

        try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(Files.newInputStream(zipFile)))) {
            ZipEntry entry;
            long total = 0;
            while ((entry = zis.getNextEntry()) != null) {
                String name = entry.getName();

                if (name.contains("\\") || name.startsWith("/") || name.contains(":")) {
                    throw new IOException("absolute or illegal path: " + name);
                }

                Path target = dest.resolve(name).normalize();
                // 核心断言:规范化后必须仍以解压根目录为前缀
                if (!target.startsWith(dest)) {
                    throw new IOException("zip slip detected: " + name);
                }

                if (entry.isDirectory()) {
                    Files.createDirectories(target);
                    continue;
                }

                total += entry.getSize();
                if (total > 512L * 1024 * 1024) {
                    throw new IOException("uncompressed size exceeds limit");
                }

                Files.createDirectories(target.getParent());
                Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
                zis.closeEntry();
            }
        }
    }
}

Path.startsWith() 基于路径段比较而非字符串前缀,因此 /srv/unpack-evil 不会被误判为位于 /srv/unpack 之内,这正是字符串 startsWith 的典型漏洞点。

3. 命令行解压场景的加固

# 1) 以低权限用户解压,且目标目录不位于 Web 根目录下
install -d -m 0750 -o unpacker -g unpacker /srv/unpack

# 2) tar 显式禁止危险行为
sudo -u unpacker tar \
  --no-same-owner \
  --no-same-permissions \
  --no-overwrite-dir \
  -xzf /tmp/pkg.tar.gz -C /srv/unpack

# 3) unzip:先列表审查条目名,确认无穿越后再解压
unzip -l /tmp/pkg.zip | awk '{print $4}' | grep -E '(^/|\.\.|\\|:)' && echo "SUSPICIOUS" || echo "clean"
unzip -q -o /tmp/pkg.zip -d /srv/unpack

# 4) 用 ulimit 兜底限制单文件写入大小(单位为 512 字节块,此处约 2GB)
ulimit -f 4194304
# 5) 使用 quota 或独立小容量分区限制解压目录的磁盘占用
# 6) 解压目录挂载 noexec,nosuid,nodev,即使写入脚本也无法执行
mount -o remount,noexec,nosuid,nodev /srv/unpack

noexec 挂载是最省力的兜底防线:即便攻击者成功穿越写入脚本文件,也无法直接执行,必须再借道其他解释器,攻击成本显著上升。

4. 隔离解压:把风险限制在容器内

docker run --rm \
  --network none \
  --read-only \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  --user 65534:65534 \
  --pids-limit 128 \
  --memory 512m \
  --tmpfs /work:rw,noexec,nosuid,size=256m \
  -v /tmp/pkg.zip:/in/pkg.zip:ro \
  -v /srv/unpack:/out:rw \
  alpine:3.20 sh -c 'mkdir -p /work/x && tar -xzf /in/pkg.zip -C /work/x --no-same-owner && cp -r /work/x/. /out/'

--network none 阻断解压过程中的外连,--read-only 配合 tmpfs 保证写入仅发生在受控目录,--pids-limit--memory 则限制资源耗尽型攻击。

配置验证

1. 构造恶意压缩包

python - <<'PY'
import zipfile, os
os.makedirs('evil', exist_ok=True)

# 一:相对路径穿越
with zipfile.ZipFile('evil/traversal.zip', 'w') as z:
    z.writestr('../../shell.php', '<?php system($_GET["c"]); ?>')

# 二:绝对路径
with zipfile.ZipFile('evil/absolute.zip', 'w') as z:
    z.writestr('/tmp/pwned.txt', 'pwned')

# 三:符号链接条目
with zipfile.ZipFile('evil/symlink.zip', 'w') as z:
    info = zipfile.ZipInfo('link')
    info.external_attr = (0o120777 << 16)   # 标记为 symlink
    z.writestr(info, '/etc/')
    z.writestr('link/cron.d/x', '* * * * * root id')

# 四:高压缩比炸弹(10MB 的 0 字节,压缩后仅数 KB)
with zipfile.ZipFile('evil/bomb.zip', 'w', zipfile.ZIP_DEFLATED) as z:
    z.writestr('zero.bin', b'\0' * (10 * 1024 * 1024))
print('samples ready')
PY
ls -lh evil/

2. 用断言验证防御生效

python - <<'PY'
import os, shutil
from safe_extract import safe_extract_zip   # 即第 1 步的实现

cases = ['evil/traversal.zip', 'evil/absolute.zip', 'evil/symlink.zip', 'evil/bomb.zip']
for c in cases:
    d = '/srv/unpack/_test'
    shutil.rmtree(d, ignore_errors=True)
    try:
        safe_extract_zip(c, d)
        print(f'[FAIL] {c} 未被拦截,存在安全缺陷')
    except ValueError as e:
        print(f'[PASS] {c} 已拦截 -> {e}')
PY

四个样例全部输出 [PASS] 才算验证通过。同时确认解压目录外层未出现 shell.phppwned.txt 等文件:

find / -maxdepth 3 -name 'shell.php' -newermt '-10 minutes' 2>/dev/null
ls -l /tmp/pwned.txt 2>/dev/null || echo "OK: no escaped file"

3. 端到端验证业务入口

# 用敏感文件作为上传目标,验证服务端返回错误而非 200
curl -s -o /dev/null -w "%{http_code}\n" \
  -F "file=@evil/traversal.zip" https://www.example.com/api/theme/upload
# 期望 400 / 422,且服务端日志出现 path traversal detected

常见问题

FAQ 1:只过滤文件名中的 .. 字符串,为什么仍然被绕过?

因为路径存在多种等价表达,且过滤点往往在解码之前。常见绕过方式包括:使用反斜杠 ..\..\shell.php(在 Windows 或部分库中会被当作分隔符);使用 URL 编码 ..%2f 后再被下游解码;使用连续斜杠 ....// 让一次替换操作产生新的 ../;以及在文件名中混入空字节截断。根本对策不是完善黑名单,而是改为规范化后的前缀校验,让所有等价写法在 resolve() 之后统一收敛为一个绝对路径,再判断它是否越界。

FAQ 2:unziptar 这些成熟命令行工具会自己拦截穿越吗?

行为不一致,不能依赖。tar 在 GNU 版本下会对含 .. 的成员名给出警告并跳过,但它不会拦截符号链接条目——攻击者可以用”先建链接、再写链接下文件”的组合绕过。unzip 对相对路径的处理同样依版本而异,部分版本会静默创建到上级目录。因此命令行解压的可靠做法是:先 unzip -ltar -tvf 列出全部条目并做正则筛查,再以低权限用户在 noexec 挂载的目录中解压,最后统一修正属主与权限。

FAQ 3:业务上确实需要解压到 Web 目录(如主题在线安装),怎么降低风险?

采用”两阶段落盘”:第一阶段在 /srv/unpack 这类非 Web 目录、以低权限用户完成安全解压,并通过前缀校验保证无逃逸;第二阶段只允许白名单文件类型(.php .css .js .png .json)从暂存目录同步到 Web 目录,同步过程同样逐文件校验规范化路径并覆盖固定前缀。同时限制解压后的单文件大小与总数量,对 .php 文件额外做一次危险函数扫描(如 evalsystembase64_decode 组合),命中即拒绝并告警。这样即使第一阶段校验存在遗漏,第二阶段仍有一道独立闸门。

总结

解压漏洞的本质是”归档条目名属于不可信输入,却被直接用于拼接文件系统路径”。防御要点:

  • 前缀校验而非字符串过滤resolve()normalize() 之后判断是否位于解压根目录之内。
  • 拒绝符号链接条目:仅校验条目名无法覆盖此类攻击,需读取归档元数据中的文件模式。
  • 资源上限:限制解压后总字节数、条目数量与压缩比,抵御解压炸弹。
  • 最小权限落盘:低权限用户 + noexec,nosuid,nodev 挂载 + 非 Web 目录,构成兜底防线。
  • 回归验证:把四类恶意样例做成自动化测试,纳入发布前检查项。

其中”规范化后前缀校验”是不可让步的一条,其余措施是纵深防御的补充。