适用场景
本文适用于允许用户上传 ZIP 的报表导入、主题包安装、批量图片处理和离线数据交换服务。压缩包来自外部时,风险不只是文件名中出现 ../:绝对路径、Windows 盘符、反斜杠、同名覆盖、Unix 符号链接以及极高压缩比,都可能让一次普通解压变成任意文件写入或磁盘耗尽。
目标是建立一个可验收的解压边界:先检查归档目录,再把内容流式写入新的隔离目录;限制成员数、单文件大小、总展开大小和压缩比;拒绝链接与路径歧义;任何成员失败时整批失败,不把半成品目录交给后续业务。
常见误区与现场现象
生产代码里经常能看到下面这种处理:
from zipfile import ZipFile
with ZipFile(upload_path) as archive:
archive.extractall(work_dir)
Python 的 zipfile 会对一部分绝对路径和 .. 做规范化,但官方文档仍明确要求:不要在未经检查的情况下解压不可信归档。更重要的是,路径规范化并不能解决资源耗尽、重复文件、链接语义和业务允许类型等问题。
典型故障或攻击迹象包括:
- 上传文件只有几 MB,解压目录却迅速占满磁盘;
- 归档成员名为
../../app/config.py、C:/Windows/...或..\\..\\config; - Linux 上制作的归档携带符号链接,后续处理程序沿链接读写了隔离目录外的文件;
- 归档包含数十万个零字节文件,磁盘容量没满但 inode、CPU 或目录遍历时间耗尽;
- 同一路径出现多次,后一个成员覆盖前一个成员,校验阶段看到的内容与业务实际读取的不一致;
- Windows 大小写不敏感文件系统上,
Report.csv与report.csv发生碰撞。
因此,安全检查不能只写一句 if ".." not in filename。字符串包含判断既会误伤 report..csv,也识别不了反斜杠、盘符和规范化后的碰撞。
先定义解压预算
每个上传入口都应根据真实业务定义限制,而不是复制一个“足够大”的全局值:
- 最大成员数:防止海量小文件消耗 inode 和遍历时间;
- 单个文件最大展开大小:阻止单成员撑爆磁盘;
- 全部文件最大展开大小:限制整批任务的最坏空间成本;
- 最大压缩比:尽早拒绝高度可疑的压缩炸弹;
- 允许的文件后缀与目录层级:减少后续解析器接触面;
- 隔离目录配额和任务超时:元数据可能伪造,应用层限制不能替代系统配额。
压缩比只是风险信号,不是恶意判定。大量重复文本本来就可能有很高压缩比;阈值需要结合业务样本调整。反过来,攻击者也能填充随机数据绕过压缩比,所以仍必须检查实际写出的字节数。
一个可复用的安全解压器
下面实现只依赖 Python 标准库,面向 Python 3.11 及以上版本。它有意不调用 extractall(),而是逐个成员校验并流式复制。
from __future__ import annotations
import shutil
import stat
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from zipfile import BadZipFile, ZipFile, ZipInfo
class UnsafeArchiveError(ValueError):
"""压缩包违反安全约束。"""
@dataclass(frozen=True, slots=True)
class ExtractLimits:
"""解压资源上限。"""
max_members: int = 1_000
max_file_bytes: int = 50 * 1024 * 1024
max_total_bytes: int = 200 * 1024 * 1024
max_compression_ratio: float = 200.0
@dataclass(frozen=True, slots=True)
class ExtractResult:
"""安全解压结果。"""
file_count: int
total_bytes: int
def _is_symlink(info: ZipInfo) -> bool:
"""识别由 Unix 创建的符号链接成员。"""
unix_mode = info.external_attr >> 16
return info.create_system == 3 and stat.S_ISLNK(unix_mode)
def _normalize_member_name(name: str) -> PurePosixPath:
"""把成员名收敛为无歧义的相对 POSIX 路径。"""
if not name or "\x00" in name:
raise UnsafeArchiveError("成员名为空或包含空字符")
if "\\" in name:
raise UnsafeArchiveError(f"成员名包含反斜杠: {name!r}")
posix_path = PurePosixPath(name)
windows_path = PureWindowsPath(name)
if posix_path.is_absolute() or windows_path.is_absolute() or windows_path.drive:
raise UnsafeArchiveError(f"成员名不能使用绝对路径或盘符: {name!r}")
if any(part in {"", ".", ".."} for part in posix_path.parts):
raise UnsafeArchiveError(f"成员名包含非法路径段: {name!r}")
return posix_path
def _check_declared_size(info: ZipInfo, limits: ExtractLimits) -> None:
"""根据中央目录元数据做快速拒绝。"""
if info.file_size < 0 or info.compress_size < 0:
raise UnsafeArchiveError(f"成员大小非法: {info.filename!r}")
if info.file_size > limits.max_file_bytes:
raise UnsafeArchiveError(f"成员展开后过大: {info.filename!r}")
if info.file_size == 0:
return
if info.compress_size == 0:
raise UnsafeArchiveError(f"非空成员的压缩大小为零: {info.filename!r}")
ratio = info.file_size / info.compress_size
if ratio > limits.max_compression_ratio:
raise UnsafeArchiveError(
f"成员压缩比过高: {info.filename!r}, ratio={ratio:.1f}"
)
def safe_extract_zip(
archive_path: Path,
destination: Path,
*,
limits: ExtractLimits = ExtractLimits(),
) -> ExtractResult:
"""将 ZIP 安全解压到一个预期为空的私有目录。"""
if min(
limits.max_members,
limits.max_file_bytes,
limits.max_total_bytes,
) <= 0:
raise ValueError("成员数和字节数上限必须大于零")
if limits.max_compression_ratio <= 0:
raise ValueError("压缩比上限必须大于零")
destination.mkdir(parents=True, exist_ok=False)
root = destination.resolve(strict=True)
seen_paths: set[str] = set()
total_declared = 0
total_written = 0
file_count = 0
try:
with ZipFile(archive_path, mode="r") as archive:
members = archive.infolist()
if len(members) > limits.max_members:
raise UnsafeArchiveError("压缩包成员数量超过限制")
for info in members:
relative_path = _normalize_member_name(info.filename)
collision_key = relative_path.as_posix().casefold().rstrip("/")
if collision_key in seen_paths:
raise UnsafeArchiveError(
f"成员路径重复或大小写冲突: {info.filename!r}"
)
seen_paths.add(collision_key)
if _is_symlink(info):
raise UnsafeArchiveError(
f"压缩包不允许包含符号链接: {info.filename!r}"
)
target = (root.joinpath(*relative_path.parts)).resolve(strict=False)
if not target.is_relative_to(root):
raise UnsafeArchiveError(
f"成员目标越过解压目录: {info.filename!r}"
)
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
_check_declared_size(info, limits)
total_declared += info.file_size
if total_declared > limits.max_total_bytes:
raise UnsafeArchiveError("压缩包声明的总展开大小超过限制")
target.parent.mkdir(parents=True, exist_ok=True)
written_for_file = 0
with archive.open(info, mode="r") as source, target.open("xb") as output:
while chunk := source.read(1024 * 1024):
written_for_file += len(chunk)
total_written += len(chunk)
if written_for_file > limits.max_file_bytes:
raise UnsafeArchiveError(
f"成员实际展开大小超过限制: {info.filename!r}"
)
if total_written > limits.max_total_bytes:
raise UnsafeArchiveError("实际总展开大小超过限制")
output.write(chunk)
file_count += 1
except (BadZipFile, OSError, RuntimeError) as exc:
shutil.rmtree(root, ignore_errors=True)
raise UnsafeArchiveError(
f"压缩包读取或写入失败: error_type={type(exc).__name__}"
) from exc
except Exception:
shutil.rmtree(root, ignore_errors=True)
raise
return ExtractResult(file_count=file_count, total_bytes=total_written)
几个关键点值得单独说明:
- ZIP 内部路径约定使用
/。直接拒绝反斜杠,可以避免同一名称在 Linux 与 Windows 上被解释成不同路径; - 同时用
PurePosixPath和PureWindowsPath判断绝对路径与盘符,不能只按当前服务器操作系统解释攻击者提供的名称; resolve(strict=False)消除..并解析已经存在的链接,再用is_relative_to()验证目标仍在根目录内;external_attr的高 16 位可携带 Unix 文件模式。这里拒绝符号链接,而不是尝试在目标机还原它;- 先按中央目录中的
file_size快速拒绝,再对实际流出的字节计数。元数据检查提高效率,实际计数才是最终边界; - 输出文件使用
xb创建,已存在文件会失败,避免静默覆盖;路径还用casefold()去重,结果在大小写敏感和不敏感文件系统上保持一致; - 任何异常都会删除本次目标目录。调用方应给每个任务创建新的随机私有目录,不要把多个请求解压到共享目录。
这段代码解决的是应用层边界,不是强隔离。若本地其他账号能在解压期间修改目标目录,路径检查与文件创建之间仍存在竞态条件。生产环境应让任务目录仅对服务账号可写,并在容器、独立卷或受配额文件系统中执行;对高风险上传,可在无网络、只读根文件系统和最小权限的沙箱进程中解压。
用测试固定攻击边界
下面的 pytest 用例全部在临时目录中运行,不依赖真实外部服务:
import stat
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
import pytest
from safe_zip import ExtractLimits, UnsafeArchiveError, safe_extract_zip
def _write_zip(path: Path, files: dict[str, bytes]) -> None:
"""创建测试归档。"""
with ZipFile(path, mode="w", compression=ZIP_DEFLATED) as archive:
for name, content in files.items():
archive.writestr(name, content)
def test_extracts_normal_files(tmp_path: Path) -> None:
"""正常归档应写入隔离目录并返回实际字节数。"""
archive_path = tmp_path / "normal.zip"
destination = tmp_path / "output"
_write_zip(archive_path, {"reports/summary.txt": "完成".encode("utf-8")})
result = safe_extract_zip(archive_path, destination)
assert (destination / "reports" / "summary.txt").read_text("utf-8") == "完成"
assert result.file_count == 1
assert result.total_bytes == len("完成".encode("utf-8"))
@pytest.mark.parametrize(
"member_name",
["../outside.txt", "/etc/cron.d/job", "C:/Windows/task.txt", "..\\outside.txt"],
)
def test_rejects_ambiguous_or_escaping_paths(
tmp_path: Path,
member_name: str,
) -> None:
"""路径穿越、绝对路径和跨平台歧义路径必须被拒绝。"""
archive_path = tmp_path / "traversal.zip"
_write_zip(archive_path, {member_name: b"blocked"})
with pytest.raises(UnsafeArchiveError):
safe_extract_zip(archive_path, tmp_path / "output")
assert not (tmp_path / "outside.txt").exists()
def test_rejects_unix_symlink(tmp_path: Path) -> None:
"""Unix 符号链接成员不得进入解压目录。"""
archive_path = tmp_path / "symlink.zip"
info = ZipInfo("latest")
info.create_system = 3
info.external_attr = (stat.S_IFLNK | 0o777) << 16
with ZipFile(archive_path, mode="w") as archive:
archive.writestr(info, "../../etc/passwd")
with pytest.raises(UnsafeArchiveError):
safe_extract_zip(archive_path, tmp_path / "output")
def test_rejects_excessive_compression_ratio(tmp_path: Path) -> None:
"""高度压缩的成员应在写盘前被快速拒绝。"""
archive_path = tmp_path / "bomb.zip"
_write_zip(archive_path, {"zeros.bin": b"0" * 1024 * 1024})
limits = ExtractLimits(max_compression_ratio=10.0)
with pytest.raises(UnsafeArchiveError):
safe_extract_zip(archive_path, tmp_path / "output", limits=limits)
项目内还应增加以下用例:成员数刚好等于和超过上限、单文件与总大小边界、重复文件名、仅大小写不同的文件名、损坏的中央目录、加密归档、磁盘写入失败,以及业务后缀允许列表。安全测试不仅要断言抛出异常,还要断言隔离目录已清理、目录外没有新文件。
接入上传服务时的完整流程
安全解压只是上传链路中的一步,推荐流程如下:
- 上传流先写入有字节数上限的临时文件,不把整个请求体读入内存;
- 计算服务端任务 ID,并为该任务创建不可预测、仅服务账号可写的目标目录;
- 校验 ZIP 结构和资源预算,安全解压到隔离目录;
- 对解压后的普通文件按业务格式重新校验,不信任扩展名和压缩包内的 MIME 声明;
- 图片、Office 文档或其他复杂格式交给低权限、无网络的解析进程;
- 全部验证通过后,以目录重命名等原子方式发布结果;失败则删除整批临时产物;
- 记录成员数、压缩大小、实际展开大小、耗时和拒绝原因,不记录文件内容或用户敏感数据。
日志字段可使用 archive_id、member_count、compressed_bytes、extracted_bytes、duration_ms、reject_reason。固定消息使用中文;不要记录原始文件内容、访问令牌、完整本地路径或未经清洗的成员名,以免日志注入和敏感信息泄露。
监控与上线验证
上线后至少观察以下指标:
- 上传 ZIP 的压缩大小与实际展开大小分布;
- 按
reject_reason分类的拒绝次数; - 解压任务耗时、超时数和并发数;
- 临时卷使用率、inode 使用率和任务目录清理失败数;
- 后续解析器的崩溃、内存峰值和异常文件类型。
发布前用一组固定攻击样本做回归:路径穿越、Windows 盘符、反斜杠、符号链接、重复成员、高压缩比、大量空文件和损坏 ZIP。再在受限容器或测试卷中验证磁盘配额和任务超时确实生效。只有应用限制、文件系统配额和进程隔离同时存在,才能把元数据欺骗、竞态和未知解析器漏洞的影响收敛在可控范围内。
总结
处理不可信 ZIP 的核心不是“把 ../ 替换掉”,而是把解压视为一次受预算约束的文件写入操作。路径必须采用跨平台规则解析并确认留在隔离根目录;链接、重复名称和覆盖行为应显式拒绝;大小既看声明值,也统计实际写入值;失败产物不能进入后续业务。
在这个应用边界之外,还要依靠私有临时目录、磁盘和 inode 配额、任务超时、低权限进程以及格式级校验。这样即使收到畸形归档或压缩炸弹,也能在文件进入正式存储前终止处理,并留下足够但不泄密的诊断证据。
Discussion
评论