← 返回博客

从 `--end-of-options` 到 CWE-88:为什么你的包管理器正在被"破折号攻击"侵入

技术深度

写在前面

2019 年 11 月,git 2.24.0 默默地引入了一个你可能从未听过的标志:--end-of-options。直到 2026 年 7 月,一位开发者在修复包管理器 CVE 时偶然发现它,才意识到这个看似晦涩的标志,背后隐藏着一个影响深远的安全问题——参数注入(CWE-88)。

这篇文章从 --end-of-options 这个”隐藏的” git 标志出发,深入探讨参数注入如何从 Unix 的 -- 约定演化到现代包管理器生态的系统性风险。我们会看到:为什么 -- 在 git 里不是万能的、为什么 19 个主流包管理器中只有 Go 真正理解了这个问题、以及为什么”支持旧版本”这个看似合理的决定,正在让整个软件供应链悬于一线。


一、-- 不是万能的:为什么 git 需要 --end-of-options

1.1 Unix 的 -- 约定:结束选项解析的传统

在 Unix 世界里,-- 有一个约定俗成的含义:结束选项解析。它的诞生源于一个简单但致命的矛盾:

# 你想删除一个名叫 -f 的文件
rm -- -f

如果没有 --rm 会把 -f 当作 force 标志,而不是文件名。-- 告诉 rm:“从这里开始,所有的参数都是文件名,不是选项。”

这个约定在大多数 Unix 工具中都成立。但 git 打破了这个约定。

1.2 git 的 -- 劫持:从选项终结符到 revision-pathspec 分隔符

git 在早期就把 -- 劫持了——它不再是”结束选项解析”的标志,而是revision 与 pathspec 的分隔符

# 显示 main 分支上对 README.md 的所有提交
git log main -- README.md

# 这里的 -- 告诉 git:main 是 revision,README.md 是 pathspec

为什么需要这个分隔符?因为 git log foo 本身是有歧义的:foo 可能是一个分支名,也可能是一个文件名。-- 消除了这种歧义。

但这带来了一个严重的问题:revision 位置再也没有终结符。如果一个脚本运行 git log "$rev",而 $rev- 开头,git 就会把它当作一个选项来解析。

1.3 --end-of-options 的诞生:git 的”第二条生命”

git 2.24.0(2019 年 11 月)引入 --end-of-options,它的作用是真正结束选项解析,而 -- 在 git 里已经被 repurposed 了。

# 安全的用法:--end-of-options 结束选项,-- 分离 revision 和 pathspec
git log --end-of-options "$rev" -- "$path"

这两个标志在 git 里不是同义的

标志作用适用场景
--分离 revision 和 pathspecgit log main -- README.md
--end-of-options结束选项解析git log --end-of-options "$rev"

把它们当作同义词是一种常见但危险的误解。git clone -- "$url" 能工作,是因为 clone 遵循 POSIX 约定;但 git checkout "$ref" -- 只能保证 $ref 被当作 revision,而不能防止它被解析为选项。

1.4 子命令支持的不一致:git 的”渐进式安全”

--end-of-options 的支持是逐子命令推出的,而不是一次性完成的:

这个渐进式推出反映了 git 的架构复杂性:不同的子命令有不同的参数解析器。一些子命令使用共享的解析器,一些有自己的手工实现。这种不一致性直接导致了安全漏洞的逭生。


二、参数注入(CWE-88):没有 shell 的攻击

2.1 CWE-88 vs. 命令注入:区别在哪里

参数注入(CWE-88)与命令注入(CWE-78)经常被混淆,但它们有着根本的区别:

关键区别在于:参数注入不需要 shell。攻击者构建一个 argv 数组,调用 exec 直接执行,正如每个”不要使用 system()” 指南所推荐的那样。数组完整到达 git,git 然后把一个以 - 开头的参数解析为选项。

2.2 攻击原语:git、hg、ssh 如何把”选项”变成”命令执行”

git、Mercurial(hg)和 ssh 都提供了选项,其文档明确表示这些选项会运行调用者命名的命令

# git clone 的 --upload-pack 选项:指定服务器端二进制
git clone --upload-pack=/tmp/evil.git origin repo

# git 的 -c core.sshCommand 选项:覆盖连接方式
git -c core.sshCommand="ssh -o ProxyCommand=/tmp/evil" clone origin repo

# hg 的 --config 选项:将子命令重新定义为 shell 脚本
hg --config alias.clone="!sh /tmp/evil" clone

# ssh 的 -oProxyCommand 选项:指定代理命令
ssh -o ProxyCommand="/tmp/evil" host

这些是文档化的功能。但当一个包装程序将不可信字符串传入参数列表时,它们就会变成攻击原语。

2.3 CVE-2019-13139:Docker Build 的经典案例

