●
CWE-22 · CVE-2007-4559
Python tarfile.extractall 路径遍历(CVE-2007-4559):成因与修复方法
在有解压过滤器之前,tarfile.extractall() 会把每个成员写到归档里记录的路径上,所以带 .. 的名称、绝对路径或链接都可能把文件放到目标文件夹之外。本文用 Python 示例说明 PEP 706 引入的 filter="data" 参数如何阻止这种情况,为什么目标路径不能来自用户输入,以及应该加上哪些大小限制。
通俗易懂的原理解析 (ELI5)
搬家公司在你的新公寓里拆箱,按照每个标签上写的位置摆放物品。一张写着“厨房,然后出前门,再进邻居卧室”的标签会被一字不差地执行,因为搬运工只看标签,从不检查地址是否还在你的公寓里。归档文件也是这样:每个文件都带着自己的目标路径。修复方法是请一位领班先看每张标签,拒绝任何会离开公寓的东西,也拒绝任何不是普通箱子的东西,比如指向别处的指示牌。
核心概念与专有名词
- CVE-2007-4559
- Python 的
tarfile模块没有阻止成员名称跳出解压目录。这个问题从 2007 年就已为人所知,2022 年 Trellix 估计约有 35 万个开源项目会对不可信的归档调用extractall()。 - 带 .. 或绝对路径的成员名称
- 名为
../outside.txt或/etc/cron.d/job的 tar 条目与目标路径拼接后会指向目标之外。没有过滤器时,extractall()会以进程的权限写入这个解析后的路径。 - 符号链接和硬链接成员
- 归档可以先在目标目录里创建一个指向别处的链接,再通过它写入一个普通文件。只检查名称发现不了这一点,所以过滤器还会检查链接的指向。
- 解压过滤器(PEP 706)
filter="data"会拒绝绝对名称、借..逃逸、指向目标外的链接和设备文件,并清除 set-user-ID 位以及其他用户的执行权限。它从 Python 3.12 起可用,3.8 到 3.11 的安全更新版本中也有,并在 Python 3.14 中成为默认行为。- 资源限制
- 过滤器不限制大小。一个很小的压缩归档可能展开成数百万个成员或许多 GB 的数据,所以解压前要统计成员数量和总大小。
攻击执行流程分解
步骤 1
应用接收上传的归档
数据导入、插件安装程序或 CI 构件处理程序从用户那里收到一个 .tar.gz。
步骤 2
某个成员名称指向文件夹之外
某个条目记录的路径向上跳出了目标目录,或者是绝对路径。一个包含名为 ../outside.txt 的成员的测试归档演示了这种情况。
步骤 3
extractall 写入解析后的路径
文件落在名称解析到的位置,而目标目录本身也可能来自用户提供的 job_id。
步骤 4
应用文件被替换
被覆盖的配置、模板或脚本会在应用下次加载时运行,这就是路径遍历演变成代码执行的方式。
源代码对比:漏洞与安全实现
存在漏洞的实现
# imports.py:每个成员都写到它记录的名称所指的位置
import tarfile
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
# 目录由调用方决定,没有过滤器,也没有大小限制
def import_bundle(archive_path: str, job_id: str) -> Path:
dest = UPLOAD_ROOT / job_id
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path) as tar:
tar.extractall(dest)
return dest
加固后的安全修复
# imports.py:data 过滤器、服务器命名的目录和大小限制
import tarfile
import uuid
from pathlib import Path
UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024
# 拒绝成员过多或字节过多的归档
def check_limits(tar: tarfile.TarFile) -> None:
members = tar.getmembers()
if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
raise ValueError("archive too large")
# filter='data' 阻止 ..、绝对名称、指向外部的链接和设备文件
def import_bundle(archive_path: str) -> Path:
dest = UPLOAD_ROOT / uuid.uuid4().hex
dest.mkdir(parents=True)
with tarfile.open(archive_path, mode="r:*") as tar:
check_limits(tar)
tar.extractall(dest, filter="data")
return dest
工程与系统安全加固清单
- 对每次
extractall()和extract()调用都传入filter="data",当 Bandit B202 或 Semgrep 规则发现没有filter=的调用时让 CI 失败。 - 解压到由服务器命名的新目录(例如 UUID),绝不要解压到用请求数据拼出的路径。
- 解压前检查成员数量和解压后的总大小,超过上限的归档一律拒绝。
- 以对应用代码和配置没有写权限的用户运行解压进程。
- 在 CI 中添加测试,使用包含
../outside.txt和一个绝对路径成员的归档,并期望抛出tarfile.FilterError。