May 20, 2026

Git Forensics for CTF: What to Do When git log Returns Nothing

Run git log --all -p first. If that comes back empty, the flag is in one of seven other places git never actually deletes. Here is the full map.

The challenge description said "I accidentally wrote the flag down. Good thing I deleted it!" The first thing I did was cat message.txt. Empty file.

Then I ran git log. The flag was right there, in a commit message titled "create flag," two commits back. It had never left.

That was Commitment Issues from picoCTF 2024. It taught me something I should have known earlier: deleting a file in git does not delete the file. It creates a new commit whose tree does not reference the old blob. The blob itself stays in .git/objects/ until a garbage collection run explicitly clears it. In CTF repos, that never happens.

The harder lesson is that git log is not git history. It is the reachable history from your current branch. An entire second universe of data lives in every repo: feature branches you cannot see from main, stash snapshots, the reflog, annotated tags, notes attached to commits out of band, and objects that are unreferenced but not yet collected. Eight spots total. Most players know one or two. This post is the map for all eight.

Note: Reading paths. Want the commands immediately? Jump to the quick answer. Flag on a feature branch? Hidden branches. Working with a disk image? The disk image variant. Nothing worked? Dangling objects is the last resort.

The quick answer

If this is all you need, here are the eight commands covering every hiding spot, in order of how often each one appears in picoCTF challenges. Run them sequentially until one returns something.

# 1. Commit messages and diffs across all branches
git log --all -p | grep -A3 picoCTF
# 2. All branches (including ones never merged to main)
git branch -a
# 3. Stash entries
git stash list && git stash show -p
# 4. Reflog (every HEAD position, including deleted branches and force-pushes)
git reflog
# 5. All annotated tags
for tag in $(git tag -l); do echo "=== $tag ==="; git show "$tag"; done
# 6. Notes attached to commits
for c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done
# 7. Unreachable (dangling) blobs
git fsck --unreachable 2>&1 | grep blob
# 8. Lost-and-found blobs (decompressed, easy to grep)
git fsck --lost-found && grep -r picoCTF .git/lost-found/

git log --all -p | grep -A3 picoCTF covers the most common case. Spots 3 through 8 are where the harder forensics challenges live, because most players stop at spot 2.

Why git never truly deletes

Git is a content-addressable object store. Every file you ever commit is stored as a blob object named by the SHA-1 hash of its content. Commit objects reference tree objects (directory snapshots), which reference blobs. This is not a filesystem: there are no directories with pointers to files on disk. There are only objects pointing to other objects by hash.

Four object types:

  • blob: the raw content of one file at one point in time
  • tree: a directory snapshot listing blob hashes and sub-tree hashes with names
  • commit: a pointer to a tree, plus parent commit hash(es), author, timestamp, and message
  • tag: a pointer to any object, with an optional message (annotated tags only)

When you delete a file and commit, you create a new commit whose tree does not reference the old blob. The blob stays in .git/objects/. The old commit that referenced it stays too. Git never overwrites objects; it only stops pointing to them. The old data is unreachable by name but still physically present on disk until git gc --prune=now runs. CTF repos are never garbage-collected.

Linus Torvalds described git's original design as a content archiver first and a version control system second. Losing committed data was simply not an acceptable outcome. So git keeps everything. That design decision is exactly what challenge authors exploit.

Git was designed to never lose your data. That same guarantee is why "I deleted it" in a CTF challenge description is a hint, not a spoiler.

Every object in .git/objects/ is permanent until git gc prunes it. References (branches, tags, stash, reflog) are named pointers to those objects. Delete the reference and the object persists. That is the entire model.

The eight hiding spots

Every git forensics challenge hides data in one of these locations. The signal column says why you are here; the command column says what to run.

#Hiding spotSignalKey command
1Commit messages + diffs"I left a note" or "I wrote it down"git log --all -p | grep picoCTF
2Deleted file in a commit"I deleted it" with a prior create commitgit show <hash>:filename
3Hidden branch"team", "features", "collaborate"git branch -a
4Stash entryWork-in-progress, interrupted, saved stategit stash list
5ReflogForce-pushed, amended, reset, deleted branchgit reflog
6Annotated tagRelease marker, version taggit tag -l
7Git notesOut-of-band metadata on commitsgit notes list
8Dangling objectsNothing found anywhere elsegit fsck --lost-found

