CVE-2026-106058 — GNU GitAhead: OS Command Injection in Filter.cpp → Live RCE
OS command injection in GitAhead Filter.cpp via unquoted filenames handed to bash -c.
- #GitAhead
- #Command Injection
- #CWE-78
- #RCE
§Breaking: I did what nobody had done
My bug-hunting list is assembled from a deeply scientific methodology: targets I can clone, code I can read, repos nobody else wants. GitAhead checked every box and then overachieved — the README says it hasn't been actively developed in years, and when I searched, **no GitAhead CVE existed on record at all.** Zero in the whole history of the project.
Unmaintained. Widely installed. Never looked at publicly before. That's the trifecta. So I cloned it at commit `1cfc416` (`v2.7.1-12-g1cfc416`) and started doing what I do: static taint audit, C++, Qt, libgit2 on the underside.
The full audit surfaced four findings. The one that became **CVE-2026-106058** is the strongest RCE story in the repo and also my favorite kind of bug — the kind where the code *looks* defensive.
§The root cause: 25 lines in `src/git/Filter.cpp`
GitAhead registers git filters (for diff rendering and checkout) like this:
// src/git/Filter.cpp:44-47
static QString quote(const QString &path) {
return QString("\"%1\"").arg(path); // ← wraps in quotes, escapes nothing
}
// src/git/Filter.cpp:58-71
command.replace("%f", quote(git_filter_source_path(src))); // :61
...
process.start(bash, {"-c", command}); // :71I remember reading `quote()` and having a moment I need to be honest about: my internal monologue went "huh, that's a quote function, fine, moving on." Then about ninety seconds later the phrase *"where does that string get consumed?"* caught up with me, and I stopped dead and read everything again.
Three facts, and together they're the bug:
- 01**`quote()` is literally `"\"" + path + "\""`** — it does not escape `"`, `$`, `` ` `` (backtick), `\`, or `!`. A filename containing `"` breaks out of the quoted region the instant bash parses the line.
- 02**`%f` puts an attacker-controlled path into a command string handed to `bash -c`.** The path comes from `git_filter_source_path()` — a filename inside the repository the victim is working on. Filenames are attacker-controlled in any Git repo, that's not even a bug, that's Git.
- 03**GitAhead always goes through `bash -c`,** even when the filter declares the safe list-form `process` protocol that would avoid a shell entirely.
Now the fun part: what can the attacker actually control? The **filename** and **`.gitattributes`** (which selects which filter runs). One thing they *can't* control is the filter command itself — GitAhead correctly reads only the *global* gitconfig (`Config::global()`), never the repo's local config. Good hygiene!
…Except it doesn't matter, because if the victim has *any* global filter whose command contains `%f`, the attacker just supplies a filename shaped like a payload. And the most common such filter in the world is the **stock git-lfs install**:
[filter "lfs"]
clean = git-lfs clean -- %f
smudge = git-lfs smudge -- %fLFS ships with Git for Windows. Millions of machines have this exact precondition already satisfied. So the "need a precondition" asterisk on this bug is, in practice, "or the user has LFS, which they almost certainly do."
§The PoC — and the Windows handicap
Painful honest bit, told fully: my host is Windows and a real full GitAhead build there isn't possible (no cmake/Qt6/MSVC link chain handy). So verification rested on two legs:
- a precise static taint audit, and
- a **runtime proof that replicates the exact C++ logic and executes it through the real bash binary** (`C:\Program Files\Git\bin\bash.exe`) exactly the way `process.start(bash, {"-c", command})` does.
`poc_gitahead_filter_rce.py` reimplements `quote()`, the `%f` substitution, and the `bash -c` invocation verbatim:
evil_filename = 'a"; touch PWNED_FILTER_RCE; echo "'
cmd = template.replace("%f", '"' + evil_filename + '"')
subprocess.run([BASH, "-c", cmd])`PWNED_FILTER_RCE` appears on disk. RCE confirmed. I don't think I've ever typed a `confirm` command in a security context before, but this one gave me the confidence of a genuine writeup.
§The full demo: rounds A–G
I then went somewhat insane and built one end-to-end demo (`demo_filter_exploit.py`) that creates a **real malicious repository** (`.gitattributes` selecting `filter=lfs`, plus payload filenames) and then fires **seven rounds** against the victim-side config on that machine (two stock `%f` templates shipped by git-lfs, clean and smudge):
Every round produced a `bash -x` trace and an on-disk marker file; all six hard checks in my verdict matrix passed. Round E (`chcp.com`) was the one that made me feel the bug was real in a way my other rounds didn't — a native Windows binary outputting through an injection my C++ musing had merely predicted.
§Two platform nuances worth writing down
- **On Windows**, filenames can't contain `"` (NTFS forbids it), so the classic `a"; command; echo "` breakout is impossible *as a filename there*. Windows payloads therefore use `$()` / backticks. **On Linux/macOS, `"` is perfectly legal in a filename** and full `;` chaining works. Same sink, two payload families — worth knowing before anyone assumes "I tested on one OS so the payload is final."
- git-lfs's own template never actually gets to run — the command string dies during bash parsing, before `git-lfs` ever executes. This is a vulnerability in the *invocation*, not in LFS. LFS's `--` argument-reading conventions are irrelevant here.
§Trigger paths (the CVSS-relevant part)
A victim is compromised when they **clone/checkout the malicious repo** (smudge fires filters automatically via libgit2) or simply **view a diff** (`DiffView` → `Patch::apply` → `git_filter_list_apply_to_buffer`, clean direction). Print the repo, look at the commit containing `pic$(touch …).evil` with LFS installed, and you're running attacker-controlled commands.
§Submission and CVE
I put Filter.cpp forward first (the audit's own advice: distinct root causes mean separate CVEs) with the full evidence kit — the `Filter.cpp` snippets, both PoC scripts, the demo transcript, the affected version range (≤ 2.7.1), the precondition statement, and a fix proposal (drop the shell — list-form args or `git_filter` plumbing; at minimum, do actual POSIX quoting in `quote()`).
Result:
- **CVE-2026-106058** assigned, CNA VulnCheck, published 2026-10-07
- Issue #661 in the gitahead repository
- CISA SSVC: Exploitation=poc · Automatable=poc · Technical Impact=total
§The closing rant, or: why I love and hate this bug class
The fix here is twenty characters. Twenty! And twenty-eight years of shell-injection history hasn't managed to kill the reflex because `"\"" + path + "\""` *feels* like quoting. It has the shape of a `quote()` function. It has a nice name. It looks like the author thought about it. It still hands you RCE, because the consumer of that string isn't Qt — it's `bash -c` parsing three layers of indirection out.
If you're ever writing code that will cross a shell boundary, the rule I keep boring people with is boring but load-bearing: **do string surgery on a command, stop and restructure instead.** List-form `process.start()`, or the git plumbing that never spawns a shell at all.
For unmaintained-but-installed projects: "no active development" cuts both ways. Nobody patched this sink since the day it was written — and, as established up top, nobody had ever published a GitAhead CVE before this one. First one on record and it's live RCE from a filename. You're welcome, repo.
Written by
MJ — offensive security researcher
I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.