Git forensics: an exposed .git is the whole source tree
How to reconstruct a repository from an exposed .git directory over HTTP, find the secret that was committed and then deleted, recover dangling objects from a repo you already have, and read the other version-control leftovers.
Git stores the complete history of a project in a .git directory beside the working tree. Deploy by git pull onto a web root and that directory is served as static files - which means anyone can reconstruct not just the current source but every version of it, including the commit where someone added a key and the commit where they took it out.
This is one of the highest-value checks in web recon and it costs one request. It is also a self-contained forensics exercise when you are handed a repository directly, because deleting a file from a repo does not delete it from the repo.
Detecting exposure
curl -s https://target/.git/HEAD # "ref: refs/heads/main" -> exposed
curl -s https://target/.git/config # remote URL, sometimes with credentials
curl -s https://target/.git/logs/HEAD # every commit HEAD ever pointed atA 200 with ref: refs/heads/ is conclusive. A 403 is interesting too - it means the directory exists and the server has a rule about it, and rules have gaps: try /.git/config directly, try //.git/HEAD, try URL-encoding the dot, and try the path traversal encodings. The same is true of a server that blocks directory listing but happily serves individual files, which is the normal case.
- `/.git/logs/HEAD` is the best single file. It is plain text, it lists every commit hash HEAD moved through, and it works even when directory listing is off - which means you can fetch objects by hash without needing to enumerate anything.
- `/.git/index` is a binary file listing every tracked path with its blob hash. Parse it and you know the entire file layout and what to fetch.
- `/.git/packed-refs` names every branch and tag.
- Objects are at `/.git/objects/ab/cdef...` - the first two hex characters are the directory. They are zlib-compressed, so
curl | zlib-flate -uncompressor a script. - Packfiles at
/.git/objects/pack/*.packhold most history in older repositories. Get the.idxfirst to know what is inside.
# The established tools, if you would rather not write it.
git-dumper https://target/.git/ ./out
# or
gitdumper.sh https://target/.git/ ./out && extractor.sh ./out ./srcWhat to read once you have it
Having the current source is the smaller half. The history is the point.
git log --all --oneline --graph
git log --all --diff-filter=D --name-only # files that were deleted
git log -p --all -S 'secret' --pickaxe-all # commits that added or removed a string
git grep -n 'password\|api_key\|BEGIN PRIVATE' $(git rev-list --all)-S (the pickaxe) is the important one: it searches for commits where the *count* of a string changed, which finds both the commit that introduced a secret and the commit that removed it.- A deleted file is still in every commit before the deletion.
git show <commit>:path/to/.envretrieves it. - A rewritten history leaves the old commits as dangling objects until garbage collection runs, which on a deployed repository it usually has not.
- Branches other than the current one are often forgotten.
--allmatters in every command above. - Stashes live in
refs/stashand hold uncommitted work, which is where people put the thing they did not want to commit. - `.git/config` may contain a remote URL with an embedded token, and
.git/credentialsmore so. - Commit metadata is [OSINT](/blog/osint-method) - author names, email addresses, timezones, and commit times that reveal a working pattern.
Dangling and unreachable objects
When you have the repository itself - handed to you as the challenge file - the interesting content is frequently not reachable from any branch. Amended commits, dropped stashes, reset branches and rebased history all leave objects in the store with nothing pointing at them.
git fsck --lost-found --unreachable --dangling
# dangling commit 3f2a... -> git show 3f2a
# dangling blob 9c1e... -> git cat-file -p 9c1e
# The reflog is the other route, and it is per-clone.
git reflog --all
git log -g --all
# Brute force: every object in the store, whether referenced or not.
git cat-file --batch-all-objects --batch-check |
awk '$2=="blob"{print $1}' |
while read h; do git cat-file -p "$h" | grep -l 'flag{' - >/dev/null && echo "$h"; doneThe other version-control leftovers
| Path | What it gives you |
|---|---|
| /.svn/wc.db or /.svn/entries | Subversion working copy metadata; svn-extractor reconstructs the tree. |
| /.hg/store | Mercurial. Same idea, different layout. |
| /.bzr/ | Bazaar, rarer but identical in principle. |
| /CVS/Entries and /CVS/Root | Very old, still deployed occasionally. |
| /.DS_Store | Not version control, but it lists directory contents - a free wordlist for content discovery. |
| /.env, /.env.bak, /config.php.swp | The manual version of the same mistake. .swp files from an interrupted vim session hold the whole buffer. |
| /composer.lock, /package-lock.json, /yarn.lock | Exact dependency versions, which is a vulnerability map. |
The .swp case is worth a line of its own: vim -r file.swp recovers the file being edited, and the swap file is often left behind on a server where someone edited a config in place.
Preventing it, which is also how to recognise it
Understanding the fix sharpens the detection. The correct deployment does not copy .git at all - it exports a tree, or builds an artefact. A server-level block on .git is a second layer, and it is the one with gaps. When a challenge presents a 403 on /.git/ and a 200 on /.git/config, you are looking at exactly that second layer, applied to the directory and not to its contents.
Working an exposed repository, in order
curl /.git/HEADon every host in scope. It is one request and it either ends the challenge or costs nothing.- If blocked, try
/.git/config,/.git/logs/HEAD, encoded dots, and a double slash. - Dump the repository -
logs/HEADandindexfirst, then objects and packfiles. git log --all, then pickaxe for the words that name secrets.- List deleted files and retrieve each from the commit before its deletion.
git fsck --unreachableandgit cat-file --batch-all-objectsfor anything not on a branch.- Then read the source properly - you now have the application's code, which changes every other part of the challenge.
That last step is easy to skip in the excitement of a successful dump. An exposed .git is rarely the flag; it is the thing that turns a black-box target into a white-box one, and every other bug in the application becomes far easier to find with the source in front of you.