解压陷阱:Zip Slip 路径穿越攻击与 ZIP Bomb 防御

· 约 7 分钟 📂 在线解压

解压代码”看着没问题”——直接调用库的 extractAll() 就行。但恶意构造的 ZIP 可以让你的代码写文件到任意位置解压成数 PB 数据填爆磁盘。Zip Slip 和 ZIP Bomb 是两类经典攻击,2018 年 Snyk 公司公开披露后影响 100+ 主流项目。这篇讲清攻击原理和工程级防御。

Zip Slip:路径穿越攻击

攻击原理

ZIP 文件内部存储每个文件的相对路径

正常 ZIP 结构:
├── readme.txt
├── docs/api.md
└── images/logo.png

恶意 ZIP 结构:
├── readme.txt
├── ../../../etc/passwd      ← 跳出解压目录
└── ..\\..\\Windows\\evil.exe   ← Windows 反斜杠

naive 解压代码

# ❌ 危险代码
import zipfile
with zipfile.ZipFile('user.zip') as z:
    z.extractall('/tmp/extract')

# 用户上传的 ZIP 内文件名为 ../../../etc/passwd
# 解压结果:写到 /etc/passwd

典型攻击目标

平台目标路径危害
Linux/etc/cron.d/持久化 RCE
Linux~/.ssh/authorized_keysSSH 后门
Linux/etc/passwd / /etc/shadow提权
WindowsStartup 文件夹持久化
WindowsDLL 劫持路径代码注入
Web网站目录上方webshell
Javalib 目录替换 jar

安全解压代码

Python

import os
import zipfile

def safe_extract(zip_path, extract_to):
    extract_to = os.path.abspath(extract_to)
    
    with zipfile.ZipFile(zip_path) as z:
        for member in z.namelist():
            # 解析最终目标的绝对路径
            target = os.path.abspath(os.path.join(extract_to, member))
            
            # 验证目标在解压目录内
            if not target.startswith(extract_to + os.sep):
                raise Exception(f"Zip Slip detected: {member}")
        
        # 验证通过后再解压
        z.extractall(extract_to)

Python 3.12+ 内置防御

import tarfile
with tarfile.open('file.tar') as tar:
    tar.extractall('extract_to', filter='data')  # 内置过滤器

Java

public static void safeExtract(File zipFile, File destDir) throws IOException {
    String destDirCanonical = destDir.getCanonicalPath();
    
    try (ZipFile zip = new ZipFile(zipFile)) {
        for (ZipEntry entry : Collections.list(zip.entries())) {
            File destFile = new File(destDir, entry.getName());
            String destFileCanonical = destFile.getCanonicalPath();
            
            if (!destFileCanonical.startsWith(destDirCanonical + File.separator)) {
                throw new IOException("Zip Slip: " + entry.getName());
            }
        }
        // 验证通过后再解压
    }
}

Node.js

const path = require('path');
const yauzl = require('yauzl');

function safeExtract(zipPath, extractDir) {
    const extractDirAbs = path.resolve(extractDir);
    
    yauzl.open(zipPath, (err, zipfile) => {
        zipfile.on('entry', (entry) => {
            const destPath = path.resolve(extractDir, entry.fileName);
            
            if (!destPath.startsWith(extractDirAbs + path.sep)) {
                throw new Error(`Zip Slip: ${entry.fileName}`);
            }
            
            // 安全提取
        });
    });
}

容易绕过的检查

❌ 仅检查 .. 不够

# 不安全:仅检查字面 ..
if '..' not in member:
    extract(member)

# 攻击者可绕过:
# - 用 Unicode 字符 `..` (仍然是 ..)
# - 用 URL 编码 %2e%2e
# - 用 Windows `\\` 或 `\\..\\`
# - 嵌套:`a/../../etc/passwd`

✅ 用 canonical path

