漏洞

BuildStream:解包为何能写出构建目录

CVE-2026-82331 让旧版 BuildStream 在获取源码时沿符号链接写出暂存目录;使用旧 Python 的构建节点应优先升级到 2.8.1,并核对真正执行解包的环境,源码哈希和后续构建沙箱各有自己的保护范围。

浅暖纸上的归档盒与文件夹,一条链接跨过解包区域的边界,指向外部文件夹。
文章导航

源码包刚下载下来,编译还没开始,构建工具已经可能改动主机上的其他文件。Apache 在 9 月 23 日披露的 CVE-2026-82331 就发生在这个阶段:BuildStream 的 tar 源插件解包时,没有在旧 Python 环境中正确处理符号链接。归档先放下一条指向暂存目录外的链接,后面的普通文件再沿着它写出去,写入权限来自运行 BuildStream 的用户。

这让构建节点的排查顺序很明确。优先检查会接受新源码引用、处理外部项目的 CI 节点,尤其是 Python 低于 3.12、仍运行 BuildStream 2.8.0 或更早版本的节点。当前上游修复目标是 2.8.1。暂时不能更新时,暂停这些节点处理新的、不可信的 tar 来源,保留经过审核的源码哈希;确需继续运行的任务,应把整个 bst 进程放进隔离环境,并收紧它能写入的主机目录。项目原有的编译沙箱覆盖不到此前已经发生的主机解包。

我们认为,这个漏洞最值得检查的是源码准备阶段的权限。很多流水线认真限制编译命令,却让下载、解包和缓存导入使用同一个长期工作的账号。那个账号如果还能改动其他项目、构建脚本或凭据目录,一个归档的影响就会超过当前构建。下面沿着实际调用过程看,这个越界写入是怎样发生的,以及升级后应当验证什么。

1 下载命令为什么会解包

BuildStream 用元素文件描述构建步骤及其源码。对 kind: tar 的来源,url 指向归档,ref 保存归档的 SHA-256。工具获取归档后,还要把里面的文件整理进按内容寻址的源码缓存。后面的构建从这个缓存取得源文件,所以“获取源码”包含了文件准备工作。

以修复前的 2.8.0、提交 979d31ae9861e8ec1d0a42a7889ce98c78d821f0 为观察点,ElementSources._fetch_source()先检查源码缓存。没有可用缓存时,它获取原始归档,然后调用 SourceCache.commit()。这个函数的 69–81 行创建 staging-temp 临时目录,在其中调用源插件,最后才把文件导入缓存。这里的目录属于主机文件系统。

获取或复用原始归档
  → SourceCache.commit()
  → 主机临时目录中的 TarSource.stage()
  → 把解包结果导入源码缓存
  → 后续构建使用这些文件

Source._stage()把普通目录路径交给插件的 stage()。最终解包发生在 TarSource.stage() 的 138–159 行。上游为本漏洞加入的回归测试,也在 bst source fetch 这一步期待错误,无须先执行构建命令。

缓存会影响这段代码是否在某次任务中运行。命中已有的内容缓存,或者直接取得远程源码缓存时,工具可以跳过当前节点上的原始 tar 解包。因此,一次顺利完成的热缓存构建不足以验收这次修复。检查应覆盖确实需要重新准备源码的路径,并把负责生成共享缓存的节点一起纳入升级范围。

tar 归档保存着有顺序的成员。成员可以是目录、普通文件,也可以是符号链接。符号链接保存目标路径;以后访问包含这条链接的路径时,文件系统会继续沿目标解析。于是,解包过程中刚创建的成员,可以改变后续成员的实际写入位置。

用图中的两个成员来理解就够了。第一个成员叫 link,指向暂存目录之外的 /outside;第二个成员叫 link/file.txt。第二个名字可以拼成 /stage/link/file.txt,开头也确实是 /stage。等第一条链接已经存在,打开这个路径时,落点就变成了 /outside/file.txt。这里的路径只是解释用的示例。

2.8.0 的 _assert_safe()只对拼接后的路径调用 abspath(),再检查字符串是否以暂存目录开头。abspath()可以整理相对路径中的点号,却不会解析文件系统里的符号链接。函数还单独检查了硬链接的 linkname;符号链接目标没有进入同一检查。源码中的注释认为链接进入沙箱以后不会造成危害,这个假设遗漏了解包本身就会使用链接。

