解压代码”看着没问题”——直接调用库的 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_keys | SSH 后门 |
| Linux | /etc/passwd / /etc/shadow | 提权 |
| Windows | Startup 文件夹 | 持久化 |
| Windows | DLL 劫持路径 | 代码注入 |
| Web | 网站目录上方 | webshell |
| Java | lib 目录 | 替换 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等条目) - 静态分析工具扫描代码
实战清单
✅ 必做:
- Path 验证用 canonical/abspath
- 限制总解压大小
- 限制嵌套深度
- TAR 拒绝符号 / 硬链接 / 设备文件
- Web 应用解压在沙箱中
- 资源限制(CPU / RAM / 时间)
- 升级到最新 ZIP 库
❌ 避免:
- 直接 extractAll() 不验证
- 仅检查字面
.. - 信任用户提供的 ZIP
- 在 Web 服务器进程解压
- 无限递归解压嵌套
- 无超时控制
解压安全的核心是默认不信任——所有用户提供的压缩文件都视为恶意,逐项验证路径、限制大小、隔离执行,能避免被一个恶意 ZIP 拿下整个服务器。