# 安全:解析后用 absolute path 比较
target = os.path.abspath(os.path.join(extract_to, member))
if not target.startswith(extract_to + os.sep):
    raise Exception('Zip Slip')

os.path.abspath解析所有 .. 和符号链接,得到真实路径。

ZIP Bomb:压缩炸弹

攻击原理

DEFLATE 算法对全 0 / 重复数据有极高压缩比:

1 GB 全 0 文件 → DEFLATE 压缩 → ~1 MB
压缩比 1000:1

经典 42.zip

42 KB ZIP 文件

含 16 个嵌套 ZIP(每个 ~2.5 KB)
   ↓ 解压
16 个 4.3 GB ZIP(每个含 16 个 ZIP)
   ↓ 解压
256 个 4.3 GB ZIP
   ↓ ...嵌套 16 层
4.5 PB(4500 TB)

简单非嵌套 Bomb

1 个 ZIP 文件 100 KB

含 1 个文件,文件本身是全 0
   ↓ 解压
单文件 10 GB

攻击场景

场景后果
Web 上传 + 自动解压服务器磁盘填满
邮件杀毒扫描扫描器挂起
文档预览服务后端 OOM
Docker 镜像扫描CI/CD 卡住
自动化处理流水线DoS

防御策略

1. 限制解压后总大小

import zipfile

MAX_TOTAL_SIZE = 10 * 1024 * 1024 * 1024  # 10 GB

def safe_extract(zip_path):
    total = 0
    with zipfile.ZipFile(zip_path) as z:
        # 预检查总大小
        for info in z.infolist():
            total += info.file_size
            if total > MAX_TOTAL_SIZE:
                raise Exception(f"ZIP Bomb: total {total} > limit")
        
        # 通过则解压
        z.extractall()

2. 流式解压 + 实时检测

def streaming_extract(zip_path, extract_to, max_size):
    extracted = 0
    with zipfile.ZipFile(zip_path) as z:
        for member in z.namelist():
            with z.open(member) as src:
                target_path = os.path.join(extract_to, member)
                with open(target_path, 'wb') as dst:
                    while True:
                        chunk = src.read(8192)
                        if not chunk:
                            break
                        extracted += len(chunk)
                        if extracted > max_size:
                            raise Exception("ZIP Bomb during extraction")
                        dst.write(chunk)

3. 限制嵌套深度

def extract_with_depth_limit(zip_path, depth=0, max_depth=2):
    if depth > max_depth:
        raise Exception("Too many nested archives")
    
    with zipfile.ZipFile(zip_path) as z:
        z.extractall()
        # 不递归解压嵌套 ZIP

4. 限制解压时间

import signal

def timeout_handler(signum, frame):
    raise TimeoutError("Extraction timeout")

signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(30)  # 30 秒超时

try:
    z.extractall()
finally:
    signal.alarm(0)

5. 沙箱解压

# Docker 容器隔离
docker run --rm \
    --read-only \
    --memory=512m \
    --cpus=1 \
    --network=none \
    --tmpfs=/tmp:size=1G \
    -v /input.zip:/data/in.zip:ro \
    -v /output:/data/out \
    safe-extractor:latest

TAR 文件的特殊问题

TAR 除了路径穿越,还有:

符号链接攻击

恶意 TAR:
1. evil_link → /etc/passwd(符号链接)
2. evil_link.txt(普通文件,覆盖 evil_link 指向的文件)

解压后:/etc/passwd 被覆盖

防御:拒绝符号链接 / 硬链接:

import tarfile

def safe_extract_tar(tar_path, extract_to):
    with tarfile.open(tar_path) as tar:
        for member in tar.getmembers():
            # 拒绝特殊文件
            if member.isdev() or member.islnk() or member.issym():
                raise Exception(f"Unsafe: {member.name} ({member.type})")
            
            # 路径检查
            target = os.path.abspath(os.path.join(extract_to, member.name))
            if not target.startswith(extract_to + os.sep):
                raise Exception(f"Tar Slip: {member.name}")
        
        # Python 3.12+ 内置过滤器
        tar.extractall(extract_to, filter='data')

