Python 怎么读写 zip 和 tar 归档?解压时有什么安全风险?
简化版
zipfile 和 tarfile 是标准库处理「归档文件」的两个模块——归档是「把多个文件打包成一个文件」,压缩是「把数据变小」,zip 两件事一起做,tar 只负责打包(压缩靠外挂 gzip/bz2/xz,所以叫 .tar.gz)。用法都是上下文管理器:with zipfile.ZipFile("a.zip", "w", zipfile.ZIP_DEFLATED) as z: z.write("f.txt")、with tarfile.open("a.tar.gz", "w:gz") as t: t.add("dir/");读取用 namelist()/getnames() 看内容,extractall(path) 解压,open()/extractfile() 不落地直接读某个成员。最重要的是安全问题:归档里的成员名可以是 ../../etc/passwd 或绝对路径,直接 extractall() 会把文件写到目标目录之外(这就是 ZipSlip / 路径穿越漏洞);tar 还能包含软链接、设备文件、setuid 位,危害更大。Python 3.12 起 tarfile 加了 filter= 参数("data" 是最安全的过滤器),3.14 起默认就是 filter="data";老版本必须自己逐个成员校验路径。此外还有 zip 炸弹(几十 KB 解压出几十 GB)——要先检查 ZipInfo.file_size 的总和。核心记忆:zip 打包+压缩一体、tar 打包与压缩分离;永远不要直接对不可信归档调用 extractall()。
详细版
两个模块对照:
| 维度 | zipfile | tarfile |
|---|---|---|
| 定位 | 归档 + 压缩一体 | 只归档,压缩靠 gzip/bz2/xz |
| 常见扩展名 | .zip | .tar / .tar.gz / .tgz / .tar.bz2 / .tar.xz |
| 打开 | ZipFile(p, "r"/"w"/"a"/"x") | tarfile.open(p, "r:gz"/"w:gz"/"r:*") |
| 压缩方式 | ZIP_DEFLATED/ZIP_BZIP2/ZIP_LZMA(每个成员独立压缩) | 整个 tar 流整体压缩 |
| 随机访问 | ✅ 有中央目录,可单独读某个文件 | ❌ .tar.gz 必须顺序解压到目标位置 |
| 保留元信息 | 有限(修改时间、部分权限) | 完整(权限、属主、软链接、设备文件) |
| 平台 | Windows 生态常见 | Unix 生态标准 |
| 成员列表 | namelist() / infolist() | getnames() / getmembers() |
import zipfile, tarfile, os
from pathlib import Path
# ① 写 zip(★不指定 compression 默认是 ZIP_STORED=不压缩★)
with zipfile.ZipFile("out.zip", "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as z:
z.write("report.csv") # 归档内路径 = 原路径
z.write("data/report.csv", arcname="report2.csv") # ★arcname 指定归档内的名字★
z.writestr("meta.json", '{"v":1}') # 直接写字符串,不需要真实文件
# ② 读 zip:不落地直接读某个成员
with zipfile.ZipFile("out.zip") as z:
print(z.namelist()) # ['report.csv', 'report2.csv', 'meta.json']
info = z.getinfo("meta.json")
print(info.file_size, info.compress_size) # 原始大小 / 压缩后大小
with z.open("meta.json") as f: # ★流式读取,不解压到磁盘★
print(f.read().decode())
# ③ 写 tar.gz
with tarfile.open("out.tar.gz", "w:gz") as t:
t.add("data/", arcname="data") # 递归加整个目录
# ④ 读 tar:r:* 自动识别压缩方式
with tarfile.open("out.tar.gz", "r:*") as t:
print(t.getnames())
f = t.extractfile("data/report.csv") # 返回文件对象(目录成员返回 None)
if f: print(f.read()[:50])
# ⑤ ★安全解压(关键)★
# Python 3.12+:用 filter 参数
with tarfile.open("untrusted.tar.gz") as t:
t.extractall("outdir", filter="data") # ★最严格:拒绝绝对路径/../、软链接、特殊文件★
# 通用写法(zip 和老版本 tar 都适用):逐个成员校验
def safe_extract_zip(zip_path, dest):
dest = Path(dest).resolve()
with zipfile.ZipFile(zip_path) as z:
total = sum(i.file_size for i in z.infolist())
if total > 1024**3: # ★防 zip 炸弹:限制解压后总大小★
raise ValueError(f"解压后过大: {total}")
for info in z.infolist():
target = (dest / info.filename).resolve()
if not target.is_relative_to(dest): # ★Python 3.9+;核心校验★
raise ValueError(f"非法路径: {info.filename}")
z.extractall(dest)
# ⑥ 攻击样例:构造一个会写到目标目录外的 zip
with zipfile.ZipFile("evil.zip", "w") as z:
z.writestr("../../evil.txt", "pwned") # ★extractall 会真的写到上两级目录★
⚠️ 三个必须记住的点:① 默认不压缩——
ZipFile(p, "w")不传compression时用的是ZIP_STORED(只打包不压缩),生成的文件和原始数据一样大,这是最常见的「为什么我的 zip 没变小」。要压缩必须显式写compression=zipfile.ZIP_DEFLATED。②extractall()对不可信输入是危险操作:归档成员名可以是../../../etc/cron.d/x或绝对路径/etc/passwd,解压时会写到目标目录之外(ZipSlip 路径穿越);tar 还能携带软链接(先建一个指向/etc的链接,再往链接里写文件)、设备文件和 setuid 位。Python 3.12 起tarfile.extractall(filter="data")能挡住这些,3.14 起filter="data"成为默认;zipfile至今没有内置过滤器,必须自己校验。③.tar.gz不能随机访问:整个 tar 流被当作一个整体压缩,想读中间某个文件必须从头解压到那个位置——所以「归档里有一万个文件、只想取一个」的场景应该用 zip(有中央目录,可以直接定位)。
完整版教学
一、归档 vs 压缩:为什么有 .tar.gz 这种双扩展名
两件不同的事:
归档(archive):把 N 个文件+目录结构 打包成 1 个文件 → 解决"传输/存储多文件"
压缩(compress):把数据变小 → 解决"体积"
zip :★两件事一起做★(每个成员单独压缩后放进容器)
tar :★只做归档★("tape archive",磁带时代的产物,就是把文件首尾相接)
gzip :★只做压缩★(只能压一个数据流,不认识"多文件")
所以 Unix 的组合是:tar 先打包 → gzip 再整体压缩 → 得到 .tar.gz(简写 .tgz)
关键差异由此而来:
┌────────────────┬──────────────────────┬────────────────────────┐
│ │ zip(各成员独立压缩) │ tar.gz(整体压缩) │
├────────────────┼──────────────────────┼────────────────────────┤
│ 随机访问 │ ✅ 有中央目录,直接定位 │ ❌ 必须从头顺序解压 │
│ 压缩率 │ 略低(无法跨文件找重复)│ ★更高★(跨文件共享字典) │
│ 损坏影响 │ 只坏那一个成员 │ ★后面全废★(流式依赖) │
│ 追加成员 │ ✅ 支持 "a" 模式 │ ❌ 压缩后无法追加 │
└────────────────┴──────────────────────┴────────────────────────┘
★ 压缩率的算例(1000 个内容相似的 1KB 日志文件):
zip(各自压缩) ≈ 1000 × 300B = 300 KB
tar.gz(整体压缩) ≈ 40 KB ← 跨文件的重复内容被一起压掉了
→ "很多相似小文件"用 tar.gz 优势巨大;"要随机取用某个文件"用 zip
选择准则:
给 Windows 用户 / 要随机访问 / 要追加 → zip
Unix 部署包 / 很多相似小文件 / 要保留权限属主 → tar.gz
单个大文件只要压缩(不需要打包) → gzip / xz 直接压(见"流压缩"专题)
理解这两个模块的前提是分清归档和压缩是两件事:归档把多个文件变成一个文件(解决结构问题),压缩把数据变小(解决体积问题)。zip 把两件事合在一起做——每个成员单独压缩后放进容器,所以它有「中央目录」、能随机定位、能追加成员、某个成员损坏不影响其他;tar 只做归档(文件首尾相接),压缩交给外挂的 gzip/bz2/xz,整个流被当成一个整体压缩,因此压缩率更高(能跨文件找重复),但不能随机访问、不能追加、中途损坏会导致后面全废。上面那个算例很有说服力:1000 个内容相似的 1KB 日志,zip 约 300KB 而 tar.gz 只有 40KB——很多相似小文件用 tar.gz,要随机取用某个成员用 zip。
二、zipfile 的读写与常用 API
写入(三种方式):
z.write(文件路径, arcname=归档内名字) 从磁盘文件添加
z.writestr(名字, 数据) ★直接写内存中的数据★(不需要临时文件)
with z.open(名字, "w") as f: f.write(...) 流式写(3.6+,适合大文件)
★ arcname 的重要性:不指定时归档里会带上完整路径
z.write("/home/u/data/a.csv") → 归档内是 "home/u/data/a.csv"(泄漏目录结构)
z.write("/home/u/data/a.csv", arcname="a.csv") → 干净 ✓
压缩参数:
compression=zipfile.ZIP_STORED ★默认★,不压缩
ZIP_DEFLATED 常用(需要 zlib,几乎总是可用)
ZIP_BZIP2 / ZIP_LZMA 压缩率更高、更慢
compresslevel=0~9 3.7+,DEFLATED 默认 6
读取:
z.namelist() → ['a.csv', 'sub/b.txt'](★目录成员以 / 结尾★)
z.infolist() → [ZipInfo, ...](含 file_size / compress_size / date_time)
z.read("a.csv") → bytes(★整个读进内存★)
z.open("a.csv") → 文件对象(★流式,大文件用这个★)
z.extract(名字, path) / z.extractall(path) 解压到磁盘
z.testzip() → 校验 CRC,返回第一个损坏成员的名字(None 表示都 OK)
文本成员的正确读法(★易错★):
with z.open("a.csv") as f: # ← f 是★二进制★流
text = io.TextIOWrapper(f, encoding="utf-8", newline="")
for row in csv.reader(text): ...
✗ 直接 csv.reader(f) 会报错(csv 要文本流,拿到的是 bytes)
其他实用能力:
zipfile.is_zipfile(p) 判断是不是 zip
z.setpassword(b"pw") 读加密 zip(★只支持解密,不支持创建加密 zip★;
且标准 ZipCrypto 极不安全,AES 加密的 zip 标准库读不了)
z.comment = b"..." 归档注释
ZipFile(p, "a") 追加成员(★注意:同名成员会重复存在,读取时取最后一个★)
zipfile 的写入有三种姿势:write() 从磁盘添加、writestr() 直接写内存数据(生成报表打包时不必落临时文件)、z.open(name, "w") 流式写大文件。这里有个经常被忽略的细节:不指定 arcname 时,归档里会带上完整的原始路径——z.write("/home/u/data/a.csv") 会在 zip 里创建 home/u/data/a.csv 这样的层级,既难看又泄漏了服务器目录结构。读取时最容易踩的是编码问题:z.open() 返回的是二进制流,直接喂给 csv.reader 会报错,正确做法是套一层 io.TextIOWrapper(f, encoding=...)。另外两个冷知识:标准库只能读加密 zip、不能创建(而且 ZipCrypto 极不安全,AES 加密的 zip 标准库根本读不了,需要 pyzipper);用 "a" 模式追加同名成员时旧成员不会被删除,两份都在文件里,读取时取最后一个——这会让 zip 悄悄变大。
三、tarfile 的模式字符串与元信息
tarfile.open(name, mode) 的模式字符串(★比 zipfile 复杂★):
"r" 自动识别压缩(等价 "r:*") ← 读的时候用这个最省心
"r:" ★不压缩★的 tar
"r:gz" / "r:bz2" / "r:xz" 指定压缩
"w:gz" 写 + gzip
"a:" 追加(★只能追加到未压缩的 tar★)
"r|gz" ★流模式★(管道/网络流,不可 seek,只能顺序读一遍)
★ "r:gz" 和 "r|gz" 的区别:
冒号 = 可随机 seek(需要真实文件)
竖线 = 纯流式(可以直接处理 sys.stdin.buffer 或网络响应,内存占用恒定)
tar 保留的元信息(比 zip 完整得多,这既是优点也是风险):
TarInfo.name / size / mtime
.mode 权限位(★包括 setuid/setgid★)
.uid / .gid / .uname / .gname 属主
.type REGTYPE(普通文件) / DIRTYPE / SYMTYPE(软链接) / LNKTYPE(硬链接)
/ CHRTYPE、BLKTYPE(设备文件) / FIFOTYPE
.linkname 链接指向的目标
★ 正是"能存软链接和设备文件"让 tar 的解压比 zip 更危险(见下一节)
添加时定制元信息(filter 参数):
def scrub(ti: tarfile.TarInfo):
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root" # ★不泄漏打包机器的用户名★
ti.mtime = 0 # 可复现构建(每次打包字节一致)
if ti.name.endswith(".log"):
return None # ★返回 None=排除这个成员★
return ti
with tarfile.open("out.tar.gz", "w:gz") as t:
t.add("app/", filter=scrub)
读取大归档的正确姿势:
for member in t: # ★迭代 TarFile 是流式的,内存友好★
if member.isfile() and member.name.endswith(".json"):
data = t.extractfile(member).read()
✗ t.getmembers() 会★把整个成员表读进内存★(几十万个成员时很占内存)
tarfile 的模式字符串比 zipfile 复杂,但规律清晰:冒号 "r:gz" 表示可随机 seek(需要真实文件),竖线 "r|gz" 表示纯流模式——后者能直接处理管道、sys.stdin.buffer 或网络响应流,内存占用恒定,是「边下载边解压」的关键。读取时直接用 "r"(等价 "r:*")让它自动识别压缩方式最省心。tar 相比 zip 的最大特点是保留完整的 Unix 元信息:权限位(包括 setuid/setgid)、属主 uid/gid、以及成员类型(普通文件、目录、软链接、硬链接、设备文件、FIFO)——这既是它做部署包的优势,也正是它解压时更危险的原因。打包时推荐用 add(..., filter=函数) 清洗元信息:把 uid/gid 归零(避免泄漏打包机器的用户名)、把 mtime 固定(实现可复现构建)、返回 None 直接排除某些成员。最后记住读大归档要迭代 TarFile 对象而不是 getmembers()(后者会把整个成员表读进内存)。
四、解压的安全风险:ZipSlip、软链接与 zip 炸弹
风险 1:路径穿越(ZipSlip / TarSlip)★最常见★
归档成员名可以是任意字符串:
"../../../etc/cron.d/backdoor"
"/etc/passwd" (绝对路径)
"..\\..\\Windows\\System32\\x.dll" (Windows 分隔符)
extractall("uploads/") 时会照着这个名字写文件 → ★写到目标目录之外★
真实危害:覆盖 crontab、覆盖 ~/.ssh/authorized_keys、覆盖 Web 应用的 .py 文件 → RCE
★ 注意 Python 的一个坑:os.path.join(base, "/etc/passwd") == "/etc/passwd"
(join 遇到绝对路径会★丢弃前面所有部分★)→ 手写拼接极易出错
风险 2:软链接攻击(★tar 独有,比路径穿越更隐蔽★)
归档里放两个成员:
① 成员 "link" 是一个软链接,指向 /etc
② 成员 "link/passwd" 是一个普通文件
解压时先创建软链接,再往 "link/passwd" 写 → ★实际写到了 /etc/passwd★
★ 只校验成员名不含 ".." 是拦不住这种攻击的(两个名字都很"干净")
风险 3:zip 炸弹(解压炸弹)
42.zip:42 KB → 解压出 4.5 PB(层层嵌套的高度重复数据)
单层也能做到:1 MB 的全零文件压缩后只有几 KB,重复 10000 个成员
★ 危害:磁盘写满、内存耗尽、服务不可用
✓ 防御:解压前检查 sum(info.file_size for info in z.infolist())
并限制成员数量;解压时也可以按已写字节数中途中断
风险 4:其他
- 设备文件 / FIFO(tar 可以创建 /dev/xxx)
- setuid 位(解压出一个 setuid root 的可执行文件)
- 超长文件名 / 特殊字符(NUL、控制字符)导致的下游解析问题
- 同名成员覆盖(先解压 a.txt,再解压另一个 a.txt)
★ Python 3.12+ 的官方答案:tarfile 的 filter 参数(PEP 706)
t.extractall(path, filter="data") ← ★推荐★,最严格:
拒绝 绝对路径、..、指向归档外的软链接、设备文件、FIFO
清除 setuid/setgid 位、属主信息
filter="tar" 仅去掉绝对路径和 ..,保留 Unix 元信息(用于可信来源)
filter="fully_trusted" 旧行为(完全不检查)
★ 3.12/3.13 不指定 filter 会有 DeprecationWarning;★3.14 起默认就是 "data"★
★ zipfile 没有 filter,必须自己写校验:
def is_safe(dest: Path, name: str) -> bool:
target = (dest / name).resolve()
return target.is_relative_to(dest) # ★Python 3.9+★
# 3.8 及以下:os.path.commonpath([dest, target]) == str(dest)
这是这道题的核心。风险一是路径穿越:归档成员名可以是 ../../../etc/cron.d/x 或绝对路径,extractall() 会照着写、把文件落到目标目录之外——覆盖 crontab、authorized_keys 或 Web 应用的 .py 文件都能直接变成远程代码执行;Python 里还有个额外的坑是 os.path.join(base, "/etc/passwd") 会丢弃 base 直接返回绝对路径,手写拼接极易出错。风险二是软链接攻击(tar 独有且更隐蔽):归档里先放一个指向 /etc 的软链接 link,再放一个成员 link/passwd——两个名字都不含 ..、看起来完全干净,解压后却写进了 /etc/passwd,所以只校验成员名是拦不住的。风险三是 zip 炸弹:42KB 解压出 4.5PB,防御手段是解压前累加 file_size 并限制成员数量。官方答案是 Python 3.12 引入的 tarfile 过滤器(PEP 706):extractall(path, filter="data") 会拒绝绝对路径、..、指向归档外的软链接和设备文件,并清除 setuid 位;3.14 起它成为默认。而 zipfile 至今没有过滤器,必须自己用 (dest / name).resolve().is_relative_to(dest) 逐个成员校验。
五、安全解压的完整实现
一个可以直接抄的安全解压函数(zip 版):
from pathlib import Path
import zipfile
MAX_TOTAL = 1 * 1024**3 # 解压后总大小上限 1 GB
MAX_FILES = 10_000 # 成员数量上限
MAX_RATIO = 100 # ★压缩比上限(防炸弹)★
def safe_unzip(archive: Path, dest: Path) -> None:
dest = dest.resolve()
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive) as z:
infos = z.infolist()
# ① 数量
if len(infos) > MAX_FILES:
raise ValueError(f"成员过多: {len(infos)}")
# ② 总大小 + 压缩比
total = sum(i.file_size for i in infos)
packed = sum(i.compress_size for i in infos) or 1
if total > MAX_TOTAL or total / packed > MAX_RATIO:
raise ValueError(f"疑似解压炸弹: {total} bytes, ratio={total/packed:.0f}")
# ③ 逐个成员校验路径
for i in infos:
name = i.filename
if name.startswith("/") or ".." in Path(name).parts:
raise ValueError(f"非法成员名: {name}")
target = (dest / name).resolve()
if not target.is_relative_to(dest): # ★最终防线★
raise ValueError(f"路径穿越: {name}")
z.extractall(dest)
★ 三层校验缺一不可:
startswith("/") 挡绝对路径
".." in parts 挡显式穿越(比字符串 in ".." 更准,避免误伤 "a..b.txt")
is_relative_to ★最终防线★(处理符号链接、Windows 分隔符、编码技巧等绕过)
tar 版(3.12+ 直接用 filter):
with tarfile.open(archive) as t:
t.extractall(dest, filter="data") # ★一行搞定,优先用它★
兼容老版本:
def safe_untar(archive, dest):
dest = Path(dest).resolve()
with tarfile.open(archive) as t:
for m in t.getmembers():
if not (m.isfile() or m.isdir()): # ★拒绝软链接/设备文件/FIFO★
raise ValueError(f"不允许的成员类型: {m.name} ({m.type})")
target = (dest / m.name).resolve()
if not target.is_relative_to(dest):
raise ValueError(f"路径穿越: {m.name}")
m.mode = m.mode & 0o777 & ~0o7000 # ★清除 setuid/setgid/sticky★
t.extractall(dest)
更稳妥的工程做法(纵深防御):
① 解压到★一次性的临时目录★,校验通过后再移动到正式位置
② 用独立的低权限用户 / 容器 / 沙箱执行解压
③ 磁盘配额(quota)或 ulimit -f 限制写入量
④ 只接受白名单内的扩展名,解压后再做一次类型校验(魔数)
⑤ 记录日志:谁上传的、成员列表、大小——出事能追溯
安全解压要做三层路径校验,缺一不可:startswith("/") 挡绝对路径、".." in Path(name).parts 挡显式穿越(用 parts 而不是字符串 in,避免误伤 a..b.txt 这类合法文件名)、最后 (dest / name).resolve().is_relative_to(dest) 作为最终防线(它能处理符号链接、Windows 分隔符和各种编码绕过技巧)。防炸弹则要同时看成员数量、解压后总大小、压缩比三个指标。tar 在 3.12+ 直接 filter="data" 一行搞定;老版本要显式拒绝非普通文件/目录的成员类型(软链接、设备文件、FIFO)并清除 setuid/setgid 位。真正的生产实践还应该加上纵深防御:先解压到一次性临时目录、校验通过再移动,用低权限用户或容器执行解压,配上磁盘配额,最后再对解压出的文件做一次类型校验。
六、性能、内存与选型
压缩算法对比(同一份 100 MB 日志的量级感受):
┌──────────┬──────────┬──────────┬──────────┬────────────────┐
│ 算法 │ 压缩后 │ 压缩耗时 │ 解压耗时 │ 适合 │
├──────────┼──────────┼──────────┼──────────┼────────────────┤
│ 不压缩 │ 100 MB │ — │ — │ 已压缩的数据(jpg)│
│ gzip/-6 │ ~12 MB │ ~3 s │ ~0.7 s │ ★通用默认★ │
│ gzip/-1 │ ~16 MB │ ~1 s │ ~0.7 s │ 实时/带宽换 CPU │
│ bz2 │ ~9 MB │ ~12 s │ ~4 s │ 极少用(慢) │
│ xz/lzma │ ~7 MB │ ~40 s │ ~1.5 s │ ★发行包★(压一次解多次)│
│ zstd │ ~11 MB │ ~1 s │ ~0.3 s │ 现代首选(3.14 进标准库)│
└──────────┴──────────┴──────────┴──────────┴────────────────┘
★ 决策:压一次解多次 → xz;频繁压缩 → gzip/zstd;已压缩数据 → 别再压(会变大)
内存注意事项:
✗ z.read("big.bin") ★整个成员读进内存★
✓ with z.open("big.bin") as f: shutil.copyfileobj(f, out) 流式,恒定内存
✗ t.getmembers() 成员表全进内存(几十万成员时很重)
✓ for m in t: ... 流式迭代
✓ tarfile.open(fileobj=响应流, mode="r|gz") ★边下载边解压★,不落地
不落地的常见组合:
内存里造 zip 返回给前端:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("report.csv", csv_text)
return buf.getvalue()
直接读远程 tar.gz:
with tarfile.open(fileobj=resp.raw, mode="r|gz") as t:
for m in t: ...
其他选型:
只压单个文件、不需要打包 → gzip / lzma 模块(流压缩,见对应专题)
要跨平台、给用户下载 → zip(Windows 双击就能开)
Python 项目分发 → wheel(本质就是 zip)
超大数据 / 列式分析 → 不要用归档,用 parquet 等格式
需要加密 → 不要用 zip 的 ZipCrypto(已破解);
用 age/gpg 加密,或 pyzipper(AES)
选型时先看压缩算法:gzip 是通用默认(快、够用),xz/lzma 压缩率最高但压缩极慢——适合「压一次、解很多次」的发行包;bz2 基本被淘汰;zstd 是现代首选(Python 3.14 起进标准库)。有个常识要记住:已经压缩过的数据(jpg、mp4、zip)再压不但没用还会变大。内存方面的铁律是能流式就别整读:z.read() 会把整个成员读进内存,应该用 z.open() 配 shutil.copyfileobj;t.getmembers() 会把成员表全读进来,应该直接迭代 TarFile。两个很实用的「不落地」组合:用 io.BytesIO 在内存里造 zip 直接返回给前端(不写临时文件),以及 tarfile.open(fileobj=响应流, mode="r|gz") 边下载边解压。
记忆钩子:「先分清两件事:★归档=把多文件打成一个(tar),压缩=把数据变小(gzip)★,zip 两件事一起做、tar 只打包所以要外挂 gzip 才有 .tar.gz。由此推出全部差异:zip 有中央目录→可随机访问/可追加/单个成员损坏不影响其他,但压缩率低;tar.gz 整体压缩→压缩率高(1000 个相似小文件 300KB vs 40KB)但必须顺序解压、不能追加、中途损坏后面全废。zip 的坑:★不传 compression 默认 ZIP_STORED=不压缩★、不传 arcname 会把完整路径写进归档、z.open() 返回的是二进制流(读文本要套 TextIOWrapper)、标准库只能读加密 zip 不能创建。tar 的特点是保留完整 Unix 元信息(权限含 setuid、属主、★软链接/设备文件★),模式串里冒号可 seek、竖线是纯流式(能边下载边解压)。★安全是本题重点:绝不要对不可信归档直接 extractall()★——①路径穿越(成员名写成 ../../etc/cron.d/x 或绝对路径,注意 os.path.join 遇绝对路径会丢弃前缀)②★tar 特有的软链接攻击★(先建指向 /etc 的软链接、再往链接内写文件,两个成员名都很干净,只查 .. 拦不住)③zip 炸弹(42KB 解压出 4.5PB,要查成员数+总大小+压缩比)。官方答案:★Python 3.12+ tarfile.extractall(filter=‘data’)(3.14 起为默认)★;zipfile 至今没有过滤器,必须自己三层校验:startswith(’/’)、’..’ in Path(name).parts、最终防线 (dest/name).resolve().is_relative_to(dest)。」
七、常见误区与追问
- 误区:
ZipFile(p, "w")写出来的就是压缩过的 zip。 不是——不传compression参数时默认是zipfile.ZIP_STORED,即「只打包不压缩」,生成的 zip 体积和原始文件总和几乎一样(只多了元数据)。这是「为什么我的 zip 没变小」最常见的原因。要压缩必须显式写compression=zipfile.ZIP_DEFLATED(还可以用 3.7+ 的compresslevel=0~9调级别)。顺带记住反过来的情况:对已经压缩过的数据(jpg、mp4、已有的 zip)再做 DEFLATE 不但省不了空间,还会因为额外的元数据略微变大,这类文件用ZIP_STORED才是对的。 - 误区:解压前检查成员名里有没有
..就安全了。 这道防线能挡住最初级的路径穿越,但至少有三种绕过方式:① 绝对路径(/etc/passwd),成员名里根本没有..;② Windows 路径分隔符(..\\..\\x)在某些处理下不会被按..识别;③ 软链接攻击(tar 特有)——归档里先放一个指向/etc的软链接link,再放一个成员link/passwd,两个名字都完全「干净」,解压后却写进了/etc。所以正确的做法是以「最终解析路径」为准:(dest / name).resolve().is_relative_to(dest)(Python 3.9+),并且对 tar 额外拒绝非普通文件/目录的成员类型。3.12+ 的tarfile直接用filter="data"是最省心的答案。 - 误区:
.tar.gz里可以像 zip 一样直接读某一个文件。 不能高效做到。.tar.gz是先打包成 tar 流、再把整个流压缩,因此没有「中央目录」这种索引结构——想读中间某个成员,必须从头解压到那个位置。归档里有一万个文件、只想取第 9999 个时,等于把整个包解了一遍。zipfile则相反:每个成员独立压缩并在文件尾部维护中央目录,可以直接 seek 到该成员的偏移量读取。所以「归档要被随机访问」(比如做成一个可查询的数据包)必须用 zip;「一次性整体解压、且文件多而相似」用 tar.gz 压缩率更高。这也解释了为什么 Python 的 wheel 包用 zip 格式——安装器需要按需读取里面的METADATA。 - 误区:
extractall()的第一个参数指定了目录,文件就一定落在这个目录里。extractall(path)只是把path作为基准目录去拼接成员名,成员名是../../x或/etc/x时文件就会落到外面——这就是 ZipSlip / 路径穿越漏洞,在归档来自用户上传、第三方下载、CI 缓存等不可信来源时是高危 RCE(覆盖 crontab、~/.ssh/authorized_keys、Web 应用的.py文件都能直接拿到执行权)。Python 里还有个加剧问题的细节:os.path.join(base, "/etc/passwd")会丢弃base直接返回/etc/passwd,手写路径拼接的校验代码极易在这里出错。结论就是那句话:永远不要对不可信归档直接调用extractall()。 - 误区:zip 的密码保护可以用来保护敏感数据。 两个问题。① 标准库只能读加密 zip、不能创建(
setpassword()仅用于解密);② 传统 zip 加密用的 ZipCrypto 算法早已被攻破——已知明文攻击可以在几分钟内恢复密钥,甚至只要知道归档里某个文件的部分内容就够了。而更安全的 AES 加密 zip(WinZip AES)标准库根本读不了,需要pyzipper这类第三方库。所以「用 zip 密码保护数据」在安全上基本等于没有保护。真正需要保密就用成熟的加密工具(age、gpg)或先用cryptography库加密再打包,并且注意:zip 的文件名列表始终是明文的(即使内容加密),元数据本身也可能泄漏信息。 - 追问:
tarfile的filter参数(PEP 706)具体做了什么,三个预设有什么区别? 它是 Python 3.12 引入的解压过滤机制,作用是在写入每个成员前对TarInfo做检查和清洗。三个预设从松到紧:"fully_trusted"是 3.11 及以前的旧行为——完全不检查,只应用于自己生成的、完全可信的归档;"tar"会拒绝绝对路径和指向归档外的..路径,但保留 Unix 元信息(权限、属主、软链接),适合 Unix 部署包这类「来源可信但要防手滑」的场景;"data"最严格,除了路径检查外还会拒绝软链接指向归档外的目标、拒绝设备文件和 FIFO、清除 setuid/setgid 位、丢弃属主信息,适合处理任何不可信来源。迁移节奏是:3.12/3.13 不指定filter会发DeprecationWarning,3.14 起默认就是"data"。还可以传入自定义函数(接收TarInfo和目标路径,返回修改后的TarInfo或None表示跳过)。注意zipfile没有对应机制,仍需自己校验。 - 追问:怎么防御 zip 炸弹?只看压缩包大小够吗? 不够——zip 炸弹的定义就是「压缩包很小、解压后极大」(经典的 42.zip 是 42KB 解压出 4.5PB),只看输入文件大小完全挡不住。要看三个指标:① 解压后总大小
sum(i.file_size for i in z.infolist())(注意这个字段来自归档头部,理论上可以被伪造,所以它是第一道筛而不是最终保证);② 压缩比总解压大小 / 总压缩大小,正常文本大约 3~10 倍,超过 100 倍就非常可疑;③ 成员数量(有的炸弹是靠几百万个小文件耗尽 inode 和内存)。更可靠的做法是在解压过程中实时计数:用z.open()流式读取并累计已写字节数,超过阈值立刻中断删除。另外还要防嵌套炸弹(zip 里套 zip),所以「解压后的文件如果还是归档,不要自动递归解压」。工程上再叠加磁盘配额、ulimit -f、容器资源限制作为兜底。 - 追问:什么时候该用 zip、什么时候该用 tar.gz? 按三个维度决策。① 访问方式:需要随机读取单个成员(可查询的数据包、Python wheel、Office 文档格式)必须用 zip,因为它有中央目录;一次性整体解压用 tar.gz。② 内容特征:大量相似的小文件(日志、源码)用 tar.gz 压缩率碾压 zip(整体压缩能跨文件找重复,实测可以差数倍);文件少而大、或本身已压缩则差别不大。③ 平台与元信息:给 Windows 用户下载用 zip(双击即开);Unix 部署包用 tar.gz,因为它完整保留权限、属主和软链接(zip 对这些支持有限)。另外还有两个实际考虑:需要追加成员只能用 zip(压缩后的 tar 无法追加);对损坏容忍度要求高时 zip 更好(单个成员损坏不影响其他,而 tar.gz 中途损坏会导致后面全部无法解压)。
八、加强记忆
先分清两件事:归档(把多文件打成一个)和压缩(把数据变小)。zip 两件事一起做(每个成员独立压缩),tar 只做归档、压缩靠外挂 gzip/bz2/xz,所以才有 .tar.gz 这种双扩展名。全部差异都由此推出:zip 有中央目录→可随机访问单个成员、可追加、单个成员损坏不影响其他,但压缩率低;tar.gz 整体压缩→压缩率高得多(1000 个相似小文件:zip 约 300KB vs tar.gz 约 40KB)、完整保留 Unix 元信息,但必须顺序解压、不能追加、中途损坏则后面全废。zipfile 的坑:不传 compression 时默认 ZIP_STORED 即不压缩;不传 arcname 会把完整原始路径写进归档;z.open() 返回二进制流(读文本要套 io.TextIOWrapper);标准库只能读加密 zip 不能创建,且 ZipCrypto 早已被攻破。tarfile 的特点:模式串里冒号可 seek、竖线是纯流式(能边下载边解压);add(..., filter=函数) 可清洗属主/mtime 并排除成员;读大归档要迭代 TarFile 而不是 getmembers()。安全是本题重点——绝不要对不可信归档直接 extractall():① 路径穿越(成员名写成 ../../etc/cron.d/x 或绝对路径;注意 os.path.join 遇绝对路径会丢弃前缀);② tar 特有的软链接攻击(先建指向 /etc 的软链接、再往链接内写文件,两个成员名都很干净,只查 .. 拦不住);③ zip 炸弹(42KB 解压出 4.5PB,要同时查成员数、解压后总大小、压缩比)。官方答案是 Python 3.12 的 tarfile.extractall(filter="data")(3.14 起成为默认),三档预设分别是 fully_trusted(旧行为)/tar(挡路径)/data(最严,还拒绝软链接外指、设备文件并清 setuid);而 zipfile 至今没有过滤器,必须自己做三层校验:startswith("/")、".." in Path(name).parts、以及最终防线 (dest / name).resolve().is_relative_to(dest),再配合「先解压到临时目录、校验通过再移动」的纵深防御。