Docker Build 的 CVE-2019-13139 是参数注入的一个教科书案例:

  1. Go 的 os/exec 包构建 argv 数组,没有 shell
  2. git-context URL 的 #ref:dir fragment 最终作为 --upload-pack=<cmd> 传入 git fetch
  3. git 解析这个参数为选项,执行攻击者指定的命令

整个攻击链中没有任何 shell 调用,但攻击者仍然获得了命令执行。这正是参数注入的邪恶之处:它利用了”正确的”编程实践(使用 argv 数组而非 shell)来实现攻击。


三、2017 年的集体警醒:四个 VCS 系统同一天爆出漏洞

2017 年 8 月,一天之内,四个版本控制系统爆出参数注入漏洞:

CVE系统根因
CVE-2017-1000117GitURL hostname 传入 ssh 作为参数
CVE-2017-1000116MercurialURL hostname 传入 ssh 作为参数
CVE-2017-9800SubversionURL hostname 传入 ssh 作为参数
CVE-2017-12836CVSURL hostname 传入 ssh 作为参数

3.1 相同的攻击模式:hostname → ssh → -oProxyCommand=

所有四个漏洞都有一个共同的模式:

  1. URL 的 hostname 被提取出来
  2. hostname 作为参数传入 ssh
  3. 如果 hostname 以 -oProxyCommand= 开头,ssh 会把它解析为选项
# 攻击 URL
git://-oProxyCommand=/tmp/evil/repo.git

# git 提取 hostname: -oProxyCommand=/tmp/evil
# ssh 解析为: ssh -o ProxyCommand=/tmp/evil host

3.2 不同的修复策略:-- vs. 格式校验

Phabricator 的事后分析[^1]指出了一个重要现象:只有 Subversion 真正添加了 -- 在 hostname 之前

为什么 Git 和 Mercurial 没有使用 --?部分原因是并非所有 ssh 实现都支持 --。这揭示了一个深层次的问题:-- 机制本身被称为”默认不安全”(unsafe by default),因为没有 -- 的代码看起来正确且工作良好,直到某个参数以 - 开头。

3.3 事后分析的启示:“unsafe by default”

Phabricator 的分析得出了一个重要结论:-- 机制本身是”默认不安全的”。为什么?


四、包管理器:参数注入的主要战场

4.1 为什么包管理器是目标

包管理器的一个核心功能是:从 git URL 或 ref 拉取依赖。这些 URL 或 ref 来自:

这些数据通常是不可信的 — 它们可能来自第三方包,可能被篡改。

4.2 19 个包管理器的调查:只有 Go 真正理解

作者对 19 个包管理器进行了调查(截至 2026 年 7 月):

包管理器默认使用 git 二进制使用 --end-of-options
Bundler
Cargo❌ (libgit2)N/A
CocoaPods
Composer
Conan
Go
Helm
Homebrew
Mix
Nix
npm
pip
pnpm
Poetry❌ (dulwich)N/A
Pub
SwiftPM
uv
vcpkg
Yarn

惊人的结果:19 个包管理器中,只有 Go 的 cmd/go 使用了 --end-of-options

4.3 已公开的 CVE:参数注入的记录

以下是已公开的包管理器参数注入 CVE:

这些 CVE 跨越了 5 年的时间,涉及 Ruby、PHP、Python、Go 等多个语言生态。

4.4 Go 的演进:----end-of-options

Go 的 cmd/go 在参数注入防护上经历了三个阶段:

  1. 2019 年 6 月:添加 -- 在仓库 URL 之前 — 作为一般性的安全强化
  2. 2026 年 1 月:CVE-2025-68119 爆出 — -- 不足以防护
  3. 2026 年 1 月:全面添加 --end-of-options,并引入 HGPLAIN=+strictflags

Go 的 commit message 透露了一个真理:

“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.”

这句话道出了所有维护者的心声:当前的修复只是权宜之计


五、最小 git 版本的困境:安全 vs. 兼容性

5.1 --end-of-options 的最低版本要求

--end-of-options 不是在所有 git 版本中都可用的。它的支持取决于子命令:

子命令最低版本发布时间
大多数子命令2.24.02019 年 11 月
git rev-parse2.30.02021 年 1 月
git checkout / reset2.43.12024 年 2 月

这意味着使用 --end-of-options 需要提高最低 git 版本

5.2 分发版的现实:老旧 git 的广泛存在

许多发行版仍在使用老旧的 git 版本:

发行版git 版本支持状态
Amazon Linux 22.14.3已 EOL(2026 年 6 月)
Ubuntu 18.042.17.0扩展支持至 2028 年
Ubuntu 20.042.25.1扩展支持至 2030 年

Ubuntu 20.04 的 git 2.25.1 足够新,可以接受 --end-of-optionsgit fetch 上,但对于 git rev-parse 却太旧。

5.3 包管理器的选择:为什么不用 --end-of-options