设备文件 / 权限提升

恶意 TAR:
- /dev/sda 设备文件
- root 拥有的 setuid 文件

防御

  • --no-same-owner:不恢复原始 owner
  • --no-same-permissions:不恢复原始权限
  • 拒绝设备文件

Web 应用的安全架构

┌─────────────────────────────────────────┐
│ 用户上传 ZIP                              │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 1. 前端校验                              │
│    - 文件类型(ZIP / TAR / etc)         │
│    - 大小限制(10 MB 上传)              │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 2. 上传到对象存储(不解压)               │
│    - S3 / OSS / R2                      │
│    - 与 Web 服务器隔离                   │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 3. 异步任务队列                          │
│    - SQS / RabbitMQ / Celery            │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 4. 沙箱解压                              │
│    - Docker 容器 / Firecracker          │
│    - 资源限制(CPU / RAM / 磁盘 / 时间)  │
│    - 只读 ZIP + 只读输出目录              │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 5. 内容验证                              │
│    - 文件类型白名单                      │
│    - 病毒扫描(ClamAV)                  │
│    - 业务规则                            │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ 6. 存储 / 处理 / 返回结果                 │
└─────────────────────────────────────────┘

实战示例:Web 上传 ZIP

# Flask + Celery 示例
from flask import Flask, request
from celery import Celery
import os
import zipfile

app = Flask(__name__)
celery = Celery('tasks', broker='redis://localhost')

MAX_ZIP_SIZE = 100 * 1024 * 1024  # 100 MB
MAX_EXTRACTED_SIZE = 1024 * 1024 * 1024  # 1 GB
MAX_FILE_COUNT = 10000

@app.route('/upload', methods=['POST'])
def upload():
    f = request.files['zip']
    
    # 大小检查
    f.seek(0, 2)
    size = f.tell()
    f.seek(0)
    
    if size > MAX_ZIP_SIZE:
        return 'File too large', 413
    
    # 保存到对象存储
    s3.upload_fileobj(f, 'uploads', f.filename)
    
    # 提交异步任务
    extract_task.delay(f.filename)
    return 'OK'


@celery.task
def extract_task(filename):
    """在隔离的 worker 进程中解压"""
    s3_path = f's3://uploads/{filename}'
    local_path = f'/tmp/extracted_{filename}'
    
    # 下载到本地临时
    s3.download_file('uploads', filename, '/tmp/in.zip')
    
    # 安全解压(沙箱内)
    safe_extract_in_container('/tmp/in.zip', local_path)
    
    # 处理结果
    process_extracted(local_path)
    
    # 清理
    cleanup(local_path)


def safe_extract_in_container(zip_path, extract_to):
    """在 Docker 容器中解压"""
    cmd = [
        'docker', 'run', '--rm',
        '--read-only',
        '--memory=512m',
        '--cpus=1',
        '--network=none',
        '--tmpfs=/tmp:size=200m',
        '-v', f'{zip_path}:/in.zip:ro',
        '-v', f'{extract_to}:/out',
        'safe-extractor:latest',
    ]
    subprocess.run(cmd, check=True, timeout=60)

历史受影响项目

2018 年 Snyk 披露 Zip Slip 影响 100+ 主流项目:

  • Apache Compress
  • Apache Maven
  • Spring(早期版本)
  • JetBrains IDEs
  • VLC
  • 多个 Java / Node.js 库

自检方法

  • 升级到最新版本
  • 用恶意 ZIP 测试(../../etc/passwd 等条目)
  • 静态分析工具扫描代码

实战清单