# BuildStream 2.8.0,_assert_safe() 节选
final_path = os.path.abspath(os.path.join(target_dir, member.path))
if not final_path.startswith(target_dir):
    raise SourceError(...)

检查时点进一步放大了这个缺口。stage()先遍历所有成员,完成预检查,再把整个列表交给 extractall()。Python 低于 3.12 时,这次调用没有传入解包过滤器。预检查看到的是名字列表;真正写入时,文件系统中已经增加了前面创建的链接。仅把路径检查做在列表生成阶段,无法反映这段变化。

2.8.0 使用 Python 标准库完成实际写入。为便于查看这一步,可参考 2.8.1 随包附带实现中 makefile() 的 2588–2602 行:open(targetpath, "wb") 打开目标文件,随后复制内容。这段节选展示底层写入方式,来自修复版附带的代码。操作系统正常的路径解析规则在打开文件时生效,现有文件也可能被覆盖。能写到哪里,受 BuildStream 进程账号和所在容器、挂载、文件权限约束;公告确认的是这种主机文件写入能力,具体能否进一步影响执行,需要检查实际落点及后续使用方式。

暂存目录内的 link 指向目录外,后续 link/file.txt 会沿它解析;旧版先检查名字,2.8.1 在写入前检查真实路径并拒绝越界,合法符号链接仍可保留。

左右滑动查看图中说明。

图 1:同一个归档中,先建立的链接会改变后续文件的路径解析。修复后的拒绝发生在越界成员写入之前;目录内合法的链接仍可随源码保留。图中的路径用于说明机制。

3 2.8.1 每次写入前都重新检查

2.8.1 保留了解包前的成员整理,比如移除 base-dir 前缀和跳过设备节点,把安全判断交给真正的解包过滤器。修复后的 stage()始终传入 filter=self._extract_filter。底层 extractall()处理每一个成员时,先调用过滤函数,确认可以接受以后才执行写入。

过滤器把目标目录和拟写入路径解析为真实路径,再用 commonpath() 判断后者是否仍在目标目录内。_get_filtered_attrs() 的这部分代码因此能看到先前成员留下的符号链接,也按路径组成部分比较边界。发现越界时抛出 OutsideDestinationError,插件把解包失败报告为 SourceError,该来源不会作为成功结果导入缓存。

维护者还做了一项实际取舍:保留源码所需要的符号链接,包括某些指向绝对路径的链接。_extract_filter() 的 201–207 行对符号链接使用 tar_filter,对其他成员使用更严格的 data_filter。后者会额外约束硬链接目标、文件类型和属性。无论选哪条分支,成员自身的实际写入路径都要留在解包目录内。

# BuildStream 2.8.1,_extract_filter() 的分支
if member.issym():
    return tarfile.tar_filter(member, target_dir)
else:
    return tarfile.data_filter(member, target_dir)

所以,验收时看到归档里的绝对符号链接仍然存在,可以是正常结果。需要拒绝的是随后借它写出目录的成员。上游同时保留了 test_symlinks 的合法链接断言,并新增 test_symlink_escape,要求获取源码时报告路径位于目标目录之外。只测试“所有链接都失败”,会误把破坏兼容性当成修复成功。

旧 Python 的支持也一并补齐。导入分支在 Python 3.12 及以上使用标准库,在更早的受支持版本使用随 BuildStream 提供的 _tarfile.py。这份实现从 Python 3.11.16 引入,并兼容没有 os.path.ALLOW_MISSING 的环境。2.8.1 自身要求 Python 3.10 及以上;还停留在更老解释器的节点,需要把运行环境迁移纳入更新。

4 先确认真正运行 bst 的 Python

Apache 公告列出的产品范围截至 BuildStream 2.8.0,并称 BuildStream 2.3.0 及以上配合 Python 3.12 及以上时,已有过滤功能可以阻断这次逃逸。本文对照了 2.8.0 与 2.8.1 的源码,没有逐个验证这段历史版本范围。资产排查应保留公告给出的组合条件,修复验收则以 2.8.1 或经包维护者确认的回移补丁为目标,避免仅凭解释器版本关闭问题。同一份项目配置,在不同节点上可能走到不同的解包路径。

