查看: 230|回复: 0

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

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

基本读写先跑通

创建一个 zip 并写入几个文件:
  1. import zipfile
  2. with zipfile.ZipFile('out.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
  3.     zf.write('report.pdf')
  4.     zf.write('data/a.csv', 'a.csv')
  5.     zf.writestr('note.txt', '直接写字符串,不用先落地文件')
复制代码
compression 参数很容易被忽略:不传时默认 ZIP_STORED,只是把文件原样塞进包,体积不会减少。要压缩必须显式指定 ZIP_DEFLATED(通用)或 ZIP_LZMA(压得更狠但更慢)。zf.write() 的第二个参数 arcname 可以把文件改成压缩包内的路径;writestr() 则适合把内存中生成的内容直接写进包,省去临时文件。

读取和查看内容:
  1. import zipfile
  2. with zipfile.ZipFile('out.zip', 'r') as zf:
  3.     print(zf.namelist())
  4.     for info in zf.infolist():
  5.         print(info.filename, info.file_size, info.compress_size)
  6.     data = zf.read('note.txt')
  7.     print(data.decode('utf-8'))
复制代码
read() 返回 bytes,会把整个条目一次性读进内存。小文件无所谓,大文件就是隐患。

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

假设包里有个 2GB 的日志文件,要逐行处理,下面写法会把 2GB 条目一次读进内存,直接 OOM:
  1. import zipfile
  2. with zipfile.ZipFile('logs.zip') as zf:
  3.     content = zf.read('huge.log')
  4.     for line in content.splitlines():
  5.         handle(line)
复制代码
正确做法是用 zf.open() 拿到文件式对象,像普通文件一样流式读,内存中始终只有一块数据:
  1. import io
  2. import zipfile
  3. with zipfile.ZipFile('logs.zip') as zf:
  4.     with zf.open('huge.log') as raw:
  5.         for line in io.TextIOWrapper(raw, encoding='utf-8'):
  6.             handle(line.rstrip('\n'))
复制代码
zf.open() 返回的是边解压边读的流,配合 TextIOWrapper 就能按行迭代,内存占用和文件大小无关。解压到磁盘时也不要 read() 后再自己写,用 shutil.copyfileobj 分块拷贝:
  1. import shutil
  2. import zipfile
  3. with zipfile.ZipFile('logs.zip') as zf:
  4.     with zf.open('huge.log') as src, open('huge.log', 'wb') as dst:
  5.         shutil.copyfileobj(src, dst, length=1024 * 1024)
复制代码
只要条目可能很大,就用 zf.open() 流式处理,别碰 zf.read()。

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

对不可信上传包直接 extractall 很危险:
  1. import zipfile
  2. with zipfile.ZipFile(uploaded_zip) as zf:
  3.     zf.extractall('/data/uploads/')
复制代码
zip 内条目的文件名可以是任意字符串,包括 ../../../etc/cron.d/evil 或绝对路径 /etc/passwd。攻击者构造这样的包,解压时文件就可能写到 /data/uploads/ 之外,覆盖系统配置、往定时任务目录塞脚本、篡改项目代码。这类 Zip Slip 问题在 Java、Python、JS 生态都出现过。先看攻击怎么造出来,理解后才知道怎么防:
  1. import zipfile
  2. with zipfile.ZipFile('evil.zip', 'w') as zf:
  3.     zf.writestr('../../../../tmp/pwned.txt', 'escaped!')
复制代码
Python 3.6.2 起,extractall / extract 已经会把带 .. 和绝对路径的条目名做净化,重定向到目标目录内,不会真的穿越。但这个净化只防最经典的 ../,不能全信,不同版本行为有差异,符号链接、特殊字符仍可能有边界情况。生产环境处理不可信压缩包,应当自己显式校验每一个解压目标路径:
  1. import os
  2. import zipfile
  3. def safe_extract(zip_path: str, dest_dir: str) -> None:
  4.     dest_dir = os.path.realpath(dest_dir)
  5.     with zipfile.ZipFile(zip_path) as zf:
  6.         for member in zf.namelist():
  7.             target = os.path.realpath(os.path.join(dest_dir, member))
  8.             if not target.startswith(dest_dir + os.sep) and target != dest_dir:
  9.                 raise ValueError(f'检测到路径穿越,拒绝解压条目:{member}')
  10.         zf.extractall(dest_dir)
  11. # safe_extract('evil.zip', '/data/uploads')  # 会抛 ValueError,拒之门外
复制代码
关键是先用 os.path.realpath 把拼出的目标路径解析成真实绝对路径,它会展开 .. 和符号链接,再判断结果是否仍以 dest_dir 为前缀。只要跳出了目标目录,一律拒绝。这层校验不依赖标准库版本,自己守住最踏实。

坑三:zip 里的中文文件名乱码

Windows 下用资源管理器打的 zip,文件名常用 GBK 编码,而 zip 规范默认 CP437,Python 解出来就是一串乱码:
  1. import zipfile
  2. with zipfile.ZipFile('windows.zip') as zf:
  3.     for info in zf.infolist():
  4.         name = info.filename
  5.         if not info.flag_bits & 0x800:
  6.             name = info.filename.encode('cp437').decode('gbk', errors='replace')
  7.         print(name)
复制代码
判断依据是 flag_bits & 0x800,也就是通用位标记的第 11 位:置位表示文件名是 UTF-8,不用处理;没置位且出现乱码,基本就是 CP437 误读了 GBK,用 encode('cp437').decode('gbk') 绕回去即可。

追加与防 zip 炸弹

往已有 zip 追加文件用 'a' 模式:
  1. import zipfile
  2. with zipfile.ZipFile('out.zip', 'a', zipfile.ZIP_DEFLATED) as zf:
  3.     zf.writestr('added.txt', '追加进来的内容')
复制代码
处理不可信包还要防 zip 炸弹:一个几十 KB 的包可能解压出几十 GB,撑爆磁盘。解压前用 infolist() 检查累计的 file_size,超过阈值就拒绝:
  1. import zipfile
  2. MAX_TOTAL = 500 * 1024 * 1024
  3. with zipfile.ZipFile(untrusted) as zf:
  4.     total = sum(info.file_size for info in zf.infolist())
  5.     if total > MAX_TOTAL:
  6.         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 设上限。记忆点:读大用流、解压先验路径、来源不可信就当它有毒。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-15 11:57 , Processed in 0.021642 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部