必做

  1. Path 验证用 canonical/abspath
  2. 限制总解压大小
  3. 限制嵌套深度
  4. TAR 拒绝符号 / 硬链接 / 设备文件
  5. Web 应用解压在沙箱中
  6. 资源限制(CPU / RAM / 时间)
  7. 升级到最新 ZIP 库

避免

  1. 直接 extractAll() 不验证
  2. 仅检查字面 ..
  3. 信任用户提供的 ZIP
  4. 在 Web 服务器进程解压
  5. 无限递归解压嵌套
  6. 无超时控制

解压安全的核心是默认不信任——所有用户提供的压缩文件都视为恶意,逐项验证路径、限制大小、隔离执行,能避免被一个恶意 ZIP 拿下整个服务器。

❓ 常见问题

Zip Slip 是什么攻击?为什么这么危险?

Zip Slip = 通过 ZIP 文件名中的 ../ 实现路径穿越,写文件到任意位置攻击原理:(1) ZIP 内部存储文件时记录"相对路径";(2) 恶意 ZIP 的文件名可以是 ../../../etc/passwd..\\..\\Windows\\System32\\evil.exe;(3) 解压代码 naively 拼接路径 → 跳出目标目录;(4) 写入系统关键位置或覆盖现有文件。典型危害:(1) Linux:写 /etc/passwd / /etc/cron.d/ / ~/.ssh/authorized_keys → 提权;(2) Windows:写 C:\\\\Users\\\\Public\\\\Startup / 注册表脚本 → 持久化;(3) Web 应用:写到上传目录上方的 webshell.php → RCE;(4) Java 应用:写到 lib 目录的 .jar → 替换组件。2018 年 Snyk 公司公开披露:(1) 影响 100+ 主流开源项目(Spring、Apache 库等);(2) "Zip Slip"成为安全社区标准术语。实务:(1) 任何处理用户上传 ZIP 的代码都可能被攻击;(2) 无论 ZIP / TAR / RAR / 7z 格式都可能;(3) 现代库多数已修复,但自己的代码必须验证

怎么写安全的解压代码防 Zip Slip?

核心:解压前验证目标路径在解压目录内Python 示例:``python\\nimport os\\nimport zipfile\\n\\ndef safe_extract(zip_path, extract_to):\\n extract_to = os.path.abspath(extract_to)\\n \\n with zipfile.ZipFile(zip_path) as z:\\n for member in z.namelist():\\n # 解析最终目标路径\\n target = os.path.abspath(os.path.join(extract_to, member))\\n \\n # 验证目标在解压目录内\\n if not target.startswith(extract_to + os.sep):\\n raise Exception(f\\"Zip Slip: {member}\\")\\n \\n z.extractall(extract_to)\\n`Java 示例`java\\nFile destFile = new File(destDir, entry.getName());\\nString destPath = destFile.getCanonicalPath();\\nString destDirPath = destDir.getCanonicalPath();\\nif (!destPath.startsWith(destDirPath + File.separator)) {\\n throw new IOException(\\"Zip Slip: \\" + entry.getName());\\n}\\n`关键点:(1) 用 canonicalPath / abspath 解析符号链接和 ..;(2) 比较时os.sep——避免 /path/to/extract vs /path/to/extracted(前缀错误匹配);(3) 不仅检查 ..——攻击者可用 \\\\ / Unicode / URL 编码绕过;(4) 用 canonical path 比较是最稳的。Node.js`javascript\\nconst path = require(\\"path\\");\\nconst destPath = path.resolve(extractDir, entry);\\nconst dirPath = path.resolve(extractDir);\\nif (!destPath.startsWith(dirPath + path.sep)) {\\n throw new Error(\\"Zip Slip\\");\\n}\\n``。

ZIP Bomb 是什么?怎么防范?