实际运行组合本漏洞的判断与动作
BuildStream 2.3.0–2.8.0;Python < 3.12属于公告中的受影响组合,优先升级到 2.8.1;确认新的环境实际用于获取源码。
BuildStream 2.3.0–2.8.0;Python ≥ 3.12Apache 将其列为已有过滤器缓解的组合,本文未逐版验证。建议统一更新到 2.8.1,并完成实际解包验收。
BuildStream < 2.3.0公告的 Python 3.12 缓解条件没有覆盖这些版本。按所在发行版的修复说明迁移,不能仅升级解释器就认定问题解决。
BuildStream 2.8.1;其支持的 Python ≥ 3.10已纳入完整修复,是核查时的当前上游目标。发行版回移补丁以包维护者的确认和包版本为准。

应当在 CI 作业实际使用的虚拟环境、容器或启动脚本中查看版本。下面的 python 必须是执行 bst 的那个解释器;登录主机后随手运行的另一个 Python,不能代表任务环境。输出模块路径,也有助于发现 PATH 指向旧虚拟环境、镜像更新后任务仍使用旧层的情况。

bst --version
python -c "import sys, buildstream; from importlib.metadata import version; print(sys.executable); print(sys.version); print(version('BuildStream')); print(buildstream.__file__)"

Python 官方已经把解包过滤功能回移到一些旧版本,因此它建议用 hasattr(tarfile, 'data_filter') 判断功能是否存在。BuildStream 2.8.0 自己的分支却直接比较 sys.version_info。解释器里“有这个函数”和应用在解包时“调用了这个函数”,是两项需要分别确认的事实。安装 Python 的安全更新仍然必要,这次应用修复也应按 BuildStream 的公告落实。

源码哈希继续有用。DownloadableFileSource.track() 与 fetch()分别记录新内容的哈希、核对下载内容是否符合现有引用。已经审核并固定的归档被途中替换,哈希不匹配会让获取失败。若项目主动接受了一个恶意归档及其正确哈希,完整性校验仍会通过,解包器随后还需要约束它能修改的目录。审核新的 ref 变更,直接影响哪些字节能够到达这一步。

截至 9 月 26 日的发行版记录也需要单独看。Debian列出 bookworm 的 1.6.8-2、trixie 的 1.6.9-2 受影响,forky、sid 的 2.8.1-1 已修复;Ubuntu把 24.04、22.04、20.04 的包标为待评估,26.04 标为发行版中不包含。维护者尚未确认的包不能写成已修复,也不宜为了版本号在系统环境里直接混装 pip 包。沿原有包渠道更新,或迁移到经过验证的新构建镜像,更容易确认依赖与回退范围。

公开评分存在明显差异。Apache 的严重程度是 moderate,CISA ADP 给出 CVSS 3.1 的 9.8;NVD截至本次核查仍为 Awaiting Analysis,展示的 9.8 来自 CISA ADP。9 月 25 日版 CISA KEV 未收录此条目,CVE 记录中 CISA ADP 于 9 月 23 日给出的 SSVC 为 Exploitation: none。发现者在 9 月 24 日公开了技术说明和演示程序,这些事实分别描述评分、已知利用目录和公开技术材料,不能合并成一个受害规模结论。实际排期仍应优先照顾会摄入新来源、账号权限较大的构建节点。

5 验收要覆盖真正解包的路径

更新构建镜像或虚拟环境后,重新核对启动身份,再用新建的隔离缓存完成一次可信项目的源码获取和构建。保留旧缓存可以减少正常迁移成本,验收本身则需要一条确实经过解包的路径。常规源码中的目录结构、可执行位、硬链接及项目需要的符号链接都应保持预期,构建结果也应符合已有基线。

对维护自编译 BuildStream 包的团队,上游的两个链接测试适合加入修复验证:合法链接的内容保持不变;越界用例在 source fetch 阶段被拒绝,错误明确指向目标目录之外。测试应在没有生产凭据、只含测试目录的隔离环境中执行,并另行检查测试目录外的哨兵文件未被改动。上游新增断言检查了命令失败和错误文本;对生产包验收,保留文件未变化这一观测,可以直接回答拒绝发生得是否足够早。