Spots 1 through 3 cover the 2024 general skills series. Spots 4 through 8 are the harder Forensics Git series (2026) and any challenge whose description hints at something being "lost" or "erased."

Spots 1-2: commit history and deleted files

The most common hiding spot: the flag is in a commit message, or in a file that existed in an earlier commit and was then deleted.

Commit messages and diffs. The --all flag includes commits on every branch, not just the current one. Without it, you miss any commits reachable only from non-current branches. The -p flag adds the full diff for each commit. Combined with grep, this single pipeline scans every addition and deletion in the entire repository history:

# Scan every commit message and diff across all branches:
git log --all -p | grep -A3 picoCTF
# Search only commit messages (faster on large repos):
git log --all --grep='picoCTF'
# Scope to a single file (useful for blame-game style challenges):
git log --all -p -- message.py

Time Machine hides the flag in a commit message. Plain git log finds it because the commit is on the default branch. Blame Game needs the file-scoped version: the interesting commit is buried in the history of message.py specifically, and the culprit is in the author field rather than the message text.

Reading deleted file content.When the challenge says the flag was "deleted," the blob still exists. Use git show to read the file as it was at any historical commit without changing the working tree:

# Find the commit that created the file before it was deleted:
git log --all --oneline
# Read the file from that specific commit (no working tree change):
git show <hash>:message.txt
# Or check out that commit to restore all files:
git checkout <hash>
cat message.txt
git checkout main # return to HEAD when done

Receipt: Commitment Issues has two commits: "create flag" and "delete flag." The blob from the first commit persists. git show <hash>:message.txt reads it without touching the working tree.

Note: git log vs. git log --all. Without --all, git log shows only commits reachable from the current branch. A flag hidden on feature/secret that was never merged is invisible to plain git log. Always add the flag on CTF repos.

git log --all -p | grep picoCTF covers both commit messages and file diffs in one pipeline. For deleted files, git show <hash>:filename reads any historical version without checking out the commit.

Spot 3: hidden branches

A branch is a named pointer to a commit. If that branch was never merged into your current branch, its commits are invisible to a plain git log. But the branch still exists, and git branch -a lists it.

# List all local and remote-tracking branches:
git branch -a
# Read a file from a branch without checking it out:
git show feature/secret:flag.py
# Or check out the branch:
git checkout feature/part-1 && cat flag.py
# Return to main when done:
git checkout main

Receipt: Collaborative Development splits the flag across three feature branches (feature/part-1, feature/part-2, feature/part-3). Each branch holds one segment of the flag inside flag.py. Check out each branch, read the file, and concatenate the three segments.

Key insight: In real security research, abandoned feature branches are a major source of leaked secrets. A developer commits an API key to a branch, notices the mistake, removes it in a follow-up commit, and moves on. But the original commit sits in that branch's history until someone prunes it. Tools like truffleHog and gitleaks scan every branch by default for exactly this reason.

git branch -a lists every branch. git show <branch>:file reads any file from that branch without switching. Any branch that was pushed to a remote stays in remotes/origin/ until the remote is wiped.

Spots 4-5: stash and reflog

These two are the most commonly overlooked spots. Neither shows up in git log. Neither shows up in git branch -a.

Stash. The stash is a hidden stack of work-in-progress snapshots. Running git stash saves your current uncommitted changes as a special commit object and resets the working tree to HEAD. These snapshot commits live under refs/stash and are completely invisible to git log unless you know the commands.

# Show all stash entries:
git stash list
# Show the diff for the most recent entry:
git stash show -p
# Show a specific entry by index:
git stash show -p stash@{0}
git stash show -p stash@{1}
# Show the full stash commit object:
git show stash@{0}

Reflog. Every time HEAD moves, git records the old value in the reflog. This includes every commit, merge, checkout, reset, and force-push. A commit that was replaced by git reset --hard or git commit --amend appears in the reflog as the entry just before the move. A branch that was deleted still appears in the reflog until the default 30-day expiry.

# Show the HEAD reflog (every HEAD position, newest first):
git reflog
# Show the reflog for a specific branch:
git reflog show main
# Read a commit that was force-pushed over or reset away:
git show HEAD@{2}
git show HEAD@{2}:flag.txt
# See all branch creations and deletions across all refs:
git reflog show --all | grep 'branch:'

The reflog expiry is 90 days for reachable commits and 30 days for unreachable ones. CTF repos are brand new, nothing has been collected, and the full reflog is always intact.

