git --end-of-options
摘要
一篇关于 Git 的 `--end-of-options` 标志的深入文章,介绍其历史、重要性以及在防止参数注入漏洞(包括相关 CVE)方面的作用。
<p><a href="https://lobste.rs/s/cxz7vd/git_end_options">评论</a></p>
查看缓存全文
缓存时间: 2026/07/21 12:39
# –end-of-options 来源:https://nesbitt.io/2026/07/21/end-of-options.html
上周我在阅读某个包管理器 CVE 的修复方案时,发现了一个我从未注意过的 git 标志:`\-\-end\-of\-options`。我的第一反应是某个大语言模型产生了幻觉,但它确实被记录在 `gitcli(7)` 手册页中(https://git-scm.com/docs/gitcli),于 2019 年 11 月在 git 2.24.0 中加入,它之所以存在是因为 git 已经将 `\-\-` 用于其他用途。在大多数 Unix 工具中,`\-\-` 标志着选项解析的结束,因此 `rm \-\- -f` 会删除名为 `-f` 的文件,而不是传递强制标志。而 git 很早就将 `\-\-` 重新用于分隔修订版本与路径规格,因为 `git log foo` 本身对名为 `foo` 的分支和名为 `foo` 的文件存在歧义,因此需要某种标记来区分两种解读:`git log main \-\- README.md` 表示在 `main` 分支上影响该文件的提交。但这导致修订位置缺少终止符,因此如果脚本执行 `git log "$rev"` 且 `$rev` 以短横线开头,git 就会将其解析为选项。
根据引入 `\-\-end\-of\-options` 的提交(https://github.com/git/git/commit/19e8789b236dfe33667747d5523d6689bb59b5ef)所述:
> 但这对于修订解析器不起作用,因为 `\-\-` 在那里已经有含义了:它分隔修订版本与路径规格。所以我们需要另一种标记来分隔选项与修订版本。
`\-\-` 和 `\-\-end\-of\-options` 在 git 中是不同的东西,将它们视为可互换是我在多个地方看到的一个错误。在 `git clone \-\- "$url"` 中,在 URL 之前放置 `\-\-` 是有效的,因为 `clone` 遵循 POSIX 约定。在引用之后放置 `\-\-`,例如 `git checkout "$ref" \-\-`,将 `$ref` 标记为修订版本而非文件名,但仍然会先将其作为选项解析。安全地传递不受信任的修订版本意味着要写 `git log \-\-end\-of\-options "$rev" \-\- "$path"`,两个标记各司其职。
对新标志的支持是逐步添加到各个子命令中的,而非一次性完成:`git rev-parse` 只在 2.30.0 版本中才获得支持(https://github.com/git/git/commit/3a1f91cfd9),比初始发布晚了一年,因为它有自己的手写参数解析器;而 `git checkout` 和 `git reset` 直到 2024 年 2 月的 2.43.1 版本(https://github.com/git/git/commit/9385174627)才接受该标志,因为它们自己解析 `\-\-`,而初始实现将 `\-\-end\-of\-options` 留在了参数列表中,被它们的解析器拒绝。
## 参数注入
Git、Hg 和 SSH 都提供了具有文档说明用途的选项,用于运行调用者指定的命令。`git clone` 接受 `\-\-upload\-pack=` 来指定服务端二进制文件,任何 `git` 调用都接受 `-c core.sshCommand=` 来覆盖其连接方式。Mercurial 在任何子命令上都接受 `\-\-config=alias.\!=`,这会将当前运行的子命令重新定义为任意的 shell 脚本。`ssh` 接受 `-oProxyCommand=`。这些都是有文档说明的功能,但当包装程序将不受信任的字符串传递到参数列表时,它们就变成了攻击原语。这种失败模式有自己的 CWE 编号:CWE-88(https://cwe.mitre.org/data/definitions/88.html),参数注入,它与命令注入不同,因为不涉及 shell:包装程序构建 argv 数组并直接调用 `exec`,完全按照每个“不要使用 `system()`”指南所建议的方式,该数组原封不动地到达 git,然后 git 解析其中一个参数时,因为它以短横线开头而将其视为选项。
`docker build` 中的 CVE-2019-13139(https://staaldraad.github.io/post/2019-07-16-cve-2019-13139-docker-build/)是一个清晰的例子:Go 的 `os/exec` 包,一个 argv 数组,没有 shell,以及一个 git 上下文的 URL,其 `#ref:dir` 片段作为 `\-\-upload\-pack=` 到达 `git fetch origin`。该模式在 2017 年 8 月的同一天在四个版本控制系统中得到演示,当时 CVE-2017-1000117(https://nvd.nist.gov/vuln/detail/CVE-2017-1000117)(git)、CVE-2017-1000116(https://nvd.nist.gov/vuln/detail/CVE-2017-1000116)(Mercurial)、CVE-2017-9800(https://subversion.apache.org/security/CVE-2017-9800-advisory.txt)(Subversion)和 CVE-2017-12836(https://nvd.nist.gov/vuln/detail/CVE-2017-12836)(CVS)一起被披露。每个系统都将 URL 的主机名作为参数传递给 `ssh`,而以 `-oProxyCommand=` 开头的主机名就变成了 ssh 选项。Phabricator 在披露后的事后分析(https://web.archive.org/web/20251216145944/https://secure.phabricator.com/T12961)中指出,在三个仍在积极维护的工具中,只有 Subversion 在其修复中在主机名之前添加了 `\-\-`;git 和 Mercurial 则改为验证主机名格式,部分原因是并非所有的 SSH 实现都支持 `\-\-`。同一份分析报告将 `\-\-` 机制本身称为“默认不安全”,因为没有它的代码看起来正确且运行良好,直到某个参数以短横线开头。
## 包管理器
包管理器通常将 git URL 或引用作为数据,并将其传递给子进程:在 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 和引用出现在清单、锁定文件或传递依赖项的元数据中。在我检查的 19 个包管理器 ¹(https://nesbitt.io/2026/07/21/end-of-options.html#fn:survey)中,有 17 个默认或唯一的方式是 fork `git` 二进制文件。默认使用库的两个是 Cargo(使用 libgit2(https://libgit2.org/),并有一个可选的 `net.git-fetch-with-cli` 设置来改为 fork)和 Poetry(在 1.2.0 版本(https://github.com/python-poetry/poetry/commit/ad1b0938)中切换到 dulwich(https://www.dulwich.io/),并有一个 `system-git-client` 设置作为回退)。Nix 使用 libgit2 读取本地仓库,但在获取时 fork `git`,因为 libgit2 缺乏对 git 凭据辅助程序的支持(https://github.com/NixOS/nix/blob/3aff4dc5edf30998d64eec024de186ac2d6fb5ea/src/libfetchers/git-utils.cc#L639-L641)。
针对此类包管理器发布的 CVE 包括 CVE-2021-43809(https://github.com/advisories/GHSA-fj7f-vq84-fh43)(Bundler)、CVE-2021-29472(https://github.com/composer/composer/security/advisories/GHSA-h5h8-pc6h-jvvx)和 CVE-2022-24828(https://github.com/composer/composer/security/advisories/GHSA-x7cr-6qr6-2hh6)(Composer)、CVE-2022-36069(https://github.com/advisories/GHSA-9xgj-fcgf-x6mw)(Poetry)、CVE-2023-5752(https://github.com/advisories/GHSA-mq26-g339-26xf)(pip)、CVE-2022-21223(https://github.com/advisories/GHSA-g397-v4w5-4m79)和 CVE-2022-24440(https://github.com/advisories/GHSA-7627-mp87-jf6q)(CocoaPods),以及 CVE-2025-68119(https://pkg.go.dev/vuln/GO-2026-4338)(Go)。产生其中几个 2022 年条目的 Snyk 研究在此处有详细说明(https://snyk.io/blog/argument-injection-when-using-git-and-mercurial/),而 Sonar 维护着每个二进制文件危险选项的目录(https://sonarsource.github.io/argument-injection-vectors/)。
在这 17 个 fork git 的包管理器中,恰好有一个使用了 `\-\-end\-of\-options`:Go 的 `cmd/go`。它在 2019 年 6 月作为一次通用的强化措施,在仓库 URL 之前添加了 `\-\-`(https://github.com/golang/go/commit/55d31e16c1)。在 2026 年 1 月,这被证明是不够的,因此作为 CVE-2025-68119 的修复,全面添加了 `\-\-end\-of\-options`(https://github.com/golang/go/commit/94a1296a457387d1fd6eca1a9bcd44e89bdd9d55),同时添加了 `HGPLAIN=+strictflags`,自 2017 年的 hg 4.4.2(https://wiki.mercurial-scm.org/WhatsNew/Archive#Mercurial_4.4.2_.282017-12-01.29)以来,该标志已限制了 Mercurial 的早期选项解析。提交消息结尾写道:“我们可能应该跟进一个更结构化的更改,以使其在未来更难意外地重新引入这些问题,但就目前而言,这解决了手头的问题。”
## 最低 git 版本
其他对参数列表进行保护的包管理器使用 `\-\-` 或在输入上进行前导短横线检查,从添加每个保护的时间来看,大多数是在报告了漏洞后才添加的,而非在最初实现时。Bundler 的 `git clone` 中的 `\-\-` 正是 CVE-2021-43809 的补丁(https://github.com/ruby/rubygems/commit/90b1ed8b9f)。cocoapods-downloader 中拒绝前导短横线的代码是在 2022 年 3 月的十天内通过三次提交(https://github.com/CocoaPods/cocoapods-downloader/commit/35340f4b)添加的,与 CVE-2022-21223 的披露时间吻合。Poetry 的保护是在 2021 年 9 月添加的(https://github.com/python-poetry/poetry-core/commit/cc84be6),CVE 在一年后分配,而切换到 dulwich 是在那六个月之后。vcpkg 是我发现的例外,其中 `\-\-` 从 git 注册表支持编写的那天起(https://github.com/microsoft/vcpkg-tool/commit/8cde8a06)就存在了。
Composer 关于 CVE-2022-24828 的安全公告(https://blog.packagist.com/cve-2022-24828-composer-command-injection-vulnerability/)解释了为什么这些工具几乎都不使用 `\-\-end\-of\-options`:它将该标志列为正确的修复,但随后指出 Composer 支持早于该标志的 git 版本,因此补丁改为拒绝前导短横线的分支名称。vcpkg 的 git 集成有一个注释,说明最低 git 版本为 2.7.4。Homebrew 的 `HOMEBREW_MINIMUM_GIT_VERSION` 在 Linux 上是 2.7.0,于 2018 年设置(https://github.com/Homebrew/brew/commit/7aa9956934)。打包了 git 2.14.3 的 Amazon Linux 2 在上个月达到了生命周期终点,因此这些最低版本所对应的发行版现在才逐渐过时。带有 git 2.17.0 的 Ubuntu 18.04 将获得扩展支持直到 2028 年。带有 2.25.1 的 Ubuntu 20.04(扩展支持到 2030 年)足够新,可以在 `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(https://github.com/GitoxideLabs/gitoxide)、go-git(https://github.com/go-git/go-git)、JGit(https://github.com/eclipse-jgit/jgit)和 dulwich 都实现了足够多的 git 线路协议,可以在进程内完成克隆和获取,没有 argv 边界,因此也没有可注入的参数列表。Jujutsu(https://github.com/jj-vcs/jj)使用 gitoxide 进行 git 互操作,并且没有在参数注入类别中发布过 CVE;迄今为止它的两个安全公告分别是路径遍历和从库继承的缺失 SHA-1 碰撞检查。go-git 有一个(https://github.com/go-git/go-git/security/advisories/GHSA-v725-9546-7q7m),即 CVE-2025-21613,它专门出现在 `file://` 传输协议上,这是 go-git 中唯一生成 `git` 二进制文件的代码路径。
我在关于包管理器 CWE 的帖子(https://nesbitt.io/2026/05/04/package-manager-cwes.html)中指出,这是用一个问题换取另一个问题,因为捆绑的 git 实现必须跟踪上游 git 发布的每一个 checkout 安全修复,而 libgit2 和 JGit 都经历过多轮这样的修复。这是一个真实的成本,但它是一系列要应用的具体补丁,而不是在每个调用点都需要永远记住的检查。
写这篇文章促使我向 Homebrew 提交了一个 PR(https://github.com/Homebrew/brew/pull/23223),将其最低 git 版本提高到 2.30.0,并在 `clone`、`remote set-url` 和 `ls-remote` 中的 URL 之前,以及在 `rev-parse` 中的引用之前添加 `\-\-end\-of\-options`。`checkout` 和 `reset` 的调用被保留,因为覆盖它们需要 2.43.1 的最低版本(2024 年 2 月发布),而这仍然比几个受支持的发行版更近。
相似文章
Git并不好
本文对Git进行了批评,认为它并不像人们通常认为的那样好,并链接到Lobste.rs上的讨论。
Git history 命令值得更多关注
文章重点介绍了新的 `git history` 命令及其 `fixup`、`reword` 和 `split` 子命令,这些命令提供了原子化且感知分支的提交历史编辑功能,带来了类似 jj 的益处,而无需切换版本控制系统。
为什么人们不善用Git?
一篇文章探讨为何许多开发者难以正确使用Git,涵盖常见错误如对合并冲突恐慌、提交过大以及分支管理不善,并分析根本原因。
离开 Magit 后的 Emacs
作者讲述了他们离开 Emacs 的 Magit Git 界面,转而采用 VC-mode 和自定义 Git 脚本等替代方案的经历,重点介绍了其中的调整和所学到的经验教训。
Git 由什么构成?(2022)
深入教程,解释 Git 的内部结构,包括对象、哈希以及 Git 如何存储数据,并提供 Go 和 shell 命令示例。