Skip to content

Git & Version Control for Security

Git is the most common way attackers get caught — because they committed an API key three years ago and forgot. It is also the most common way defenders find them. Every repository has .git/ with a full copy of every object ever created, including ones rm’d, branches deleted, and stashes dropped. The plumbing layer (git cat-file, git fsck, git rev-list) is a forensic toolkit in its own right. This module covers the object model, the forensics commands that recover deleted data, the git filter-repo workflow for purging leaked secrets, and the .git/ exposure vulnerability that still bites organizations every week.

Git is a content-addressable object store. Every object is identified by the SHA-1 of its zlib-compressed content. Four object types:

┌──────────────────────────────────────────────────────────────┐
│ Git object model │
│ commit ──> tree ──> tree (subdir) │
│ └──> blob (file content) │
│ tag ──> commit (annotated tags only) │
│ │
│ Every object = SHA-1 of "<type> <size>\0<content>" │
│ Stored under .git/objects/ab/cdef... (first 2 hex / rest) │
└──────────────────────────────────────────────────────────────┘

A commit points to a tree (the root directory listing). A tree points to blobs (file content) and to other trees (subdirectories). The whole history is a DAG of commits with parent pointers; HEAD and branch refs are just labels pointing into the DAG.

  1. The .git/ directory layout:

    • HEAD — text file with ref: refs/heads/main (or similar).
    • config — repo-level config (user.name, remote.origin.url, core.bare).
    • index — staged changes; binary format, can be parsed with git ls-files --stage.
    • refs/heads/<branch> — file containing the commit SHA the branch points at.
    • refs/remotes/<remote>/<branch> — file for remote-tracking refs.
    • objects/ab/cdef... — loose object files; zlib-compressed.
    • objects/pack/pack-<sha>.pack — pack files (multiple objects compressed together).
    • packed-refs — text file with refs that were packed for space.
    • hooks/ — sample hooks (pre-commit.sample, etc.); rename to activate.
    • logs/HEAD — reflog; one line per ref update.
    • logs/refs/heads/<branch> — per-branch reflog.
  2. Plumbing commands — the forensic toolkit:

    Terminal window
    git cat-file -t <sha> # blob | tree | commit | tag
    git cat-file -p <sha> # pretty-print contents
    git cat-file -s <sha> # size in bytes
    git hash-object -w file.txt # write file as blob, print SHA
    git ls-tree <sha> # list a tree's entries
    git rev-list --all # list every reachable commit
    git rev-list --all --objects # list every reachable object (incl. blobs)
    git fsck --unreachable # objects not pointed to by any ref
    git fsck --lost-found # write unreachable objects to .git/lost-found/
    git reflog show <branch> # every update to that branch
    git stash list # reflog for the stash
  3. The git log flags that matter for forensics:

    • git log --all — include all branches, not just the current.
    • git log -p — show diffs.
    • git log -S '<string>' — pickaxe; commits that added/removed the string.
    • git log -G '<regex>' — like -S but on the diff content.
    • git log --follow file.py — follow file through renames.
    • git log --source --remotes — include remote refs and label them.
    • git log --since=2.weeks --until=yesterday --author='Alice'.
  4. git bisect — binary search through history for the commit that introduced a bug:

    Terminal window
    git bisect start
    git bisect bad HEAD
    git bisect good v1.2.0
    # git checks out the midpoint; you mark good/bad until it identifies the culprit
  5. Secret scanning — three stages of failure:

    • Stage 1 (preventive): pre-commit hooks (pre-commit, gitleaks, trufflehog).
    • Stage 2 (CI gate): PR-time scans (gitleaks-action, git-secrets, GitHub’s native secret scanning).
    • Stage 3 (forensic): history audit, git log -p -S 'api_key', git rev-list --objects --all then git cat-file -p each.
  6. git filter-repo — purging secrets (replaces the now-deprecated git filter-branch and bfg):

    Terminal window
    pip install git-filter-repo
    echo 'MY_AWS_KEY==>' > replacements.txt
    git filter-repo --replace-text replacements.txt
    # Rewrites history: every commit's tree gets the string replaced with '***REMOVED***'.
    # Force-push required; all clones must re-clone.

    The history rewrite changes every commit SHA from the affected commit forward — which means every contributor must re-clone or git reset --hard origin/main after the force-push.

  7. .git/ directory exposure on web servers: a misconfigured Apache/Nginx served .git/ as static files. Attackers download /.git/config, /.git/HEAD, /.git/index, then git-dumper.py https://target/.git/ ./recovered/ reconstructs the repo. The index file contains every file path the repo had at the last commit, and the object directory contains every blob (including deleted secrets).

