Python 3.14 tarfile 默认过滤:解压踩坑与 5 个真实报错
TL;DR:Python 3.12 给 tarfile.extractall() 加了 filter 参数,但 3.12 与 3.13 的默认行为仍然是「全信任」,只抛一条 DeprecationWarning;到 3.14 默认值才改成 data。我在本机把五种逃逸方式逐个构造出来实测:../.. 成员真的落到了目标目录的两层上级,绝对路径成员 /tmp/x 在 Windows 上被写到 C:\tmp\x。filter='tar' 也不是安全选项——符号链接在 Windows 上是静默消失,既不报错也不留日志。把 filter="data" 显式写上,是这几行代码里唯一重要的事。
本文不含任何联盟推广链接,也没有任何商品推荐。
我为什么开始查这件事
我在本机跑的是 Windows 11 + CPython 3.13.14,属于「默认还全信任」的最后一代。看到 PEP 706 明确写了 3.14 要改默认值,我想知道改之前到底能坏到什么程度,于是不去引二手文章的说法,直接自己造归档来测。
做法很朴素:用 tarfile 自己构造带恶意成员的 tar(不需要 GNU tar,不需要 root,也不需要任何第三方库),解包目标一律放在 tempfile.mkdtemp() 里嵌套两层的子目录,这样 ../../x 就算真的逃出去也只会落到我自己建的临时根,不会碰到系统目录。下面所有数字都是这么跑出来的。
先划清边界:本篇答的是哪一层
站内已经有两篇讲「归档靠不住」的文章,它们和本篇不在同一个决策层,先说清楚免得读混:
- 09-29 那篇 GitHub Actions 供应链复盘,答的是「怎么把依赖锁死」——依赖锁定层。
- 10-17 那篇 xz 后门复盘,答的是「tarball 里的东西和 git 仓库对不上」——产物溯源层。
- 本篇答的是更小也更靠下的一层:不管归档里装的是什么,光是「解压」这一个动作本身,就会把文件写到目录外面去。这是解压执行层,前面两篇都没碰。
如果你只记一句话:锁版本、验哈希解决的是「你拿到的是不是那个包」,而 filter 解决的是「你拿到之后往哪里放」。
复现环境
- Windows 11(NTFS),CPython 3.13.14(MSC v.1944 64 bit)
- 标准库
tarfile/zipfile/shutil,零第三方依赖 - 恶意归档全部用
tarfile.open(mode="w")手工构造 - 一手来源:PEP 706(Petr Viktorin,2023-02-09 创建,Final,Python-Version 3.12)+
docs.python.org/3/library/tarfile.html的 Extraction filters 章节
五种「写到目录外」的方式,逐个实测
我把同一个解包目录设为 <临时根>/work/sub,于是 ../../ESCAPED.txt 的落点理论上是 <临时根>/。结果如下:
| 逃逸手段 | 不写 filter(= fully_trusted) | filter="tar" | filter="data" |
|---|---|---|---|
成员名 ../../ESCAPED.txt | 真的写出来,落到临时根 | 抛 OutsideDestinationError | 抛 OutsideDestinationError |
成员名 /tmp/ABSOLUTE.txt | 写到 C:\tmp\ABSOLUTE.txt(盘符根下) | 剥掉前导 /,落到目录内 | 剥掉前导 /,落到目录内 |
| 符号链接指向目录外 | 静默什么都不做 | 静默什么都不做 | 抛 LinkOutsideDestinationError |
| 硬链接指向目录外 | 抛 KeyError: "linkname ... not found" | 同上 | 抛 LinkOutsideDestinationError |
| FIFO / 字符设备 | 静默跳过 | 静默跳过 | 抛 SpecialFileError |
注意第二行。绝对路径成员在 Windows 上不是「写不出去」,而是 os.path.join(dest, "/tmp/x") 里那个绝对路径直接把 dest 顶掉了,文件落在盘符根下 C:\tmp\。这一条我在实测中真的看到文件生成,然后再删掉。
也注意第三行:filter="tar" 对指向目录外的符号链接既不报错也不建文件,extractall() 正常返回、退出码为 0,但归档里的那个成员就是不存在了。这条比「抛异常」更麻烦,因为它没有信号。
Troubleshooting:5 个真实报错与可复现修复
报错一:`OutsideDestinationError`(加了 filter 之后最常见)
完整信息长这样:
tarfile.OutsideDestinationError: '../../ESCAPED.txt' would be extracted to
'C:\Users\xuany\AppData\Local\Temp\archlab_base_16k82zgj\t1_data\ESCAPED.txt',
which is outside the destination
原因:归档里有带 .. 的成员名,data 过滤器按官方口径「Refuse to extract files whose absolute path (after following symlinks) would end up outside the destination」拒绝了它。
修复:别把它当成 bug 去 try/except 吞掉。这条异常是在告诉你这个包有问题。正确做法是先列出成员审一遍:
import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
for m in tf.getmembers():
print(m.name, "->", m.linkname if (m.issym() or m.islnk()) else "")
报错二:`DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives...`
原文(3.13 下实际输出):
DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives
and reject files or modify their metadata. Use the filter argument to control
this behavior.
原因:PEP 706 原文写明,Python 3.12–3.13 期间不显式传 filter 就会发这条警告,且行为等价于 'fully_trusted';3.14 起默认改为 'data'。
修复:显式写 filter,别用 warnings.simplefilter("ignore") 糊过去——CI 里一旦跑 -W error,这条警告会直接让构建失败:
import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
tf.extraction_filter = tarfile.data_filter # 3.12+ 全局兜底
tf.extractall("dest", filter="data")
命令行也可以用官方自带的 CLI,它是支持 --filter 的:
python -m tarfile -l bundle.tar # 先列成员
python -m tarfile -e bundle.tar dest --filter data # 再按 data 规则解
报错三:符号链接静默消失(不报错、无日志、退出码 0)
现象:归档里明明有 link.sh,解完之后目录里没有它,控制台一行输出都没有。
原因:这是三段代码叠出来的。makelink_with_filter() 先尝试 os.symlink,在 Windows 上会抛 OSError: [WinError 1314] 客户端没有所需的特权(我直接调 os.symlink 复现过);它被 symlink_exception = (AttributeError, NotImplementedError, OSError) 捕获,于是降级成「去找链接目标、复制一份普通文件」;接着 _find_link_target() 找不到目标,抛 ExtractError("unable to resolve link inside archive");最后 _handle_nonfatal_error() 里只有 errorlevel > 1 才 raise,而 TarFile.errorlevel 默认是 1,于是走 self._dbg(1, ...)——而 TarFile.debug 默认是 0,_dbg 在 level > self.debug 时什么都不打印。
修复:默认参数下你收不到任何信号,只能自己核对数量:
import tarfile, os
with tarfile.open("bundle.tar", mode="r:*") as tf:
tf.extractall("dest", filter="data")
want = {m.name for m in tf.getmembers() if m.isfile()}
got = {os.path.relpath(os.path.join(dp, f), "dest").replace("\\", "/")
for dp, dn, fn in os.walk("dest") for f in fn}
print("归档里有但没落盘:", sorted(want - got))
顺带一个反直觉的对照:指向归档内部的符号链接不是消失,而是被降级成「复制一份普通文件」。我造了一个 alias.txt -> real.txt,三种 filter 下解出来都是 islink=False、内容等于 real.txt 的普通文件。PEP 706 里那句「On systems that don't support links, tarfile will, in most cases, fall back to creating regular files」就是这个意思。
报错四:`ValueError: filter 'xxx' not found`
现象:tf.extractall(dest, filter="full_trusted")(少写了一个 ly)→
ValueError: filter 'full_trusted' not found
传非字符串也一样:filter=123 → ValueError: filter 123 not found。
原因:filter 只认三个内置名 'fully_trusted' / 'tar' / 'data',或一个 callable。PEP 706 原文:「Any other string will cause a ValueError.」
修复:先查一遍名字,或者用函数形态让 IDE 和类型检查帮你兜住:
import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
tf.extractall("dest", filter=tarfile.data_filter) # 用函数,不拼字符串
报错五:`errorlevel=0` 时文件悄悄少了,后面的成员照解不误
原因:FilterError 被官方认为是 fatal error,但它仍然受 errorlevel 管辖。我用一个含 a_good.txt / ../b_escape.txt / c_after.txt 三个成员的归档实测:
errorlevel=1(默认):抛OutsideDestinationError,目录里已经有a_good.txt——即「部分解压」,官方文档原话「extractall() may leave the archive partially extracted; it is the user's responsibility to clean up」。errorlevel=0:不抛异常,../b_escape.txt被跳过,c_after.txt继续解出来。而且debug=0时连 stderr 都没有一行;只有debug=1才会打印tarfile: OutsideDestinationError '...' which is outside the destination。
修复:不要为了「跑通」把 errorlevel 调成 0。要容错就显式记账:
import tarfile
skipped = []
def guarded(member, path):
try:
return tarfile.data_filter(member, path)
except tarfile.FilterError as e:
skipped.append((member.name, type(e).__name__))
return None # 返回 None = 跳过该成员
with tarfile.open("bundle.tar", mode="r:*") as tf:
tf.extractall("dest", filter=guarded)
print("被跳过:", skipped)
异常继承关系顺便记一下,写 except 的时候用得上(本机实测 __bases__):TarError ← FilterError ← {AbsolutePathError, OutsideDestinationError, AbsoluteLinkError, LinkOutsideDestinationError, SpecialFileError}。只捕 tarfile.TarError 会把「格式坏了」和「过滤器拒了」混成一件事。
手写「在目录内」的检查为什么总是写错
很多人不用 filter,自己写判断。最常见的写法是 os.path.commonprefix,而它是按字符比较、和路径层级无关。我实测:
| 表达式 | 结果 | 看起来像在目录内吗 |
|---|---|---|
commonprefix(['C:\\tmp\\abc', 'C:\\tmp\\abcd']) | C:\tmp\abc | 像(其实是错的) |
commonpath(['C:\\tmp\\abc', 'C:\\tmp\\abcd']) | C:\tmp | 正确 |
'C:\\tmp\\abcd'.startswith('C:\\tmp\\abc') | True | 像(同样错) |
abc 和 abcd 是两个平级目录,commonprefix 却返回了 C:\tmp\abc,判定成「在里面」。正确写法用 commonpath,并且要先 realpath 化解符号链接:
import os
def is_within(directory, target):
d = os.path.realpath(directory)
t = os.path.realpath(target)
return os.path.commonpath([d, t]) == d
这正是 PEP 706 把 CVE-2007-4559 写进 Motivation 的原因——它的原文是:文档早就写了「never extract archives from untrusted sources without prior inspection」,但「it's not clear what kind of inspection should be done. Indeed, it's quite tricky to do such an inspection correctly」。
filter 改的不只是路径,`data` 还会改元数据
这一条不看文件系统更清楚——直接调 tarfile.data_filter() 看它返回的 TarInfo(本机实测):
| 归档里写的 mode | 经 data 过滤器后 |
|---|---|
0o4755(setuid) | 0o755 |
0o2750(setgid) | 0o750 |
0o1777(sticky) | 0o755 |
0o666 | 0o644 |
目录 0o777 | None(跳过 chmod) |
uid / gid / uname / gname 也一律被置为 None。官方口径是「Clear high mode bits (setuid, setgid, sticky) and group/other write bits」加「Set the owner read and write permissions」。所以从「备份还原」场景切到 data 之后,还原出来的权限和属主本来就不该和原机一致,这不是 bug。
还有一条 filter 完全不管的事:同名重复成员。我在一个归档里塞了三个都叫 dup.txt 的成员(内容分别是 FIRST / SECOND / THIRD),filter="data" 和默认行为解出来都是 THIRD——后解压的覆盖先解压的,没有任何提示。要查就自己数:
from collections import Counter
import tarfile
with tarfile.open("bundle.tar", mode="r:*") as tf:
dup = [n for n, c in Counter(m.name for m in tf.getmembers()).items() if c > 1]
print("重复成员:", dup)
zipfile 是另一套规则,别照搬
zipfile.ZipFile.extractall() 没有 filter 参数(本机实测签名只有 path / members / pwd),因为它从很久以前就自己做成员名清理。我构造了 ../../ESCAPED_ZIP.txt、C:/ESCAPED_ZIPDRV.txt、/abs/ESCAPED_ZIPABS.txt 三个成员,extractall() 之后全部被压进目标目录,一个都没逃出去。
源码里那几行就是它和 tarfile 的分水岭:
drive, root, arcname = os.path.splitroot(arcname)
invalid_path_parts = ('', os.path.curdir, os.path.pardir)
arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
if x not in invalid_path_parts)
不过 zipfile 也不是什么都不丢:我造了一个带 Unix 符号链接位(external_attr = 0o120777 << 16)的成员,解出来是一个 11 字节的普通文件,内容就是字符串 /etc/passwd,不是链接——CPython 的 zipfile 从不创建符号链接。
shutil.unpack_archive() 则两头都占:签名里有 filter=None(3.12 起),不传的时候照样发那条 DeprecationWarning,传 filter="data" 就能正常拦下。另外 ZipFile._ignore_invalid_names 这个类属性默认是 False,即默认不丢弃名称非法的成员,只是把名字压平。
我现在的写法
import tarfile
def safe_extract(archive, dest):
with tarfile.open(archive, mode="r:*") as tf:
names = [m.name for m in tf.getmembers()]
bad = [n for n in names
if n.startswith(("/", "\\")) or ".." in n.split("/")]
if bad:
raise SystemExit(f"拒绝解压,可疑成员名:{bad}")
tf.extractall(dest, filter="data")
mode="r:*" 让 tarfile 自己识别压缩方式;显式 filter="data" 让行为在 3.12 到 3.14 之间保持一致——这一点很关键,否则同一份代码在 3.13 和 3.14 上会得到两套结果。
FAQ
Q:我现在就该把 filter="data" 加上吗?
A:如果你的 Python 是 3.12 或 3.13,加。不加的话 3.14 升级那天默认行为会变,而且你已经先吃了两年 DeprecationWarning。
Q:filter="data" 会让我的备份还原丢权限吗?
A:会。data 会清掉 setuid/setgid/sticky 与组/其他用户写位,并把 uid/gid 置空(见上方实测表)。备份还原场景要的是 filter="tar" 或 fully_trusted,且前提是你信任这个归档。
Q:为什么我加了 filter="data",目录里还是少文件?
A:三种可能:符号链接在 Windows 上静默降级失败(报错三)、errorlevel=0 把拒绝的成员无声跳过(报错五)、以及同名重复成员被后来的覆盖。前两种都不产生异常。
Q:zipfile 要不要一起改?
A:zipfile 没有 filter 参数可用,它自己会压平 ..、盘符和前导斜杠。但解压后仍建议核对成员数量。
本文的边界
- 全部实测在 Windows 11 + CPython 3.13.14 上完成。符号链接相关的降级行为是 Windows 特有的;在 POSIX 上
os.symlink通常成功,那条「静默消失」的链路不会出现,但「链接指向目录外」的危害反而更直接。 - 我没有在 Python 3.14 上实测默认值为
data的行为,3.14 的改动依据来自官方文档的「Changed in version 3.14」条目与 PEP 706。 - 官方自己也不认为 filter 是终点:
docs.python.org原文写的是「Even withfilter='data', tarfile is not suited for extracting untrusted files without prior inspection」,并指出预置过滤器挡不住拒绝服务攻击。 - 本文讨论的是解压动作本身,不涉及归档内容的真实性校验(哈希、签名、来源),那是另一层。
相关阅读:如果你想继续顺着「包靠不住」这条线往下看,可以读 xz 后门复盘:tarball 与 git 仓库对不上,以及 GitHub Actions 供应链投毒复盘。同属 Python 线的还有 PEP 440 版本约束踩坑 与 Python logging 丢日志实测。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):