CVE-2026-106059 — GNU GitAhead: \"Show in Finder\" AppleScript Injection on macOS
AppleScript injection in GitAhead ShowTool on macOS via unescaped filenames.
- #GitAhead
- #AppleScript
- #macOS
- #CWE-78
Same GitAhead audit that produced CVE-2026-106058 (the `Filter.cpp` bash injection), but a genuinely different bug — different sink, different platform, different payload grammar — and, unusually for this blog, an *honest* story about what I could and couldn't prove, because my test box is a Windows machine and this bug lives on macOS.
Yes, I found a Mac bug from a Windows desk. Yes, I'm proud of it, and yes, I would like the reader to notice how much careful work that took.
§The vulnerable code: one line, untouched since 2018
In `src/tools/ShowTool.cpp`, GitAhead's mac "Show in Finder" context-menu feature launches AppleScript like this:
#if defined(Q_OS_MACOS)
return QProcess::startDetached("/usr/bin/osascript", {
"-e", "tell application \"Finder\"",
"-e", QString("reveal POSIX file \"%1\"").arg(mFile), // ← the sink
"-e", "activate",
"-e", "end tell"
});`mFile` comes from `FileContextMenu.cpp:59`: `repo.workdir().filePath(file)`, where `file` is a filename from the repo the victim opened. The whole path hands the bytes over untouched — `Qt QString::arg` is plain substitution that escapes nothing, the constructor stores it with zero transformation (`ExternalTool.cpp:44`, `mFile(file)`), and a grep across `src/tools` found no escaping, quoting, or sanitization anywhere.
Now, precision about the bug class, because this is where a lot of people (including, briefly, me) make a wrong turn:
- `QProcess::startDetached` receives the program and args in **list form**. There is **no shell layer here**. `;` and `|` in a filename are inert.
- But the args are fed to **osascript**, and osascript joins its `-e` arguments with newlines and runs the result through the **AppleScript parser**.
So list-form `QProcess` protects you from the *shell* — and does essentially nothing against the *host language of the argument you're interpolating*. The interpreter that parses the attacker's bytes is AppleScript itself. The payload grammar is AppleScript's own `&` expression concatenation. That realization changed my payload from "shell injection stuff" to "must be a valid AppleScript expression," which is a completely different game.
§The payload — and why only a *balanced* filename works
AppleScript string literals are double-quoted. To execute code from inside a literal, the filename has to *open* the string, concatenate a `do shell script`, and *close* the string cleanly:
x" & do shell script "touch /tmp/gitahead_osascript_poc" & ".evilAfter `workdir().filePath()` and `.arg()`, osascript receives:
reveal POSIX file "/Users/victim/repo/x" & do shell script "touch /tmp/gitahead_osascript_poc" & ".evil"Balanced — four quotes, three `&`-concatenated expressions. `do shell script` is legal as an `&` operand even inside a `tell application "Finder"` block, so the injected command runs with the victim's privileges when they right-click a file and choose "Show in Finder."
And here's the part of the design I respect and slightly hate: an *unbalanced* quote fails closed. A filename like `quote".txt` produces a syntax error and nothing executes. There's no "garbage in, partial code out" gray zone — the attacker has to craft exact balance. Which, to be clear, is easy, and the audit proved it, but it does mean the payload had to be *precise*, not just "throw quotes at it."
§How I validated it — adversarially, from a Windows desk
Here's the confession, upfront, like a good validation doc should do: **my test host is Windows 11 — no macOS, no osascript, no full GitAhead build linkable.** So instead of pretending, I wrote the report in adversarial-validation mode: every claim was either experimentally run, or explicitly marked as reasoned-not-run (`ANALYSIS`) or impossible-in-this-environment (`BLOCKED`).
What *was* proven experimentally:
- 01**Byte-exact generated script.** I compiled a little harness (`applescript_reconstruct.exe`) against **real Qt 6.9.1** using verbatim the `QString("reveal POSIX file \"%1\"").arg(mFile)` construction, and captured the exact bytes osascript would receive for several filenames. The payload filename came out **byte-for-byte** as written above. This is the step that mattered most to me, honestly — if the reconstructed script is byte-exact, every downstream claim about what AppleScript will parse follows directly.
- 02**Negative controls.** Benign names (`normal.txt`, `my photo.png`, `foo&bar.txt`) render inert; `quote".txt` produces an unbalanced, fails-closed script; backslash filenames get rejected by git's own tree validation well before my sink gets a chance.
- 03**Filename feasibility at the git layer.** I proved the payload filename is actually *committable*: `update-index --cacheinfo` → commit → `ls-tree`/`cat-file` round-trips the exact payload bytes. On macOS/APFS only `/` and NUL are forbidden, so `"` is perfectly legal in a filename. (On the Windows side, staging the same name only needed `core.protectNTFS=false` — a Windows-specific default that's off everywhere else.)
- 04**Reachability and attribution.** Two UI clicks from the victim (open repo, right-click → "Show in Finder"), **no config preconditions** (unlike the Filter bug — no LFS needed), and — my favorite line of `git log` ever — the fix commit *never existed*: `git log -S osascript -- src` shows only the 2018 initial import. The sink has three lifetime commits and none of them touch it.
What was **not** run: actual osascript execution, and the real UI click path on a Mac. That's precisely what the doc flagged. My submission recommendation at that point was, verbatim, **"NEEDS MORE EVIDENCE"** — with the note that one five-minute Mac run (commit the encoded filename in a repo, click "Show in Finder," check for the marker file at `/tmp/gitahead_osascript_poc`) would convert the verdict to fully-confirmed. My confidence at submission was 70% — and, importantly, I could name every single missing percentage point.
§What got assigned, and what the record says
The finding went to VulnCheck along with the rest of the GitAhead package, was validated, and was accepted:
- **CVE-2026-106059** assigned, CNA VulnCheck, published 2026-10-07
- Affected: GitAhead ≤ 2.7.1, **macOS build only** — the Windows branch (`explorer.exe` in list form) is safe by construction, and the Linux branch (`QDesktopServices::openUrl` on a local file) has no script interpreter to inject into
- CISA SSVC: Exploitation=none · Automatable=no · Technical Impact=total
The most precise framing of the class: application-level command injection into an interpreter (AppleScript playing the role bash played in CVE-2026-106058), with a two-click trigger. If the injected command fires, it's user-level RCE in an unsandboxed process context — full user-file access, not root, and not a TCC bypass. Lower privilege ceiling than "root," but nothing in the sandbox walls between the victim's files and the attacker's command.
§Takeaways for fixers and finders
**The fix:** don't script Finder at all. `NSWorkspace`'s reveal API does this without an interpreter. Anywhere a scripting bridge is unavoidable, escape `\` and `"` for the *target language*, or stop interpolating attacker-controlled strings into `-e` bodies.
**For finders, the transferable bits:**
- 01Treat "argument to any interpreter" (`bash -c`, `osascript -e`, `cmd /c`, `sh -c`) as a taint sink **regardless of process-launch form**. List-form `QProcess` protects you from the *shell*, not from the *host language of the argument*. This one realization upgrades your hunting from "finds half the shells" to "finds the whole family."
- 02Write the validation report to refute your own bug. Negative controls, honest BLOCKED entries, the name of the missing experiment. I was worried "I couldn't run it on my OS" would sink this finding; instead, that kind of honesty about the gap is what apparently made the package credible enough to still assign the CVE. Precision about "verified" vs. "analyzed" doesn't weaken a submission — it's what makes it credible.
- 03`git log -S` is a wonderful sanity-check: if a sink has never been touched since initial import, the audit is basically interviewing a fossil.
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.