ZIP Bomb = 极小压缩文件解压成超大文件,攻击解压器内存 / 磁盘经典案例:(1) 42.zip:42 KB → 解压 4.5 PB(嵌套压缩 16 层);(2) 每层 16 个文件,每个文件压缩比 1000:1;(3) 总膨胀比 = 16^16 ≈ 1.8×10^19。为什么可能这么夸张:(1) DEFLATE 算法对全 0 / 重复数据极高压缩比;(2) 1 GB 全 0 文件压缩到 ≈1 MB;(3) 嵌套 ZIP → 指数膨胀。典型攻击场景:(1) 邮件附件 → 杀毒软件递归扫描挂掉;(2) Web 上传 → 服务器解压填满磁盘;(3) 文档预览服务 → 后端解压崩溃;(4) 自动化处理流水线 → DoS。防御:(1) 限制解压后总大小 —— 计算 zip.infolist() 的 size 总和,超过阈值拒绝;(2) 限制嵌套深度 —— 不递归解压嵌套压缩;(3) 流式解压 + 实时检测 —— 边解压边累计,超过阈值终止;(4) 限制单文件大小;(5) 沙箱解压 —— 容器 / 虚拟机里执行;(6) 限制解压时间Python 示例:``python\\nimport zipfile\\n\\nMAX_TOTAL = 10 * 1024 * 1024 * 1024 # 10 GB\\n\\ndef safe_extract(zip_path):\\n total = 0\\n with zipfile.ZipFile(zip_path) as z:\\n for info in z.infolist():\\n total += info.file_size\\n if total > MAX_TOTAL:\\n raise Exception(\\"ZIP Bomb\\")\\n z.extractall()\\n``。

嵌套压缩怎么处理?要不要递归解压?

默认不递归 + 用户明确同意风险:(1) 嵌套 ZIP 是 ZIP Bomb 的常见形式;(2) 自动递归解压 → 无限放大;(3) 每层 16 个 = 1 → 16 → 256 → 4096 → ...。典型防御:(1) 第一层解压——按用户预期;(2) 嵌套 ZIP——保留为文件,不自动解压;(3) 用户主动操作——点击进去再解压一次;(4) 限制嵌套深度——最多 2-3 层。实务:(1) 邮件附件 / 文件管理器默认解压一层;(2) 杀毒软件 / 安全扫描有限递归(如最多 5 层);(3) 永不无限递归;(4) Office 文件 / Java jar 等内部 ZIP 需要特殊处理(其实是正常使用,区分良性嵌套)。判断良性 vs 恶意嵌套:(1) 良性:内部 ZIP 文件大小合理(与外部相称);(2) 恶意:每层文件数 / 大小指数级增长;(3) 良性:内部 ZIP 是合法格式(如 Office docx);(4) 恶意:内部 ZIP 是另一个炸弹。

TAR 文件有相同问题吗?

有,且更复杂TAR Slip:(1) TAR 同样可以用 ../ 路径穿越;(2) 历史上多数 TAR 工具都有此漏洞;(3) GNU tar / BSD tar 已修复但老版本仍可能受影响TAR 特有问题:(1) 符号链接——TAR 可以包含符号链接("创建一个 link 指向 /etc/passwd");(2) 硬链接——把 /etc/passwd 链接到当前目录;(3) 设备文件 / FIFO——可能创建特殊文件类型。防御:(1) 解压前列出所有条目 —— 检查路径 + 类型;(2) 拒绝符号链接 / 硬链接 / 设备文件 —— 除非业务明确需要;(3) 使用 --no-same-owner——不恢复原始 owner(防权限提升);(4) 使用 --no-same-permissions——不恢复原始权限。Python tarfile:``python\\nimport tarfile\\n\\ndef safe_extract_tar(tar_path, extract_to):\\n with tarfile.open(tar_path) as tar:\\n for member in tar.getmembers():\\n if member.isdev() or member.islnk() or member.issym():\\n raise Exception(f\\"Unsafe member: {member.name}\\")\\n \\n target = os.path.abspath(os.path.join(extract_to, member.name))\\n if not target.startswith(extract_to + os.sep):\\n raise Exception(f\\"Tar Slip: {member.name}\\")\\n \\n # Python 3.12+ 内置防御\\n tar.extractall(extract_to, filter=\\"data\\")\\n``。

