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.
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 branchesgit log --all -p | grep -A3 picoCTF# 2. All branches (including ones never merged to main)git branch -a# 3. Stash entriesgit stash list && git stash show -p# 4. Reflog (every HEAD position, including deleted branches and force-pushes)git reflog# 5. All annotated tagsfor tag in $(git tag -l); do echo "=== $tag ==="; git show "$tag"; done# 6. Notes attached to commitsfor c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done# 7. Unreachable (dangling) blobsgit 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 spot | Signal | Key command |
|---|---|---|---|
| 1 | Commit messages + diffs | "I left a note" or "I wrote it down" | git log --all -p | grep picoCTF |
| 2 | Deleted file in a commit | "I deleted it" with a prior create commit | git show <hash>:filename |
| 3 | Hidden branch | "team", "features", "collaborate" | git branch -a |
| 4 | Stash entry | Work-in-progress, interrupted, saved state | git stash list |
| 5 | Reflog | Force-pushed, amended, reset, deleted branch | git reflog |
| 6 | Annotated tag | Release marker, version tag | git tag -l |
| 7 | Git notes | Out-of-band metadata on commits | git notes list |
| 8 | Dangling objects | Nothing found anywhere else | git 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.txtgit 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.
--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.
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.
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.
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/disksudo 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/repocd /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.
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.
| Challenge | What hides the flag | Key technique |
|---|---|---|
| Time Machine (2024) | Flag in a commit message | git log --all |
| Commitment Issues (2024) | Flag in a file deleted in a follow-up commit | git show <hash>:message.txt |
| Blame Game (2024) | Flag is the author field of a suspicious commit to message.py | git log -- message.py + git show |
| Collaborative Development (2024) | Flag split across three feature branches | git 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 objects | mmls + mount + copy + all 8 spot commands |
| Forensics Git 1 (2026) | Disk image; requires iterating all eight hiding spots including tags and notes | Full checklist; tag and notes loops required |
| Forensics Git 2 (2026) | Disk image; deletion routine interrupted; partial .git may need TSK recovery | fls -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.
| Task | Command |
|---|---|
| Search all commit messages and diffs | git log --all -p | grep -A3 picoCTF |
| Read deleted file from a commit | git show <hash>:filename |
| Scope log to one file | git log --all -p -- file.py |
| List all branches | git branch -a |
| Read file from a branch | git show <branch>:file |
| List stash entries | git stash list |
| Show stash diff | git stash show -p stash@{0} |
| Show HEAD reflog | git reflog |
| Read reflog commit | git show HEAD@{N} |
| List all tags | git tag -l |
| Show tag content | git show <tagname> |
| Notes on all commits | for c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done |
| List dangling blobs | git fsck --unreachable 2>&1 | grep blob |
| Write lost-found blobs | git fsck --lost-found |
| Search lost-found | grep -r picoCTF .git/lost-found/other/ |
| Read blob by hash | git cat-file -p <hash> |
| Partition table (disk image) | mmls disk.img |
| Mount partition | sudo mount -o loop,ro,offset=$((512*N)) disk.img /mnt/ |
| Find .git in mounted image | find /mnt/disk -name '.git' -type d |
| Copy repo to writable path | cp -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.