再加一项与漏洞触发不同的检查:固定一个可信归档的引用,改变测试副本内容,用另一份空缓存获取时,必须因哈希不匹配而失败。它验证源码固定机制仍在工作。随后恢复原归档,正常获取与构建应成功。这样既覆盖解包边界,也保留了原有来源管理的正反对照。

临时措施的代价要落实到任务上。暂停新来源会推迟依赖更新;把整个 bst 放进容器或虚拟机,需要确认源码缓存、输出目录与凭据挂载。容器里挂载了可写的主机工作区或共享凭据,进程仍可能通过这些挂载影响主机数据。永久修复完成、上述检查通过后,才能恢复被暂停的新来源流程。更新失败时继续保持限制;回退目标应为已确认包含本次补丁的镜像或发行版包,不能为了恢复任务把旧 Python 加旧 BuildStream 的组合重新投入使用。

若节点在修复前已经处理过可疑来源,升级还需要配合调查。保留项目引用变更、归档哈希、作业日志和节点写入记录,重点核查运行账号能修改的构建配置、其他项目及凭据相关文件。发现实际越界改动后,重建受影响节点、撤销相关凭据,并复核此后产出的构建制品。未保存足够记录时,应把历史影响列为尚未确认,不能用一次修复后的成功构建替代这项调查。

本文的机制结论来自固定源码、Apache 公告、发现者说明和上游回归测试。我们在 Windows、Python 3.12.14 上单独调用了固定版本中抽取的过滤函数,确认普通文件与绝对符号链接元数据可接受,越界相对路径与越界硬链接元数据被拒绝;本机缺少创建符号链接的权限,沿已有链接解析的动态检查未执行,也没有运行完整 BuildStream 漏洞复现。发现者报告的测试环境为 Linux、Python 3.10。两种验证范围在这里分别保留。

这个补丁给构建系统留下了一个具体的检查位置:任何把外部字节变成主机文件的步骤,都需要在写入时确认落点。源码固定负责保住选定的内容,解包器负责限制内容能写到哪里,构建隔离再约束后面的命令。把这三处放回各自的执行阶段,才容易看见那些发生在“正式构建”之前的权限。

研究依据

研究依据固定源码与上游回归测试,另做四项过滤函数元数据检查;完整 BuildStream 复现与已有链接动态检查未运行,具体限制见正文。官方状态核对截至 2026-09-26 15:53 UTC。

来源Apache BuildStream、Zero Science Lab、CVE 与发行版记录

证据置信度 高

6证据与来源

6.1时间线

  1. 发现者记录问题

    Zero Science Lab 的披露时间线记录发现日期,随后与 Apache 协调修复。

  2. 修复合入

    PR 2192 合入自带 tar 实现、统一过滤和符号链接逃逸回归测试。

  3. 2.8.1 发布记录

    GitHub Release 发布于 17:40 UTC;发现者时间线按 9 月 23 日记录厂商发布。

  4. Apache 公布 CVE

    公告解释 Python 条件、源码哈希缓解及 2.8.1 修复建议。

  5. 发现者公开技术说明

    Zero Science Lab 公布 ZSL-2026-6005 及 Linux、Python 3.10 测试环境。

6.2来源与材料

  1. Apache 维护者的公开安全公告https://www.openwall.com/lists/oss-security/2026/09/23/9
  2. Apache 官方邮件列表原始公告https://lists.apache.org/thread/9b342631x7bvtyg0pq7zgywtl2cmy34v
  3. Apache CNA 与 CISA ADP 原始记录https://cveawg.mitre.org/api/cve/CVE-2026-82331
  4. 发现者公告 ZSL-2026-6005https://www.zeroscience.mk/advisories/ZSL-2026-6005.html
  5. PR 2192 合并提交及修复历史https://github.com/apache/buildstream/commit/342f216656b7f1a594cbb78f8f3be686cc136c2f
  6. BuildStream 2.8.1 当前上游发行https://github.com/apache/buildstream/releases/tag/2.8.1
  7. Python tarfile 解包过滤规则与限制https://docs.python.org/3/library/tarfile.html#extraction-filters
  8. Debian 包修复状态https://security-tracker.debian.org/tracker/CVE-2026-82331
  9. Ubuntu 包评估状态https://ubuntu.com/security/CVE-2026-82331
  10. NVD 分数归属及变更历史https://nvd.nist.gov/vuln/detail/CVE-2026-82331