git --end-of-options

git —end-of-options

I was reading through the fix for a package manager CVE last week and ran into a git flag I’d somehow never noticed: --end-of-options. My first reaction was that some LLM had hallucinated it, but it’s documented in gitcli(7), it was added in git 2.24.0 in November 2019, and it exists because git had already used -- for something else. 上周我在阅读一个包管理器 CVE 的修复补丁时,偶然发现了一个我从未注意过的 git 标志:--end-of-options。我的第一反应是某个大模型产生了幻觉,但它确实记录在 gitcli(7) 中。该标志于 2019 年 11 月在 git 2.24.0 版本中引入,其存在的原因是 git 之前已经将 -- 用于其他用途了。

In most Unix tools -- marks the end of option parsing, so rm -- -f removes a file called -f rather than passing the force flag. Git had repurposed -- early on to separate revisions from pathspecs, because git log foo on its own is ambiguous between a branch named foo and a file named foo and one of the two readings needed a marker: git log main -- README.md means commits on main touching that file. 在大多数 Unix 工具中,-- 标志着选项解析的结束,因此 rm -- -f 会删除名为 -f 的文件,而不是传递强制删除标志。Git 很早就将 -- 重新用于区分修订版本(revisions)和路径规范(pathspecs),因为 git log foo 本身存在歧义——它既可以指名为 foo 的分支,也可以指名为 foo 的文件,因此其中一种解读需要一个标记:git log main -- README.md 表示查看 main 分支上涉及该文件的提交。

That left the revision position with no terminator, so if a script runs git log "$rev" and $rev starts with a dash, git parses it as an option. From the commit that introduced --end-of-options: “But that doesn’t work for the revision parser, because -- is already meaningful there: it separates revisions from pathspecs. So we need some other marker to separate options from revisions.” 这导致修订版本的位置没有终止符,因此如果脚本运行 git log "$rev"$rev 以连字符开头,git 就会将其解析为选项。在引入 --end-of-options 的提交中写道:“但这对于修订版本解析器不起作用,因为 -- 在那里已经有了特定含义:它用于分隔修订版本和路径规范。所以我们需要另一个标记来分隔选项和修订版本。”

-- and --end-of-options are different things in git, and treating them as interchangeable is a mistake I’ve now seen in several places. Putting -- before a URL in git clone -- "$url" works, because clone follows the POSIX convention. A trailing -- after a ref, as in git checkout "$ref" --, marks $ref as a revision rather than a filename but still lets it be read as an option first. Passing an untrusted revision safely means writing git log --end-of-options "$rev" -- "$path", with both markers doing separate jobs. 在 git 中,----end-of-options 是不同的东西,将它们视为可互换的错误,我已经不止一次在别处见过了。在 git clone -- "$url" 中,在 URL 前放置 -- 是有效的,因为 clone 遵循 POSIX 约定。在引用(ref)之后放置一个尾随的 --(如 git checkout "$ref" --)会将 $ref 标记为修订版本而非文件名,但它仍然允许该引用首先被解析为选项。要安全地传递不受信任的修订版本,必须写成 git log --end-of-options "$rev" -- "$path",这两个标记各自承担不同的任务。

Support for the new flag arrived per subcommand rather than all at once: git rev-parse only got it in 2.30.0, a year after the initial release, because it has its own hand-rolled argument parser, and git checkout and git reset rejected it until 2.43.1 in February 2024 because they parse -- themselves and the initial implementation left --end-of-options in the argument list where their parsers rejected it. 对该新标志的支持是分批次而非一次性在各子命令中实现的:git rev-parse 直到 2.30.0 版本(初始发布一年后)才支持它,因为它有自己手写的参数解析器;而 git checkoutgit reset 直到 2024 年 2 月的 2.43.1 版本才支持,因为它们自行解析 --,而最初的实现将 --end-of-options 留在了参数列表中,导致它们的解析器报错。

Argument injection

参数注入

Git, hg, and ssh all ship options whose documented purpose is to run a command the caller names. git clone accepts --upload-pack=<cmd> to specify the server-side binary, and any git invocation accepts -c core.sshCommand=<cmd> to override how it connects. Mercurial accepts --config=alias.<subcmd>=!<shell> on any subcommand, which redefines the subcommand you’re running as an arbitrary shell script. ssh accepts -oProxyCommand=<cmd>. Git、hg 和 ssh 都提供了一些选项,其文档说明的用途是运行调用者指定的命令。git clone 接受 --upload-pack=<cmd> 来指定服务器端二进制文件,任何 git 调用都接受 -c core.sshCommand=<cmd> 来覆盖其连接方式。Mercurial 在任何子命令上都接受 --config=alias.<subcmd>=!<shell>,这会将你正在运行的子命令重定义为任意 shell 脚本。ssh 则接受 -oProxyCommand=<cmd>

These are documented features that become attack primitives when a wrapping program passes an untrusted string into the argument list. The failure mode has its own CWE, CWE-88, argument injection, and it’s distinct from command injection because there’s no shell involved: the wrapping program builds an argv array and calls exec directly, exactly as every “don’t use system()” guide recommends, the array reaches git intact, and git then parses one of the arguments as an option because it starts with a dash. 这些是文档化的功能,但当封装程序将不受信任的字符串传入参数列表时,它们就变成了攻击原语。这种故障模式有其对应的 CWE,即 CWE-88(参数注入),它与命令注入不同,因为其中不涉及 shell:封装程序构建一个 argv 数组并直接调用 exec,这完全符合每一份“不要使用 system()”指南的建议。数组完整地到达 git,然后 git 因为其中一个参数以连字符开头,将其解析为了选项。