The stash is git's hidden pocket. The reflog is git's memory of everywhere HEAD has been. Neither is a secret, but both are invisible unless you know the commands.

git stash list && git stash show -p covers stash entries. git reflog covers deleted branches, force-pushed commits, and amended commits. Run both before reaching for the heavier git fsck tools.

Spots 6-7: tags and notes

These two are genuinely surprising the first time you see them in a challenge. They require knowing git features that most developers never touch in normal work.

Annotated tags. Tags come in two flavors. Lightweight tags are just a pointer to a commit with no extra object. Annotated tags are full objects in the git store: they have a tagger identity, a timestamp, and a message. That message can contain a flag.

# List all tags:
git tag -l
# Show a tag (includes the message for annotated tags):
git show v1.0
# Iterate all tags to catch every message:
for tag in $(git tag -l); do echo "=== $tag ==="; git show "$tag"; done
# Check whether a tag is annotated (object type) or lightweight (commit type):
git cat-file -t v1.0
# 'tag' = annotated; 'commit' = lightweight

Git notes.Notes let you attach arbitrary text to an existing commit without changing that commit's hash. They live in a separate ref namespace: refs/notes/commits. Running git log can show notes, but only when the notes ref is fetched and configured. On a copied or locally-cloned repo, you need to iterate them manually.

# Check if any notes exist:
git notes list
# Show the note on a specific commit:
git notes show <commit-hash>
# Iterate all commits and print any attached notes:
for c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done
# Notes are stored under refs/notes/:
git log refs/notes/commits --oneline 2>/dev/null
Key insight: Git notes are used in production at scale. Gerrit, the code review system powering Android and Chromium, stores all review comments as git notes on commits. Most developers never see them because their tooling hides the refs. CTF challenge authors use exactly that invisibility as the hiding mechanism.

for tag in $(git tag -l); do git show "$tag"; done covers annotated tag messages. The notes loop covers out-of-band commit metadata. Both are invisible to a plain git log.

Spot 8: dangling objects and lost-found

You have checked all seven spots above and found nothing. The flag is in a dangling object: a blob, commit, or tree in .git/objects/ with no path from any named reference.

Dangling objects accumulate from: amending commits (the original version becomes dangling), force-pushing (the overwritten commits become dangling), dropping stash entries, and deliberate CTF trickery where a blob is created and then all references to it are removed.

# List every unreachable object:
git fsck --unreachable 2>&1
# Filter to just blobs (file contents):
git fsck --unreachable 2>&1 | grep 'unreachable blob'
# Read any dangling blob by its SHA-1 hash:
git cat-file -p <blob-hash>
# The shortcut: write all dangling objects to .git/lost-found/
git fsck --lost-found
# Blobs land in .git/lost-found/other/, already decompressed:
grep -r picoCTF .git/lost-found/other/
ls .git/lost-found/other/ # each file is named by its SHA-1 hash

The key distinction: loose object files at .git/objects/XX/YY... are zlib-compressed. You cannot cat them directly. Use git cat-file -p <hash> or decompress manually:

# Decompress a loose object manually
# (useful when git commands are unavailable or the repo structure is damaged):
python3 -c 'import sys, zlib; sys.stdout.buffer.write(zlib.decompress(open(sys.argv[1],"rb").read()))' .git/objects/ab/cdef1234...
# The lost-found blobs are already plain text, so this is only needed
# when working directly with loose objects.
Note: Why zlib? Git compresses every loose object with zlib to save disk space. The raw bytes are blob <size>\0<content> zlib-compressed. The --lost-found command decompresses these automatically when writing to .git/lost-found/, which is why grepping that directory is simpler than scanning the raw object store.

git fsck --lost-found writes every dangling blob to .git/lost-found/other/ in plain text. grep -r picoCTF .git/lost-found/other/ then scans all of them at once. This is the last resort that catches everything.

The disk image variant

The Forensics Git series (picoCTF 2026) adds a layer before the git commands: the repository lives inside a disk image that needs to be mounted first.

Step 1: decompress and strings triage.

gunzip disk.img.gz
# Fast pre-check: if the flag is in any plain-text git object, this finds it.
strings disk.img | grep picoCTF

strings scans raw bytes for printable character runs with no filesystem awareness. If the flag is stored as a plain-text blob anywhere in the image, this finds it before you even mount. If not, keep going.

Step 2: find the partition and mount.

