Python利用zipfile读写压缩包的实战指南
作者:codedevin
处理用户上传的 zip、批量打包导出文件、解压第三方数据包——zipfile 是绕不开的标准库。但两个坑几乎人人踩过:一个是 read() 一把把整个大文件读进内存,几百 MB 的压缩项直接把服务干 OOM;另一个更危险,直接用 extractall 解压不可信的 zip,一个构造好的压缩包能把文件写到 /etc/ 或者你的项目源码目录里,这就是 Zip Slip 路径穿越漏洞。
这篇把 zipfile 的正确姿势讲清楚:怎么读、怎么写、怎么流式处理不爆内存,以及解压不可信压缩包时必须做的安全防护。
基本读写:先跑通再说
创建一个 zip,把几个文件写进去:
import zipfile
# 'w' 覆盖写;ZIP_DEFLATED 才会真正压缩,默认 ZIP_STORED 只打包不压缩
with zipfile.ZipFile("out.zip", "w", zipfile.ZIP_DEFLATED) as zf:
zf.write("report.pdf") # 按原路径存进去
zf.write("data/a.csv", "a.csv") # 第二个参数 arcname:改成压缩包内的路径
zf.writestr("note.txt", "直接写字符串,不用先落地文件") # 内存内容直接入包
compression 参数很多人忽略:不传的话默认 ZIP_STORED,只是把文件原样塞进去、体积一点不减。要压缩必须显式指定 ZIP_DEFLATED(通用)或 ZIP_LZMA(压得更狠但慢)。writestr 尤其好用,把内存里生成的内容(比如导出的 CSV 字符串)直接写进包,省去先写临时文件再读的麻烦。
读取和查看内容:
with zipfile.ZipFile("out.zip", "r") as zf:
print(zf.namelist()) # 包内所有条目名列表
for info in zf.infolist(): # 更详细:每个条目的元信息
print(info.filename, info.file_size, info.compress_size)
data = zf.read("note.txt") # 返回 bytes,整个条目一次读进内存
print(data.decode("utf-8"))
read() 简单,但注意它把整个条目一次性读进内存——对小文件无所谓,大文件就是隐患,下面细讲。
坑一:read()大文件爆内存,改用流式
假设包里有个 2GB 的日志文件,你要逐行处理:
# ❌ 2GB 条目一次读进内存,直接 OOM
with zipfile.ZipFile("logs.zip") as zf:
content = zf.read("huge.log") # 危险
for line in content.splitlines():
handle(line)
正确做法是用 zf.open() 拿到一个文件式对象,像普通文件一样流式读,内存里始终只有一小块:
import io
with zipfile.ZipFile("logs.zip") as zf:
# open 返回二进制流,不会一次性解压全部
with zf.open("huge.log") as raw:
# 包裹成 TextIOWrapper 就能按行迭代,编码自己指定
for line in io.TextIOWrapper(raw, encoding="utf-8"):
handle(line.rstrip("\n"))
zf.open() 返回的是一个边解压边读的流,配合 TextIOWrapper 就能按行迭代,内存占用和文件大小无关。同理,解压到磁盘时也别用 read() 再自己写,用 shutil.copyfileobj 分块拷贝:
import shutil
with zipfile.ZipFile("logs.zip") as zf:
with zf.open("huge.log") as src, open("huge.log", "wb") as dst:
# 分块拷贝,内存里只留一个缓冲区
shutil.copyfileobj(src, dst, length=1024 * 1024) # 1MB 一块
记住:只要条目可能很大,就用 zf.open() 流式处理,别碰 zf.read()。
坑二:Zip Slip 路径穿越,extractall不可信包等于开后门
这是最危险的一个。你可能觉得解压很安全,一行 extractall 搞定:
# ❌ 对不可信的上传 zip 这样写,等于把写文件的权力交给了攻击者
with zipfile.ZipFile(uploaded_zip) as zf:
zf.extractall("/data/uploads/")
问题在于:zip 内条目的文件名可以是任意字符串,包括 ../../../etc/cron.d/evil 或者绝对路径 /etc/passwd。攻击者构造这样一个包,解压时文件就被写到了 /data/uploads/ 之外——覆盖系统配置、往定时任务目录塞脚本、篡改你的项目代码,都成为可能。这就是 Zip Slip(CVE 遍地都是,Java、Python、JS 生态全中过招)。
先看攻击是怎么造出来的,理解了才好防:
# 演示:构造一个带路径穿越条目的恶意包(仅用于理解攻击)
with zipfile.ZipFile("evil.zip", "w") as zf:
zf.writestr("../../../../tmp/pwned.txt", "escaped!") # 条目名带 ../
好消息是:Python 3.6.2 起,extractall / extract 已经会把带 .. 和绝对路径的条目名做「净化」,把它们重定向到目标目录内,不会真的穿越。但——这个净化只防最经典的 ../,不能全信,而且不同版本行为有差异,符号链接、特殊字符仍可能有边界情况。生产环境处理不可信压缩包,应当自己显式校验每一个解压目标路径:
import os
import zipfile
def safe_extract(zip_path: str, dest_dir: str) -> None:
dest_dir = os.path.realpath(dest_dir) # 解析成绝对真实路径,消除符号链接
with zipfile.ZipFile(zip_path) as zf:
for member in zf.namelist():
# 拼出目标路径,再算真实绝对路径
target = os.path.realpath(os.path.join(dest_dir, member))
# 核心校验:目标必须仍在 dest_dir 之内,否则就是路径穿越
if not target.startswith(dest_dir + os.sep) and target != dest_dir:
raise ValueError(f"检测到路径穿越,拒绝解压条目:{member}")
# 全部校验通过才解压
zf.extractall(dest_dir)
# safe_extract("evil.zip", "/data/uploads") # 会抛 ValueError,拒之门外
关键是用 os.path.realpath 把拼出来的目标解析成真实绝对路径(它会展开 .. 和符号链接),再判断结果是否仍以 dest_dir 为前缀。只要跳出了目标目录,一律拒绝。这层校验不依赖标准库版本,自己守住最踏实。
坑三:zip 里的中文文件名乱码
Windows 下用资源管理器打的 zip,文件名常用 GBK 编码,而 zip 规范默认 CP437,Python 解出来就是一串乱码:
with zipfile.ZipFile("windows.zip") as zf:
for info in zf.infolist():
name = info.filename
# 条目没标记 UTF-8 时(flag_bits 第 11 位为 0),往往是 CP437 误读的 GBK
if not info.flag_bits & 0x800:
# 先按 CP437 编码回 bytes,再用 GBK 正确解码
name = info.filename.encode("cp437").decode("gbk", errors="replace")
print(name)
判断依据是 flag_bits & 0x800(通用位标记的第 11 位):置位表示文件名是 UTF-8,不用处理;没置位且出现乱码,基本就是 CP437 误读了 GBK,encode("cp437").decode("gbk") 绕回去即可。
追加与防「zip 炸弹」
往已有 zip 追加文件用 'a' 模式:
with zipfile.ZipFile("out.zip", "a", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("added.txt", "追加进来的内容")
处理不可信包还要防 zip 炸弹——一个几十 KB 的包解压出几十 GB,撑爆磁盘。解压前用 infolist() 检查累计的 file_size,超阈值就拒绝:
MAX_TOTAL = 500 * 1024 * 1024 # 解压上限 500MB
with zipfile.ZipFile(untrusted) as zf:
total = sum(info.file_size for info in zf.infolist())
if total > MAX_TOTAL:
raise ValueError(f"解压后体积 {total} 超限,疑似 zip 炸弹")
小结
- 写包必须显式
compression=ZIP_DEFLATED,否则默认只打包不压缩、白占空间;writestr可把内存内容直接入包。 - 大条目一律用
zf.open()流式读 +shutil.copyfileobj分块写,别用zf.read()一次吞进内存。 - 解压不可信压缩包必须防 Zip Slip:用
os.path.realpath校验每个目标路径仍在目标目录内,跳出就拒绝——别只依赖标准库的内置净化。 - Windows 来源的 zip 中文名乱码,靠
flag_bits & 0x800判断编码,encode("cp437").decode("gbk")修复。 - 不可信包还要防 zip 炸弹,解压前用
infolist()累加file_size设上限。 - 记忆点:读大用流、解压先验路径、来源不可信就当它有毒——
zipfile的安全底线就这三条。
以上就是Python利用zipfile读写压缩包的实战指南的详细内容,更多关于Python zipfile读写压缩包的资料请关注脚本之家其它相关文章!
