–end-of-options–end-of-options
作者发现了一个 git flag,起初以为是 LLM 产生的幻觉。文章围绕这个看似不存在的 git 选项展开,验证其真实性并探讨其用途。
Andrew Nesbitt
上周我在阅读一个包管理器 CVE 的修复代码时,偶然发现了一个我不知怎么从未注意到的 git 标志:--end-of-options。我的第一反应是某个 LLM 幻觉编造出来的,但它实际上记录在 gitcli(7) 中,是在 2019 年 11 月的 git 2.24.0 版本中添加的,它之所以存在是因为 git 已经将 -- 用于其他用途了。
在大多数 Unix 工具中,-- 标志着选项解析的结束,因此 rm -- -f 会删除一个名为 -f 的文件,而不是传递 force 标志。Git 早期就将 -- 重新用于分隔版本(revisions)和路径规范(pathspecs),因为单独的 git log foo 在名为 foo 的分支和名为 foo 的文件之间存在歧义,这两种解释中的一种需要一个标记:git log main -- README.md 表示在 main 分支上触及该文件的提交。这使得版本位置没有终止符,因此如果脚本运行 git log "$rev" 且 $rev 以连字符开头,git 会将其解析为选项。
来自引入 --end-of-options 的提交:
但这对于版本解析器不起作用,因为 -- 在那里已经有了意义:它用于分隔版本和路径规范。因此我们需要其他标记来分隔选项和版本。
在 git 中,-- 和 --end-of-options 是不同的东西,将它们视为可互换是我在好几个地方都见过的错误。在 git clone -- "$url" 中将 -- 放在 URL 前面是可行的,因为 clone 遵循 POSIX 约定。在引用(ref)后面加上尾随的 --,如 git checkout "$ref" --,会将 $ref 标记为版本而不是文件名,但仍然会首先将其作为选项读取。安全地传递不受信任的版本意味着要写成 git log --end-of-options "$rev" -- "$path",两个标记各自承担不同的职责。
对新标志的支持是按子命令逐步提供的,而不是一次性全部支持:git rev-parse 直到一年后的 2.30.0 版本才获得支持,因为它有自己的手动编写的参数解析器,而 git checkout 和 git reset 直到 2024 年 2 月的 2.43.1 版本才接受它,因为它们自己解析 --,并且最初的实现将 --end-of-options 留在了参数列表中,导致它们的解析器拒绝了它。
参数注入
Git、hg 和 ssh 都提供了有文档记录的选项,其目的是运行调用者指定的命令。git clone 接受 --upload-pack=<cmd> 来指定服务器端的二进制文件,任何 git 调用都接受 -c core.sshCommand=<cmd> 来覆盖其连接方式。Mercurial 在任何子命令上接受 --config=alias.<subcmd>=!<shell>,这会将你正在运行的子命令重新定义为任意 shell 脚本。ssh 接受 -oProxyCommand=<cmd>。这些都是有文档记录的功能,当包装程序将不受信任的字符串传递到参数列表中时,它们就会成为攻击原语。
这种故障模式有其专属的 CWE,即 CWE-88 参数注入,它与命令注入不同,因为不涉及 shell:包装程序构建一个 argv 数组并直接调用 exec,正如每篇“不要使用 system()”的指南所建议的那样,该数组完好无损地到达 git,然后 git 将其中一个参数解析为选项,因为它以连字符开头。docker build 中的 CVE-2019-13139 就是一个典型的例子:使用 Go 的 os/exec 包,一个 argv 数组,没有 shell,以及一个 git-context URL,其 #ref:dir 片段作为 --upload-pack=<cmd> 传递到了 git fetch origin <ref>。
2017年8月的同一天,CVE-2017-1000117 (git)、CVE-2017-1000116 (Mercurial)、CVE-2017-9800 (Subversion) 和 CVE-2017-12836 (CVS) 被同时披露,这一模式在四个版本控制系统中得到了印证。它们都将 URL 的主机名作为参数传递给 ssh,而以 -oProxyCommand= 开头的主机名则变成了一个 ssh 选项。Phabricator 在针对此次披露的复盘报告中指出,在三个仍在积极维护的工具中,只有 Subversion 在其修复方案中真正在主机名前添加了 --;git 和 Mercurial 则是验证了主机名格式,部分原因是并非所有 ssh 实现都支持 --。同一份报告还将 -- 机制本身称为“默认不安全”,因为缺少它的代码看起来是正确的,并且一直能正常工作,直到某个参数以连字符开头时才会出问题。
包管理器
包管理器通常将 git URL 或引用(ref)作为数据接收并传递给子进程:例如 Gemfile 中的 gem 'foo', git: '...',package.json 中的 github:user/repo#ref,以及 pyproject.toml、Cargo.toml、mix.exs、Package.swift、pubspec.yaml、conanfile.py 和 go.mod 中的等效写法。这些 URL 和引用通常存在于清单文件、锁文件或传递依赖的元数据中。
在我检查的十九个包管理器中,有十七个默认或仅通过 fork git 二进制文件来执行操作。默认使用库的两个是 Cargo 和 Poetry:Cargo 使用 libgit2,但可以通过开启 net.git-fetch-with-cli 设置来改为 fork 进程;Poetry 在 1.2.0 版本中切换到了 dulwich,并提供了 system-git-client 设置作为回退方案。Nix 在读取本地仓库时使用 libgit2,但在拉取代码时会 fork git 进程,因为 libgit2 缺乏对 git-credential 助手的支持。
针对这类包管理器已发布的 CVE 包括 CVE-2021-43809 (Bundler)、CVE-2021-29472 和 CVE-2022-24828 (Composer)、CVE-2022-36069 (Poetry)、CVE-2023-5752 (pip)、CVE-2022-21223 和 CVE-2022-24440 (CocoaPods),以及 CVE-2025-68119 (Go)。促成其中几个 2022 年 CVE 条目的 Snyk 研究记录在此,而 Sonar 则维护着一个按二进制文件分类的危险选项目录。
在 fork git 的十七个包管理器中,只有一个使用了 --end-of-options:Go 的 cmd/go。它在 2019 年 6 月作为常规加固措施,在仓库 URL 之前添加了 --。到了 2026 年 1 月,事实证明这还不够,于是全面添加了 --end-of-options 作为 CVE-2025-68119 的修复方案,同时引入了 HGPLAIN=+strictflags,该选项自 2017 年的 hg 4.4.2 版本起就限制了 Mercurial 的早期选项解析。提交信息的结尾写道:“我们或许应该跟进一个更具结构性的修改,以降低未来意外重新引入这些问题的风险,但目前这已经解决了手头的问题。”
最低 git 版本
其他对参数列表进行防护的包管理器,要么使用 --,要么对输入进行前导连字符检查。观察每个防护措施的引入时间可以发现,大多数都是作为已报告漏洞的修复方案引入的,而非在最初的实现中就存在。Bundler 在 git clone 的 URL 前添加的 -- 就是 CVE-2021-43809 的补丁。cocoapods-downloader 中对前导连字符的拒绝逻辑是在 2022 年 3 月的十天内通过三次提交落地的,与 CVE-2022-21223 的披露时间相吻合。Poetry 的防护措施于 2021 年 9 月引入,一年后才分配了 CVE 编号,随后六个月它切换到了 dulwich。vcpkg 是我发现的唯一例外,从编写 git 仓库支持的第一天起它就包含了 --。
Composer 针对 CVE-2022-24828 的安全公告解释了为什么这些工具几乎都没有使用 --end-of-options:公告指出该标志是正确的修复方法,但随后表示 Composer 支持早于该标志的 git 版本,因此补丁改为拒绝以连字符开头的分支名。vcpkg 的 git 集成中有一条注释说明 git 的最低版本要求为 2.7.4。Homebrew 在 Linux 上的 HOMEBREW_MINIMUM_GIT_VERSION 为 2.7.0,这是在 2018 年设定的。
打包了 git 2.14.3 的 Amazon Linux 2 上个月已停止支持,因此这些最低版本要求所追踪的发行版直到现在才逐渐被淘汰。包含 git 2.17.0 的 Ubuntu 18.04 的扩展支持将持续到 2028 年。Ubuntu 20.04 的扩展支持持续到 2030 年,其打包的版本是 2.25.1,该版本足够新,可以在 git fetch 上接受 --end-of-options,但又足够旧,会在 git rev-parse 上拒绝它。依赖该标志意味着对于大多数子命令需将最低 git 版本提升至 2.24.0,对于 rev-parse 需提升至 2.30.0,或者对于 checkout 和 reset 需提升至 2.43.1,并且会失去那些仍在使用发行版自带 git 的用户。
Git 库
libgit2、gitoxide、go-git、JGit 和 dulwich 都实现了足够多的 git 通信协议,可以在进程内进行 clone 和 fetch,没有 argv 边界,因此没有可供注入的参数列表。Jujutsu 使用 gitoxide 进行 git 互操作,并且在参数注入类别中没有已发布的 CVE;它迄今为止的两个安全公告分别是路径遍历问题以及从库中继承的缺失 SHA-1 碰撞检查。go-git 有一个此类漏洞,CVE-2025-21613,它专门针对 file:// 传输,这是 go-git 中唯一会启动 git 二进制文件的代码路径。
我在关于包管理器 CWE 的文章中指出,这是用一个问题换取另一个问题,因为捆绑的 git 实现必须追踪上游 git 发布的每一个 checkout 安全修复,而 libgit2 和 JGit 都已经历过多轮此类修复。这是一个实实在在的代价,但它是一系列需要应用的具体补丁,而不是必须在每个调用点永远记住的检查。
撰写本文促使我向 Homebrew 提交了一个 PR,将其最低 git 版本提升至 2.30.0,并在 clone、remote set-url 和 ls-remote 中的 URL 之前,以及 rev-parse 中的 refs 之前添加 --end-of-options。checkout 和 reset 调用保持原样,因为覆盖它们需要 2.43.1 的最低版本,该版本于 2024 年 2 月发布,时间太近,以至于仍然超出了几个受支持发行版的版本。
需要完整排版与评论请前往来源站点阅读。