–end-of-options | Andrew Nesbitt
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.
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. 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.
-- 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.
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.
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= to specify the server-side binary, and any git invocation accepts -c core.sshCommand= to override how it connects. Mercurial accepts --config=alias.=! on any subcommand, which redefines the subcommand you’re running as an arbitrary shell script. ssh accepts -oProxyCommand=. 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. 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 as --upload-pack=.
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. 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.
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.
Of nineteen package managers I checked1, 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.
The published CVEs against package managers in this class include CVE-2021-43809 (Bundler), CVE-2021-29472 and CVE-2022-24828 (Composer),...