# Read the partition table:
mmls disk.img
# Sample output:
# 002: 000 0000002048 0000262143 0000260096 Linux (0x83)
# ^-- Start sector (multiply by 512 for byte offset)
# Mount the data partition read-only:
sudo mkdir -p /mnt/disk
sudo mount -o loop,ro,offset=$((512 * 2048)) disk.img /mnt/disk

mmls is from The Sleuth Kit (TSK). It reads the partition table without mounting. The Start column is in 512-byte sectors; multiply by 512 to get the byte offset that mount -o offset expects.

Step 3: find, copy, and run git commands.

# Find the git repository on the mounted image:
find /mnt/disk -name '.git' -type d
# Copy the whole repository to a writable path:
cp -r /mnt/disk/path/to/repo /tmp/repo
cd /tmp/repo
# Now run all eight spot commands from the quick reference above.

Why copy instead of running git commands in place? Two reasons. The mounted filesystem is often read-only, and even a read-write mount would cause git to update the reflog on your evidence image. Copying to /tmp sidesteps both problems and leaves the original image intact.

Note: When .git is partially deleted. Forensics Git 2 presents a repo whose deletion routine was interrupted. If find /mnt/disk -name '.git' returns nothing, switch to TSK raw inode tools: fls -r -d disk.img to list deleted inodes and icat disk.img <inode> to extract them. See CTF Disk Forensics for the full TSK workflow including partition offsets and inode recovery.

Disk image challenges add three commands before the git work: gunzip, mmls to find the partition offset, and mount -o loop,ro,offset=N to mount it. Copy the repo to /tmp and run the standard eight-spot checklist.

picoCTF challenges

Every git forensics challenge on the site, mapped to its hiding spot.

ChallengeWhat hides the flagKey technique
Time Machine (2024)Flag in a commit messagegit log --all
Commitment Issues (2024)Flag in a file deleted in a follow-up commitgit show <hash>:message.txt
Blame Game (2024)Flag is the author field of a suspicious commit to message.pygit log -- message.py + git show
Collaborative Development (2024)Flag split across three feature branchesgit branch -a + git checkout each branch
Forensics Git 0 (2026)Disk image; git repo inside; flag in commits, branches, stash, tags, notes, reflog, or dangling objectsmmls + mount + copy + all 8 spot commands
Forensics Git 1 (2026)Disk image; requires iterating all eight hiding spots including tags and notesFull checklist; tag and notes loops required
Forensics Git 2 (2026)Disk image; deletion routine interrupted; partial .git may need TSK recoveryfls -r -d + icat + git fsck --lost-found

The 2024 challenges (Time Machine through Collaborative Development) are plain git repos with no disk image. They are the right starting point. The 2026 Forensics Git series layers disk image work on top. Work through the 2024 set first, then tackle Forensics Git 0.

For the shell skills underlying these challenges: Linux CLI for CTF covers the find, cat, and grep commands used throughout. For disk image work including mmls and icat in full detail: CTF Disk Forensics. For memory forensics (RAM dumps instead of disk images): Volatility 3 for CTF Memory Forensics.

Quick reference

The complete command checklist for any git forensics challenge.

TaskCommand
Search all commit messages and diffsgit log --all -p | grep -A3 picoCTF
Read deleted file from a commitgit show <hash>:filename
Scope log to one filegit log --all -p -- file.py
List all branchesgit branch -a
Read file from a branchgit show <branch>:file
List stash entriesgit stash list
Show stash diffgit stash show -p stash@{0}
Show HEAD refloggit reflog
Read reflog commitgit show HEAD@{N}
List all tagsgit tag -l
Show tag contentgit show <tagname>
Notes on all commitsfor c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done
List dangling blobsgit fsck --unreachable 2>&1 | grep blob
Write lost-found blobsgit fsck --lost-found
Search lost-foundgrep -r picoCTF .git/lost-found/other/
Read blob by hashgit cat-file -p <hash>
Partition table (disk image)mmls disk.img
Mount partitionsudo mount -o loop,ro,offset=$((512*N)) disk.img /mnt/
Find .git in mounted imagefind /mnt/disk -name '.git' -type d
Copy repo to writable pathcp -r /mnt/disk/repo /tmp/repo

The hardest part of git forensics is not knowing the commands. It is remembering that git log is a filtered view, not the complete picture. Start with the eight-spot checklist, and the flag is in there somewhere.