Git argument injection and -- / --end-of-options
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 headis safe only ifbaseandheadcannot 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 roughlygit rebase --quiet <BaseBranch> <head>via rawprocess.ExecDir— no--barrier. BaseBranchcould be a ref whose name was literally shaped like--exec=…(Git allows$,{,},=,-in ref names).- Earlier Gogs CVEs had already added
--/--end-of-optionson 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 --verifyduring 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:
- Argv only — no shell concatenation.
- Barrier —
--or--end-of-optionsimmediately before operands that may start with-. - Prefer a maintained library that already inserts barriers (and keep it updated).
- Refuse or encode ref names that begin with
-if your product does not need them. - 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
git grep -n 'rebase' $(your-forge-or-ci-repo)and list call sites missing--.- Run
end_of_options_lab.shin/tmpto feel dashed ref names. - Read Rapid7’s public post for the full disclosure timeline and operator mitigations — upgrade paths, not exploit steps.
Sources
- Rapid7 — Authenticated RCE via Argument Injection in Gogs (CVE-2026-52806)
- Git documentation:
git rebase --exec; general argv parsing and--as end-of-options - Prior related class: forge advisories that added
--/--end-of-optionstogit tag/git diff/ upload-pack paths (cited in the Rapid7 post)