Composer 的 CVE-2022-24828 通告解释了为什么几乎没有包管理器使用 --end-of-options

Composer 支持的 git 版本早于 --end-of-options 引入,因此补丁拒绝以 - 开头的分支名。

vcpkg 的 git 集成代码中有一个注释,声明最低版本为 git 2.7.4。Homebrew 的 HOMEBREW_MINIMUM_GIT_VERSION 是 2.7.0(自 2018 年设置)。

这些最低版本标准反映了一个现实:支持旧版本是商业上的必要。但这也意味着安全修复只能通过输入校验(拒绝以 - 开头的参数)来实现,而不是通过结构性的防护--end-of-options)。

5.4 Homebrew 的尝试:提高最低版本

作者向 Homebrew 提交了一个 PR,将最低 git 版本提高到 2.30.0,并在 cloneremote set-urlls-remote 中添加 --end-of-options

checkoutreset 调用被留下不做处理,因为覆盖它们需要最低版本 2.43.1(2024 年 2 月发布),仍然领先于多个支持的发行版。

这个 PR 展示了一个普遍的难题:安全修复的速度,永远赶不上漏洞的发现速度


六、库 vs. 二进制 fork:架构上的权衡

6.1 库的优势:没有 argv 边界

libgit2、gitoxide、go-git、JGit 和 dulwich 都实现了足够的 git 线协议,可以在进程内克隆和获取,没有 argv 边界,也没有参数列表可以注入

Jujutsu 使用 gitoxide 进行 git 互操作,没有发布过参数注入类 CVE。它的两个 CVE 都是路径遍历和缺失的 SHA-1 碰撞检查,继承自库本身。

6.2 库的劣势:跟踪上游的安全补丁

但库也有代价:必须跟踪上游 git 发送的所有 checkout-safety 修复。libgit2 和 JGit 都经历过这样的补丁轮次。

这是一个具体的补丁流,而不是一个需要在每个调用点永远记住的检查。从某种意义上说,库的安全模型更容易推理:

6.3 go-git 的例外:file:// 传输仍然 fork git

go-git 有一个 CVE(CVE-2025-21613),但它专门在 file:// 传输上,这个代码路径仍然 fork git 二进制

这揭示了一个深层次的真理:只要有一条代码路径 fork git,你就有参数注入的风险

6.4 Nix 的混合策略

Nix 使用 libgit2 读取本地仓库,但 fork git 进行获取,因为 libgit2 缺乏 git-credential helper 支持

这种混合策略反映了一个现实:库和二进制在功能上并不完全等价。


七、更广远的启示:软件供应链的脆弱性

7.1 参数注入的普遍性

参数注入不仅存在于 git、ssh,还存在于任何接受外部输入并将其作为选项传递给子进程的软件:

7.2 “unsafe by default” 的根本原因

-- 机制之所以”默认不安全”,是因为:

  1. 代码看起来正确:没有 -- 的代码在正常情况下工作良好
  2. 漏洞潜伏:只有当参数以 - 开头时,问题才暴露
  3. 难以测试:正常的测试用例不会触发这个漏洞

7.3 结构性防护 vs. 输入校验

当前的防护策略分为两种:

策略示例优点缺点
结构性防护--end-of-options从根本上防止解析为选项需要较高的最低版本
输入校验拒绝以 - 开头的参数兼容性好容易遗漏调用点

结构性防护更可靠,但需要提高最低版本;输入校验更兼容,但容易出错。

7.4 未来的方向

从 Go 的 commit message 可以看出,业界正在寻求更结构性的解决方案:

“We should probably follow up with a more structured change to make it harder to accidentally re-introduce these issues in the future.”

可能的方向包括:

  1. 参数化的 API:提供接受结构化参数而非字符串的 API
  2. 类型安全的 argv:在编译时防止参数被解析为选项
  3. 沙箱化:限制子进程的能力,使参数注入无法导致命令执行

八、结论:从 --end-of-options 到供应链安全

--end-of-options 这个看似晦涩的 git 标志,背后隐藏着一个关于软件供应链安全的深刻真理:

安全不是一个特性,而是一个架构决策。

每一个将不可信字符串传入参数列表的调用,都是一个潜在的攻击面。-- 约定在 Unix 中工作了几十年,但 git 的 repurposing 告诉我们:约定可以被打破,安全不能依赖约定

对于包管理器维护者来说,这个问题没有简单的解决方案:

但有一个真理是清晰的:“默认不安全”的代码注定会出问题。安全必须是默认的、结构性的、无法绕过的。

正如 Go 的 commit message 所说:

“We should probably follow up with a more structured change…”

这不仅是对 Go 的期望,更是对整个软件行业的呼唤。


相关资源

原创技术博客 · 开源项目分享 · AI全栈创作社区 idao.fun