脚本专家 发表于 2026-9-15 10:00:00

Python zipfile流式读写与Zip Slip防护实战

处理用户上传的 zip、批量打包导出、解压第三方数据包时,Python 标准库 zipfile 几乎绕不开。真正容易出问题的地方不是会不会调用 ZipFile,而是 read() 大文件把服务 OOM、extractall 解压不可信包触发 Zip Slip、Windows 中文文件名乱码和 zip 炸弹。下面按读写、流式处理、路径安全、编码修复、追加与体积限制展开。

基本读写先跑通

创建一个 zip 并写入几个文件:

import zipfile

with zipfile.ZipFile('out.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
    zf.write('report.pdf')
    zf.write('data/a.csv', 'a.csv')
    zf.writestr('note.txt', '直接写字符串,不用先落地文件')

compression 参数很容易被忽略:不传时默认 ZIP_STORED,只是把文件原样塞进包,体积不会减少。要压缩必须显式指定 ZIP_DEFLATED(通用)或 ZIP_LZMA(压得更狠但更慢)。zf.write() 的第二个参数 arcname 可以把文件改成压缩包内的路径;writestr() 则适合把内存中生成的内容直接写进包,省去临时文件。

读取和查看内容:

import zipfile

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')
    print(data.decode('utf-8'))

read() 返回 bytes,会把整个条目一次性读进内存。小文件无所谓,大文件就是隐患。

坑一:read() 大文件爆内存,改用流式

假设包里有个 2GB 的日志文件,要逐行处理,下面写法会把 2GB 条目一次读进内存,直接 OOM:

import zipfile

with zipfile.ZipFile('logs.zip') as zf:
    content = zf.read('huge.log')
    for line in content.splitlines():
      handle(line)

正确做法是用 zf.open() 拿到文件式对象,像普通文件一样流式读,内存中始终只有一块数据:

import io
import zipfile

with zipfile.ZipFile('logs.zip') as zf:
    with zf.open('huge.log') as raw:
      for line in io.TextIOWrapper(raw, encoding='utf-8'):
            handle(line.rstrip('\n'))

zf.open() 返回的是边解压边读的流,配合 TextIOWrapper 就能按行迭代,内存占用和文件大小无关。解压到磁盘时也不要 read() 后再自己写,用 shutil.copyfileobj 分块拷贝:

import shutil
import zipfile

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)

只要条目可能很大,就用 zf.open() 流式处理,别碰 zf.read()。

坑二:Zip Slip 路径穿越,extractall 对不可信包等于开后门

对不可信上传包直接 extractall 很危险:

import zipfile

with zipfile.ZipFile(uploaded_zip) as zf:
    zf.extractall('/data/uploads/')

zip 内条目的文件名可以是任意字符串,包括 ../../../etc/cron.d/evil 或绝对路径 /etc/passwd。攻击者构造这样的包,解压时文件就可能写到 /data/uploads/ 之外,覆盖系统配置、往定时任务目录塞脚本、篡改项目代码。这类 Zip Slip 问题在 Java、Python、JS 生态都出现过。先看攻击怎么造出来,理解后才知道怎么防:

import zipfile

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))
            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 解出来就是一串乱码:

import zipfile

with zipfile.ZipFile('windows.zip') as zf:
    for info in zf.infolist():
      name = info.filename
      if not info.flag_bits & 0x800:
            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' 模式:

import zipfile

with zipfile.ZipFile('out.zip', 'a', zipfile.ZIP_DEFLATED) as zf:
    zf.writestr('added.txt', '追加进来的内容')

处理不可信包还要防 zip 炸弹:一个几十 KB 的包可能解压出几十 GB,撑爆磁盘。解压前用 infolist() 检查累计的 file_size,超过阈值就拒绝:

import zipfile

MAX_TOTAL = 500 * 1024 * 1024
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 设上限。记忆点:读大用流、解压先验路径、来源不可信就当它有毒。

热心网友4 发表于 2026-9-15 19:00:00

Re: Python zipfile流式读写与Zip Slip防护实战

这篇很实用,尤其是把 zf.read() 和 zf.open() 的差别讲清楚了,很多 OOM 就是 read 大条目造成的。safe_extract 的思路也对,先 realpath 再判断前缀能挡住大部分 Zip Slip。 实际用的时候可以再严一点:校验通过后最好自己逐条 zf.open 加 copyfileobj 写到目标文件,而不是校验完再 extractall,这样路径和写入行为都更可控;Windows 上还要考虑大小写不敏感、反斜杠和保留名。 zip 炸弹除了单文件大小,也要算总解压量和压缩比,流式读的时候累计计数,超阈值就中断。中文文件名乱码可以先用 metadata_encoding 指定,或者读出来按 cp437 到 gbk 的常见情况处理,但写入前最好再统一。追加模式也不能改变已有条目的压缩方式,想统一压缩得重建包。整体很干货,感谢分享。

热心网友1 发表于 2026-9-15 19:10:00

Re: Python zipfile流式读写与Zip Slip防护实战

这篇整理很实用,尤其是 zf.open 配合 TextIOWrapper 流式读、以及用 shutil.copyfileobj 分块拷贝,确实比 read 大文件稳很多。Zip Slip 那段 safe_extract 的思路也清楚,先 realpath 再判断目标路径是否还在解压目录内,比直接 extractall 可靠。实际处理不可信包时,除了文件名里的 ..,符号链接、目录条目、异常压缩比和超大文件也值得一起纳入检查,最好在解压前先看 infolist 里的 file_size 和 compress_size,提前拦截 zip 炸弹。期待后续把 Windows 中文文件名乱码、追加写和体积限制也展开讲讲。

热心网友5 发表于 2026-9-15 19:20:00

Re: Python zipfile流式读写与Zip Slip防护实战

感谢分享,这几个坑确实都是实战里最容易翻车的地方。我补充两点自己的经验。 关于流式读取,zf.open() 配合 shutil.copyfileobj 这个组合我一直在用,非常稳。另外如果只是要判断条目大小、做个阈值拦截,不真正读内容,直接用 infolist() 里的 file_size 就行,根本不用 open,这样连解压都省了。对于 zip 炸弹,我会在解压前先遍历一遍 infolist(),累计 file_size,一旦超过设定上限就直接拒绝,同时再看一下压缩比,如果单条目的 compress_size 很小而 file_size 极大,基本可以判定是刻意构造的炸弹。 关于 Zip Slip,楼主说得对,不能依赖标准库的自动净化,不同 Python 版本行为确实有差异,自己显式校验最保险。我用 realpath 的方式跟你思路一样,但额外注意一点:Windows 下路径大小写不敏感,而且盘符和 UNC 路径会让 startswith 判断变得很微妙,所以我会统一用 os.path.normcase 再比较,避免边界情况下误判。 编码那块也很关键,Windows 下用系统自带工具打的包经常出现中文名乱码,本质是条目没有走 UTF-8 标志位。读取时如果 namelist() 里出现乱码,可以尝试用 cp437 解出来再用 gbk 重新编码,这个方法在不少老包里都救过场,当然这
页: [1]
查看完整版本: Python zipfile流式读写与Zip Slip防护实战