CVE-2019-13139 in docker build is a clean example: Go’s os/exec package, an argv array, no shell, and a git-context URL whose #ref:dir fragment reached git fetch origin <ref> as --upload-pack=<cmd>. The pattern was demonstrated across four version control systems on the same day in August 2017, when CVE-2017-1000117 (git), CVE-2017-1000116 (Mercurial), CVE-2017-9800 (Subversion), and CVE-2017-12836 (CVS) were disclosed together. Docker build 中的 CVE-2019-13139 是一个典型的例子:使用了 Go 的 os/exec 包,通过 argv 数组,没有 shell,而一个 git 上下文 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) 被同时披露。

Each passed a URL’s hostname to ssh as an argument, and a hostname starting with -oProxyCommand= became an ssh option. Phabricator’s post-mortem on the disclosure noted that of the three actively maintained tools, only Subversion actually added -- before the hostname in its fix; git and Mercurial validated the hostname format instead, partly because -- isn’t supported by every ssh implementation. The same write-up called the -- mechanism itself “unsafe by default”, since code without it looks correct and works fine right up until an argument starts with a dash. 它们都将 URL 的主机名作为参数传递给 ssh,而以 -oProxyCommand= 开头的主机名就变成了 ssh 选项。Phabricator 在披露后的事后分析中指出,在三个活跃维护的工具中,只有 Subversion 在修复中确实在主机名前添加了 --;git 和 Mercurial 则选择了验证主机名格式,部分原因是并非所有 ssh 实现都支持 --。同一份报告称 -- 机制本身“默认不安全”,因为没有它的代码看起来是正确的,并且在参数以连字符开头之前都能正常工作。

Package managers

包管理器

Package managers routinely take a git URL or ref as data and pass it to a subprocess: gem 'foo', git: '...' in a Gemfile, github:user/repo#ref in a package.json, and equivalents in pyproject.toml, Cargo.toml, mix.exs, Package.swift, pubspec.yaml, conanfile.py, and go.mod. The URL and ref arrive in a manifest, a lockfile, or a transitive dependency’s metadata. 包管理器通常会将 git URL 或引用作为数据并将其传递给子进程:例如 Gemfile 中的 gem 'foo', git: '...',package.json 中的 github:user/repo#ref,以及 pyproject.tomlCargo.tomlmix.exsPackage.swiftpubspec.yamlconanfile.pygo.mod 中的等效项。URL 和引用通常出现在清单文件、锁文件或传递依赖项的元数据中。

Of nineteen package managers I checked, seventeen fork the git binary as their default or only path. The two that default to a library are Cargo, which uses libgit2 with an opt-in net.git-fetch-with-cli setting to fork instead, and Poetry, which switched to dulwich in 1.2.0 with a system-git-client setting to fall back. Nix uses libgit2 for reading local repositories but forks git for fetches, because libgit2 lacks git-credential helper support. 在我检查的 19 个包管理器中,有 17 个默认或仅通过 fork git 二进制文件来工作。默认使用库的两个是 Cargo(使用 libgit2,可通过可选的 net.git-fetch-with-cli 设置切换为 fork)和 Poetry(在 1.2.0 版本切换到 dulwich,并提供 system-git-client 设置作为回退)。Nix 使用 libgit2 读取本地仓库,但在获取(fetch)时会 fork git,因为 libgit2 缺乏对 git-credential 助手的支持。

The published CVEs against package managers in this class include CVE-2021-43809 (Bundler), CVE-2021-29472 and CVE-2022-24828 (Composer), CVE-2022-36069 (Poetry), CVE-2023-5752 (pip), CVE-2022-21223 and CVE-2022-24440 (CocoaPods), and CVE-2025-68119 (Go). The Snyk research that produced several of the 2022 entries is written up here, and Sonar maintains a catalogue of the dangerous options per binary. 针对此类包管理器的已发布 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 则维护着一份各二进制文件的危险选项目录。

Of the seventeen that fork git, exactly one uses --end-of-options: Go’s cmd/go. It added -- before repository URLs in June 2019 as a general hardening pass. In January 2026 that turned out to be insufficient and --end-of-options was added across the board as the fix for CVE-2025-68119, along with HGPLAIN=+strictflags, which has restricted Mercurial’s early-option parsing since hg 4.4.2 in 2017. The commit message ends: “We should probably follow up with a more structured change to make it harder to accidentally re-introduce these issues in the future, but for now this addresses the issue at hand.” 在 fork git 的 17 个包管理器中,只有一个使用了 --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 的早期选项解析。提交信息结尾写道:“我们可能需要后续进行更结构化的更改,以使未来更难意外地重新引入这些问题,但目前这解决了手头的问题。”

Minimum git versions

最低 git 版本

The other package managers that guard the argument list at all use -- or a leading-dash check on the input, and looking a 其他确实对参数列表进行防护的包管理器要么使用 --,要么对输入进行前导连字符检查,而查看……