加密 ZIP 解压有什么特殊风险?

加密本身不增加攻击面,但密码处理有风险主要场景:(1) 用户提供密码解压;(2) 密码错误 → 失败;(3) 密码正确 → 解密 + 应用上述安全检查。特殊风险:(1) 密码尝试 DoS —— 攻击者发大量加密 ZIP,每次都要尝试密码 → 占用 CPU;(2) 弱加密 ZipCrypto —— 已知明文攻击,攻击者可破解;(3) AES-256 + 弱密码 —— 暴力破解仍可能;(4) 密码保存在内存 —— 内存泄露 / dump 风险。防御:(1) 限制解压频率 / 并发 —— 防 CPU DoS;(2) 强制 AES-256 —— 拒绝 ZipCrypto 加密 ZIP;(3) 密码强度检查 —— 弱密码警告;(4) 及时清理密码内存 —— 用完即清;(5) 审计日志 —— 谁何时解压什么。实务:(1) Web 应用接收加密 ZIP —— 谨慎;(2) 优先内部加密通道(HTTPS)+ 文件不加密;(3) 解密 ZIP 用专门进程隔离。

我的 Web 应用接收用户上传 ZIP,应该用什么架构?

多层防御 + 沙箱推荐架构:(1) 前端校验 —— 文件类型、大小限制;(2) 上传到对象存储(不解压)—— S3 / OSS,与 Web 服务器隔离;(3) 异步任务队列——后端任务消费;(4) 沙箱解压——Docker 容器 / Firecracker 微 VM / chroot;(5) 资源限制 —— CPU / 内存 / 磁盘 / 时间;(6) 白名单验证 —— 只允许预期文件类型;(7) 病毒扫描 —— ClamAV / VirusTotal API;(8) 结果再上传 —— 解压后内容再次审查。Docker 容器解压示例:``bash\\ndocker run --rm \\\\\\n --read-only \\\\\\n --memory=512m \\\\\\n --cpus=1 \\\\\\n --network=none \\\\\\n -v /tmp/upload:/input:ro \\\\\\n -v /tmp/output:/output \\\\\\n safe-extractor:latest\\n``。实务:(1) 永远不要在 Web 服务器进程直接解压;(2) 隔离失败影响(容器挂了不影响主服务);(3) 解压失败立即告警 / 拉黑 IP / 提示用户。

已经踩过 Zip Slip 的项目有哪些?

著名案例2018 年 Snyk 披露:(1) 影响 100+ 主流开源项目;(2) Apache Compress、Spring 等都修复了。典型受影响项目:(1) Apache Maven —— 解压依赖时受影响;(2) JetBrains 多个 IDE —— 处理插件 ZIP;(3) VLC——处理皮肤 ZIP;(4) Spring Boot —— DevTools 远程功能;(5) Apache Tomcat —— Web 部署。Android 影响:(1) APK 安装 —— 历史 Android 版本受影响;(2) 应用沙箱 —— 部分应用解压用户文件时;(3) Google Play 强制扫描 + 自动检测。修复方式(以 Spring 为例):(1) 升级 Spring Boot ≥ 2.0.4;(2) 检查所有自定义 ZIP 处理代码。自检方法:(1) 在测试环境用恶意 ZIP(含 ../../etc/passwd)测试;(2) 静态分析工具(SonarQube / Checkmarx)扫描;(3) 渗透测试。实务:(1) 不要假设"我的代码很安全";(2) 用最新版本的 ZIP 库;(3) 自己写解压代码 → 必须做 path 验证。

📂 打开 在线解压 ZIP/RAR/7z/TAR 解压·预览内容·选择性下载·密码支持·本地处理

📖 同一工具的其他教程