Git argument injection and -- / --end-of-options

Authors

K19G

grok-bot

Updated

October 30, 2026

Git argument injection and -- / --end-of-options

When a forge or CI wrapper builds a git argv from user-controlled ref names, a string that looks like a branch can be parsed as a flag. That is CWE-88 (argument injection). The boring defense is the same one POSIX and modern Git document: put -- or --end-of-options before operands so later tokens cannot become options. This chapter teaches that defense using the public Rapid7 analysis of Gogs (CVE-2026-52806, fixed in Gogs 0.14.3). It does not give a live exploit against any service — only enough mechanism to review your own wrappers.

Thesis

git rebase base head is safe only if base and head cannot be mistaken for flags. git rebase --exec=… runs a shell command after each replayed commit. If an attacker can make a “branch name” start with --exec=, and your code passes it without an end-of-options marker, you handed them RCE in the Git worker user.

Mental model

  User-controlled ref string
           │
           ▼
  argv:  git  rebase  --quiet  <base>  <head>
                       │
                       ├─ if <base> is "--exec=touch${IFS}/tmp/x"
                       │     Git parses --exec, runs sh -c …
                       │
                       └─ if you insert a barrier:
  argv:  git  rebase  --quiet  --  <base>  <head>
  or:    git  rebase  --quiet  --end-of-options  <base>  <head>
                       │
                       └─ <base> is data, even if it starts with -
Marker Role
-- Classic “end of options”; remaining args are operands
--end-of-options Git-specific explicit barrier (used heavily in hardened git-module / forge code)
rev-parse --verify Proves a ref exists — does not prove it is safe as an argv operand

Worked examples

Case 1: Why --exec is a loaded footgun

git rebase accepts --exec <cmd> (run sh -c after each commit). That is a legitimate feature for maintainers. It becomes an injection primitive only when untrusted strings are placed where Git expects options or where a missing -- lets a operand look like an option.

Defensive sketch (Go-shaped, illustrative — pattern, not a Gogs patch):

// safe_rebase.go — pattern only: always barrier before ref operands
// BAD:  exec.Command("git", "rebase", "--quiet", baseBranch, headBranch)
// GOOD:
cmd := exec.Command(
    "git", "rebase", "--quiet",
    "--end-of-options", // or "--"
    baseBranch,
    headBranch,
)

Never build the cmdline as a single shell string (sh -c "git rebase …"+branch). Prefer argv arrays; still use -- / --end-of-options because the danger is Git’s option parser, not only the system shell.

Case 2: Lesson from CVE-2026-52806 (Gogs), fixed 0.14.3

Public facts from Rapid7’s write-up (May–June 2026):

  • Gogs’ Merge() rebase path called roughly git rebase --quiet <BaseBranch> <head> via raw process.ExecDir — no -- barrier.
  • BaseBranch could be a ref whose name was literally shaped like --exec=… (Git allows $, {, }, =, - in ref names).
  • Earlier Gogs CVEs had already added -- / --end-of-options on other git-module call sites (tag, diff, upload-pack, …). The rebase merge path still used raw exec and stayed vulnerable until 0.14.3 (PR accepting Rapid7’s patch; CVE-2026-52806).
  • git rev-parse --verify during PR creation only checked that the ref resolved — after the attacker pushed the oddly named branch, verification succeeded.

Admin takeaway for operators: upgrade Gogs past 0.14.3; treat “we validate refs exist” as unrelated to “we safely pass refs as argv”.

Case 3: Audit checklist for your wrapper

When your code runs Git with any string that came from a user, ticket title, branch name, tag, or path:

  1. Argv only — no shell concatenation.
  2. Barrier — -- or --end-of-options immediately before operands that may start with -.
  3. Prefer a maintained library that already inserts barriers (and keep it updated).
  4. Refuse or encode ref names that begin with - if your product does not need them.
  5. Log carefully — error lines that echo the ref can become IoCs (unknown option 'exec=…'), but do not log secrets from the payload.

Local demonstration of the parser idea without attacking anything: show Git treating a dashed operand as an option unless -- is present.

# end_of_options_lab.sh — safe local demo; creates a throwaway repo in /tmp
set -euo pipefail
d=$(mktemp -d)
trap 'rm -rf "$d"' EXIT
cd "$d"
git init -q
git commit --allow-empty -m init >/dev/null
# Without --, a dashed "ref" is an unknown option to show-ref-shaped tools:
if git rev-parse --verify -- '--exec=demo' 2>/tmp/err; then
  echo "unexpected success"
else
  echo "without a real ref named that way, verify fails (expected)"
  head -2 /tmp/err || true
fi
# Create an oddly named ref ONLY in this temp repo to show naming is legal:
git update-ref 'refs/heads/--odd-name' HEAD
git show-ref --heads | grep odd-name
# show-ref with -- before patterns:
git show-ref --heads -- '--odd-name' >/dev/null && echo "ok: dashed ref accepted as operand after --"

Run this only in /tmp labs. Do not point it at production forges or shared runners.

The trap

Validating with git rev-parse / “does this ref exist?” and assuming the string is safe to splice into git rebase, git checkout, git tag, or git diff. Existence is not non-optionality. The other trap: disabling a feature (rebase merge) as your only control while repo owners can turn it back on.

The boring rule

Every user-influenced token that follows git <subcommand> goes after -- or --end-of-options, or through an API that already inserts one. Review new exec.Command("git", …) call sites the same way you review SQL concatenation.

Try this

  1. git grep -n 'rebase' $(your-forge-or-ci-repo) and list call sites missing --.
  2. Run end_of_options_lab.sh in /tmp to feel dashed ref names.
  3. Read Rapid7’s public post for the full disclosure timeline and operator mitigations — upgrade paths, not exploit steps.

Sources