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.
The Core Concept
Section titled “The Core Concept”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.
How It Works
Section titled “How It Works”-
The
.git/directory layout:HEAD— text file withref: refs/heads/main(or similar).config— repo-level config (user.name,remote.origin.url,core.bare).index— staged changes; binary format, can be parsed withgit 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.
-
Plumbing commands — the forensic toolkit:
Terminal window git cat-file -t <sha> # blob | tree | commit | taggit cat-file -p <sha> # pretty-print contentsgit cat-file -s <sha> # size in bytesgit hash-object -w file.txt # write file as blob, print SHAgit ls-tree <sha> # list a tree's entriesgit rev-list --all # list every reachable commitgit rev-list --all --objects # list every reachable object (incl. blobs)git fsck --unreachable # objects not pointed to by any refgit fsck --lost-found # write unreachable objects to .git/lost-found/git reflog show <branch> # every update to that branchgit stash list # reflog for the stash -
The
git logflags 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-Sbut 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'.
-
git bisect— binary search through history for the commit that introduced a bug:Terminal window git bisect startgit bisect bad HEADgit bisect good v1.2.0# git checks out the midpoint; you mark good/bad until it identifies the culprit -
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 --allthengit cat-file -peach.
- Stage 1 (preventive): pre-commit hooks (
-
git filter-repo— purging secrets (replaces the now-deprecatedgit filter-branchandbfg):Terminal window pip install git-filter-repoecho 'MY_AWS_KEY==>' > replacements.txtgit 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/mainafter the force-push. -
.git/directory exposure on web servers: a misconfigured Apache/Nginx served.git/as static files. Attackers download/.git/config,/.git/HEAD,/.git/index, thengit-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).
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
.git/ exposure | Misconfigured web server serves .git/ over HTTP | Easy — wmap / nuclei -t git-config.yaml finds it |
| Recovered deleted branch | git fsck --lost-found finds the dangling commit | Easy — the object is on disk until git gc --prune=now |
| Reflog recovery | git reflog show reveals the SHA the branch pointed at before reset | Easy — by default reflog is kept 90 days |
| Stash recovery | git stash list shows dropped stashes | Easy — stashes live in reflog until pruned |
| Submodule URL injection (CVE-2017-1000117) | Attacker crafts a submodule URL that starts with - to inject git args | Easy — 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 clone | Easy — patched in 2.17.1 |
| SSH recursion (CVE-2018-17456) | ssh://-foo argument injection in git clone --recurse-submodules | Easy — patched in 2.14.5, 2.15.x, 2.16.x, 2.17.x, 2.18.x, 2.19.x |
Hands-On Commands
Section titled “Hands-On Commands”# Show every object in the repo, including unreachable onesgit rev-list --objects --all --refloggit fsck --unreachable --dangling --no-reflogs
# Recover a deleted branch from a reflog entrygit reflog show --all | grep <deleted-branch-name> # find last SHA before deletegit checkout -b recovered <sha>
# Print every blob in the repo and grep for secretsgit 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=' --allgit 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 porcelaingit 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 commitgit ls-tree HEAD# 100644 blob <sha> README.md# 040000 tree <sha> src/
# Verify a signed commit (GPG or SSH-signed)git verify-commit HEADgit log --show-signature -1 HEAD
# Purge a leaked secret from history (forces history rewrite)echo 'AWS_SECRET_ACCESS_KEY=abcd1234==>' > /tmp/repl.txtgit filter-repo --replace-text /tmp/repl.txt --force
# Inspect pack contents without unpackinggit 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 testgit bisect startgit bisect bad HEADgit 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 loopgit bisect resetDetection — Defender View
Section titled “Detection — Defender View”For .git/ exposure detection (network side):
nuclei -t http/exposures/git/ -u https://target.tldfinds.git/configor.git/HEADreachable.- Suricata SID for HTTP requests to
/.git/paths — depends on rule set; Emerging Threats hasET POLICY HTTP request for /.git/config[verify].
For secret-in-history detection (host side):
gitleaks detectruns 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.
Evasion Techniques
Section titled “Evasion Techniques”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.
| Evasion | What it hides | How to detect |
|---|---|---|
git gc --prune=now | Local unreachable objects | Compare git log --all --reflog vs git log --all; gap indicates pruning |
| Force-push rewrite | Old commit history on remote | Server-side reflog; GitHub audit log records force-pushes |
.git/ exposure via misconfig | Source code + secrets | nuclei scan; Suricata SID for /.git/ paths |
| Submodule URL injection | Argument injection in git clone | Update Git; reject URLs starting with - server-side |
SSH ProxyCommand injection | Pre-auth command exec on clone | Update 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 forBEGIN PRIVATE KEY, and found a developer’s SSH private key in a.envfile that had been committed and deleted in the same afternoon, three years earlier. The secret was still there becausegit gchad never been run with--prune=now. Lesson:--reflogis 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.
Lab Practice
Section titled “Lab Practice”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:
- Create a repo, commit a file with a fake
AWS_SECRET_ACCESS_KEY, then commit a fix that removes the line. Rungitleaks detectand confirm it still flags the original commit. - Use
git filter-repo --replace-textto purge the secret, force-push to a fresh clone, and confirmgitleaks detectfinds nothing. - Run
git-dumperagainst the lab nginx site, reconstruct the repo, and demonstrate you can readgit logfrom the recovered clone. - Use
git bisectto 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.
Interview Questions
Section titled “Interview Questions”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.
Related Modules
Section titled “Related Modules”→ F5 (Python for Security) · F7 (PowerShell Fundamentals) · S11 (Source Code Review)