TechniqueDescriptionDetection Difficulty
.git/ exposureMisconfigured web server serves .git/ over HTTPEasy — wmap / nuclei -t git-config.yaml finds it
Recovered deleted branchgit fsck --lost-found finds the dangling commitEasy — the object is on disk until git gc --prune=now
Reflog recoverygit reflog show reveals the SHA the branch pointed at before resetEasy — by default reflog is kept 90 days
Stash recoverygit stash list shows dropped stashesEasy — stashes live in reflog until pruned
Submodule URL injection (CVE-2017-1000117)Attacker crafts a submodule URL that starts with - to inject git argsEasy — patched in 2.7.6+, 2.8.x, 2.9.x, 2.10.x, 2.11.x
Argument injection (CVE-2018-11235)Malicious .gitmodules triggers argument injection in git cloneEasy — patched in 2.17.1
SSH recursion (CVE-2018-17456)ssh://-foo argument injection in git clone --recurse-submodulesEasy — patched in 2.14.5, 2.15.x, 2.16.x, 2.17.x, 2.18.x, 2.19.x
Terminal window
# Show every object in the repo, including unreachable ones
git rev-list --objects --all --reflog
git fsck --unreachable --dangling --no-reflogs
# Recover a deleted branch from a reflog entry
git reflog show --all | grep <deleted-branch-name> # find last SHA before delete
git checkout -b recovered <sha>
# Print every blob in the repo and grep for secrets
git rev-list --all --objects | \
awk '{print $1}' | sort -u | \
while read sha; do
git cat-file -p "$sha" 2>/dev/null | grep -lE 'api[_-]?key|secret|password'
done
# Find when a specific string was added or removed across history (pickaxe)
git log -p -S 'AWS_SECRET_ACCESS_KEY=' --all
git log -p -G 'AKIA[0-9A-Z]{16}' --all # AWS access key ID pattern
# Use gitleaks on the whole history (offline, no API needed)
gitleaks detect --source . --report-path leaks.json --report-format json
# Inspect a commit object directly without going through porcelain
git cat-file -p HEAD
# tree <sha>
# parent <sha> # if not a root commit
# author Name <email> <unix_ts> <tz>
# committer Name <email> <unix_ts> <tz>
#
# <commit message>
# List the root tree of a commit
git ls-tree HEAD
# 100644 blob <sha> README.md
# 040000 tree <sha> src/
# Verify a signed commit (GPG or SSH-signed)
git verify-commit HEAD
git log --show-signature -1 HEAD
# Purge a leaked secret from history (forces history rewrite)
echo 'AWS_SECRET_ACCESS_KEY=abcd1234==>' > /tmp/repl.txt
git filter-repo --replace-text /tmp/repl.txt --force
# Inspect pack contents without unpacking
git verify-pack -v .git/objects/pack/pack-*.idx | head -20
# <sha> <type> <size> <size-in-pack> <offset-in-pack>
# Use bisect to find which commit broke a test
git bisect start
git bisect bad HEAD
git bisect good v2.3.0
# git checks out midpoint; run your test; mark with `git bisect good` or `git bisect bad`
# Loop until: `git bisect run make test` automates the loop
git bisect reset

For .git/ exposure detection (network side):

  • nuclei -t http/exposures/git/ -u https://target.tld finds .git/config or .git/HEAD reachable.
  • Suricata SID for HTTP requests to /.git/ paths — depends on rule set; Emerging Threats has ET POLICY HTTP request for /.git/config [verify].

For secret-in-history detection (host side):

  • gitleaks detect runs as a CI step on every push.
  • GitHub Advanced Security / GitGuardian / GitLab Secret Detection all flag common patterns (AKIA[0-9A-Z]{16}, gh[ps]_[A-Za-z0-9]{36}, etc.) at push time.
  • TruffleHog scans for verified secrets (calls the API to confirm a leaked token is live).

MITRE ATT&CK T1552.001 — Unsecured Credentials: Credentials In Files (the canonical case — secrets committed to git, then exfiltrated via a public mirror).

MITRE ATT&CK T1552.004 — Unsecured Credentials: Credentials in files at rest (the same technique applied to .git/-exposure downloads of source code).

MITRE ATT&CK T1213.003 — Data from Information Repositories: Code Repositories (adversary clones target’s public/private Git to harvest secrets and IP).

Wazuh rule 920 / 415 — git exposure path: requests to /.git/ on a web server [verify — typically needs a custom decoder for HTTP URL fields].

For supply-chain integrity: signed commits with GPG (git config commit.gpgsign true) or SSH keys (Git 2.19+ with gpg.format=ssh and user.signingkey=<ssh-key-id>). Branch protection rules force git verify-commit to succeed before merge.

Force-push after history rewrite erases the trail — locally. Anyone who cloned before the rewrite still has the original commits in objects/. The Git server (GitHub) keeps a reflog of its own that is invisible to the public; an enterprise audit log can recover the original commit SHAs.

Rewrite secrets into “deleted” objects, then git gc --prune=now. git filter-repo leaves unreachable objects in .git/objects/. Running git reflog expire --expire=now --all && git gc --prune=now actually deletes them. But the original objects may still be in remote clones; the GitHub-side reflog is still intact.

Submodule URLs starting with - (CVE-2017-1000117). Before the patch, a .gitmodules entry with url = -ext-foo was parsed as git clone -ext-foo, injecting command-line flags. Defense: upgrade Git; on the server side, reject submodule URLs starting with -.

SSH URL trick (CVE-2018-17456). url = ssh://-oProxyCommand=...@attacker.tld/path injected ProxyCommand into SSH before Git 2.14.5. Same patch family as 2017-1000117.

.git/ exposure even on sites with .htaccess. Apache serves .htaccess if AllowOverride None is set, but the default on Debian is AllowOverride All, which means the .git/.htaccess shipped by Git does work — except when an admin copies a project’s .htaccess to a different directory without copying .git/.htaccess, exposing the repo. Defense: RedirectMatch 404 /\\.git in the server config.

EvasionWhat it hidesHow to detect
git gc --prune=nowLocal unreachable objectsCompare git log --all --reflog vs git log --all; gap indicates pruning
Force-push rewriteOld commit history on remoteServer-side reflog; GitHub audit log records force-pushes
.git/ exposure via misconfigSource code + secretsnuclei scan; Suricata SID for /.git/ paths
Submodule URL injectionArgument injection in git cloneUpdate Git; reject URLs starting with - server-side
SSH ProxyCommand injectionPre-auth command exec on cloneUpdate Git; reject URLs starting with -

⚠️ What caught me: An internal engagement where the customer swore no secrets were ever committed. We ran git rev-list --objects --all --reflog (note --reflog — also recovers unreachable objects that the reflog still references). We pulled every blob, grepped for BEGIN PRIVATE KEY, and found a developer’s SSH private key in a .env file that had been committed and deleted in the same afternoon, three years earlier. The secret was still there because git gc had never been run with --prune=now. Lesson: --reflog is the forensics flag. Without it, you only see the current refs; with it, you see the entire 90-day default reflog window even after deletes.

VM: Ubuntu 22.04 with git, python3, git-filter-repo, gitleaks, nuclei. A second VM with nginx serving a deliberately vulnerable site where .git/ is reachable at http://target/.git/.

Objective:

  1. Create a repo, commit a file with a fake AWS_SECRET_ACCESS_KEY, then commit a fix that removes the line. Run gitleaks detect and confirm it still flags the original commit.
  2. Use git filter-repo --replace-text to purge the secret, force-push to a fresh clone, and confirm gitleaks detect finds nothing.
  3. Run git-dumper against the lab nginx site, reconstruct the repo, and demonstrate you can read git log from the recovered clone.
  4. Use git bisect to identify which of 10 commits introduced a failing test.

Expected flag format: flag{gitleaks_found_then_purged_git_dumped_off_web_bisect_id_<sha>}.

Detection that should fire: gitleaks exit code 1 with JSON report; git filter-repo writes a new filter-repo/ directory with backup data; nuclei reports git-config exposure; bisect prints a single commit SHA at the end.

Conceptual: explain the difference between a ref and a reflog entry. Why does deleting a branch with git branch -D not actually delete the commits?

Technical: walk through what happens when you run git push --force to a remote that has branch protection requiring signed commits. Which server-side checks run, and in what order?

Scenario: a customer found a BEGIN PRIVATE KEY block in their public GitHub repo’s history. They want to “just delete it.” Explain why git rm does nothing, why git filter-branch is the wrong tool now, and what the correct sequence of commands is.

What is the difference between git log -S 'string' and git log -G 'string'? When would you use one over the other?

Technical: describe the .git/ exposure vulnerability in terms of (a) what HTTP responses the attacker reads, (b) how git-dumper.py reconstructs the working tree from those responses, and (c) one config directive that prevents the exposure even if .git/.htaccess is missing.

→ F5 (Python for Security) · F7 (PowerShell Fundamentals) · S11 (Source Code Review)