● 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

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →