Skip to main content

Forensics Git 2 picoCTF 2026 Solution

Recover a flag from a disk image containing a git repository where evidence has been deliberately deleted or hidden.

Published: March 20, 2026Updated: August 13, 2026

Description

The agents interrupted the perpetrator's disk deletion routine. Can you recover this git repo?

Download and decompress the disk image.

Mount the image and inspect the filesystem for a damaged git repository.

bash
gunzip disk.img.gz
bash
mmls disk.img
bash
sudo mount -o loop,offset=$((512*<start_sector>)) disk.img /mnt/disk

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Decompress and quick strings check
    Observation
    The challenge says the deletion was interrupted, so the flag bytes may still be intact in the raw image. A quick strings grep may find them with no mounting at all.
    Extract the disk image. The deletion routine was interrupted, so git objects may still be present. Run strings first as a fast check.
    bash
    gunzip disk.img.gz
    bash
    strings disk.img | grep picoCTF
    What didn't work first

    Tried: Run foremost or photorec on disk.img to carve the git objects before trying strings.

    Carvers like foremost and PhotoRec look for known magic bytes, and a loose git object is a zlib blob with no recognized signature, so they skip it silently. The strings grep works because the flag text sits as uncompressed ASCII in the raw image bytes.

    Tried: Pipe strings to grep without the picoCTF prefix, searching for just 'flag' or 'secret' instead.

    A disk image holds thousands of matches for a generic word like flag or secret, from kernel strings, library symbols, and filesystem metadata. The picoCTF prefix is distinctive enough to return the one line you want.

    Learn more

    When a deletion process is interrupted, the filesystem may be in an inconsistent state: some files are deleted (their directory entries removed) but their data blocks have not yet been overwritten. The raw bytes of the deleted content remain on disk until the operating system reuses those blocks for new data. strings | grep picoCTF scans the raw image and finds these bytes even though the filesystem no longer has a path to them.

    This is the principle behind file carving: recovering files by their content patterns rather than their filesystem metadata. Known file types have recognizable headers (magic bytes): for example, JPEG files start with FF D8 FF, ZIP files with 50 4B 03 04, and git objects are zlib-compressed with a characteristic header. Carving tools like Foremost and PhotoRec scan raw disk images looking for these magic bytes and extract complete files even from unallocated space.

  2. Step 2Detect partition layout and mount
    Observation
    This is a compressed raw disk image rather than a filesystem image, so it carries a partition table. Find the byte offset with mmls before mounting.
    Use mmls to identify the partition offset, then mount the filesystem.
    bash
    mmls disk.img
    bash
    sudo mount -o loop,offset=$((512*<start_sector>)) disk.img /mnt/disk
    bash
    ls /mnt/disk
    What didn't work first

    Tried: Mount the image directly with sudo mount -o loop disk.img /mnt/disk without finding the partition offset first.

    A raw image with a partition table is not a mountable filesystem: it opens with an MBR or GPT header, not an ext4 superblock. Linux reads offset 0, finds partition metadata, and reports a bad superblock. Get the start sector from mmls and pass the byte offset to mount.

    Tried: Use fdisk -l disk.img instead of mmls to read the partition table.

    fdisk shows the same start sector, but reports it without making the unit size obvious. mmls is explicit and standard in forensic work, which makes the offset arithmetic less error-prone. Either gives the right number; one is easier to read.

    Learn more

    Even when files are deleted, the partition table and filesystem superblock are usually intact because they are written early in the deletion process and are often the last to be cleared. mmls reads the partition table to locate the data partition, and Linux can still mount and read the filesystem structure even if individual file inodes have been cleared.

    Linux ext4 filesystems mark deleted inodes as free in the inode bitmap but do not immediately zero the inode's block pointers. This means the Sleuth Kit's ils (inode list) and ifind tools can still see deleted inodes and their data block addresses for a period after deletion: long enough to recover recently deleted files. The fls -r -d command from TSK lists deleted files and directories by scanning for inodes marked as unallocated.

  3. Step 3Recreate missing ref directories so git works again
    Observation
    git reports this is not a repository even with a .git directory present. The hint says only refs/heads and refs/tags were deleted, so those two empty directories are all that is missing.
    Navigate to the repository directory on the mounted disk (or a writable copy of it). Git refuses to work because the refs/heads and refs/tags directories are gone. Recreate them, then git will recognize the repository normally and you can use standard git commands to inspect the object database.
    bash
    # Copy the repo to a writable location
    bash
    cp -r /mnt/disk/home/ctf-player/Code/killer-chat-app /tmp/recovered
    bash
    cd /tmp/recovered
    bash
    # Restore the two directories the deletion routine removed
    bash
    mkdir -p .git/refs/heads .git/refs/tags
    bash
    # Confirm git now sees the repo
    bash
    git status
    bash
    # List every object in the database
    bash
    find .git/objects -type f | sort
    What didn't work first

    Tried: Work directly inside /mnt/disk without copying the repo to /tmp first.

    A loop mount defaults to read-only, so creating the missing ref directories fails outright. Copy the repo somewhere writable first. Copy recovered artifacts before modifying any git metadata, always.

    Tried: Run git init inside the .git-containing directory to 'repair' the repository instead of recreating the two missing ref directories.

    git init in an existing repository overwrites HEAD and config and can alter the object database, destroying the forensic state you came for. Only two empty directories are missing, so recreate exactly those. init is too blunt and can leave log and reflog showing nothing.

    Learn more

    Git's object database stores blobs, trees, and commits as individual files under .git/objects/XX/YY.... These files are immutable once written: git gc is the only thing that removes unreachable objects, and gc was never run here. So even though the deletion routine ran, every object the developers ever wrote is still on disk. The only damage is structural: the two ref namespace directories that git expects to exist when it starts up are absent, causing git to report "not a git repository" rather than any data loss.

    Recreating .git/refs/heads and .git/refs/tags as empty directories is enough for git to accept the repository. The HEAD file survived intact and still points to the correct branch, so git status succeeds immediately after the mkdir. No TSK, no icat, no zlib decompression is required.

  4. Step 4Use git reflog to find old HEAD positions
    Observation
    refs/heads is gone, but the reflog lives in a separate directory and survives, and it still records every commit including the one that added the secret file.
    With git working again, check the reflog. Even though refs/heads was deleted, git stores a separate log of every HEAD movement in .git/logs/. The reflog lists old commit hashes that still point to the full history, including commits that introduced the secret file.
    bash
    # Primary discovery: read the reflog
    bash
    git reflog
    bash
    # Or read the raw log file directly
    bash
    cat .git/logs/HEAD
    bash
    # Find all commit objects as a cross-check
    bash
    git cat-file --batch-all-objects --batch-check | grep commit
    bash
    # Or use fsck to see dangling (unreachable) objects
    bash
    git fsck --unreachable
    What didn't work first

    Tried: Run git log --all to find old commits instead of git reflog.

    git log --all walks commits reachable from refs, and with refs/heads deleted no branch points into the history, so it returns the current HEAD commit or nothing at all. The reflog is independent of the ref graph and records every past HEAD position regardless.

    Tried: Run git fsck --lost-found hoping it creates .git/lost-found/ with all the blobs already decompressed and readable.

    fsck does write the dangling blobs out to lost-found, but you still have to work out which one holds the flag. The reflog is faster, because it shows commit messages that name the commit outright, so you check out a hash instead of scanning dozens of anonymous blobs.

    Learn more

    The reflog (.git/logs/) is a separate subsystem from the refs themselves (.git/refs/). Deleting .git/refs/heads/master removes the current branch pointer, but it does not touch .git/logs/HEAD or .git/logs/refs/heads/master. Those log files record every position HEAD has ever been at, complete with the commit hash, timestamp, and the action that moved HEAD (commit, checkout, reset, etc.). This makes the reflog the fastest path to finding old commits after a ref deletion: you can see entries like "commit: Add secret hideout chat log" with the exact hash, then jump directly to that state.

    Git loose object files are stored in .git/objects/XX/YYYYYYYY.... Because git gc was never run, every object ever written is still present. git cat-file --batch-all-objects --batch-check | grep commit lists all commit objects as a cross-check. git fsck --unreachable walks the object graph from known references and labels anything not reachable from HEAD or a branch as "dangling," giving you hash IDs to inspect.

  5. Step 5Checkout the old commit that added the secret file
    Observation
    The reflog shows an entry adding the secret chat log, then one removing it. Check out the earlier hash and the file comes back to the working tree.
    Spot the reflog entry for the commit that introduced the secret content, then checkout that commit. The full working tree is restored and you can read the file normally.
    bash
    # Identify the hash from the reflog output
    bash
    git reflog
    bash
    # Checkout the commit that added the secret file
    bash
    git checkout <add-commit-hash>
    bash
    # The secret file is now present in the working tree
    bash
    ls logs/
    bash
    cat logs/3.txt
    bash
    # Alternative: read the blob directly without checkout
    bash
    git show <add-commit-hash>:logs/3.txt
    What didn't work first

    Tried: Check out the most recent commit (HEAD) instead of the specific hash from the reflog entry that added the file.

    The most recent commit is the one that removes the file, so checking out HEAD gives you a tree without it. Check out the earlier commit, the one that added it.

    Tried: Use git show HEAD:logs/3.txt to read the file without checking out a different commit.

    HEAD is the removal commit, and the file is not in its tree, so git show against HEAD reports the path does not exist. Reference the earlier hash explicitly, through checkout or through show.

    Learn more

    Once the reflog reveals the hash for the commit titled "Add secret hideout chat log," git checkout <hash> places the entire repository into a detached-HEAD state at that point in history. The working tree is fully reconstructed from the commit's tree object: all files that existed at that commit, including the secret log file, reappear on disk as ordinary files. This is simpler than reading blobs by hash because you interact with a normal directory listing rather than git plumbing commands.

    git log --all also works after the ref directories are restored, showing every commit reachable from any ref. In this repo you will see a pair: a commit titled something like "Add secret hideout chat log" followed by "Remove secret hideout log." The removal commit only deletes the file from the working tree and records a new tree object; it never touches the blob that the earlier commit wrote. That blob is permanently in the object database. git ls-tree -r <hash> lists every file in a commit's snapshot and git show <commit>:logs/3.txt prints its content directly.

  6. Step 6Extract the flag
    Observation
    After the checkout the file is a plain file in the working tree, and the object database still holds its blob, so either cat or git cat-file reads it.
    Read flag content from whichever commit, blob, or recovered file contains it.
    bash
    git cat-file -p <blob-hash>
    bash
    git show <commit>
    bash
    cat .git/lost-found/other/<hash>
    Learn more

    After recovery, git cat-file -p and git show let you read the content of any recovered object. The .git/lost-found/other/ directory (created by git fsck --lost-found) contains the dangling blob objects already decompressed and named by their SHA-1 hash, so plain cat works directly. Loose objects under .git/objects/XX/YY... are still zlib-compressed; use git cat-file -p for those, or decompress manually with python3 -m zlib < .git/objects/XX/YY... .

    This challenge is a microcosm of real incident response: a threat actor attempted to destroy evidence by deleting a repository, the deletion was interrupted, and forensic investigators recover what they can from the surviving artifacts. The same skills (mounting disk images, understanding git internals, recovering commits and blobs) are used in real forensic investigations of developer workstations, source code repositories, and cloud storage buckets.

    For more on the surrounding shell workflow, see Linux CLI for CTF.

Interactive tools
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
  • File Magic IdentifierIdentify file types from magic numbers. Paste hex bytes or drop a file to detect PNG, JPEG, ZIP, PDF, ELF, PCAP, SQLite, and dozens of other formats.

Flag

Reveal flag

picoCTF{...}

The .git directory survived intact; only refs/heads and refs/tags were removed. After recreating those two directories, use git reflog to find old HEAD positions (the refs/heads deletion does not remove .git/logs/), then git checkout <hash> to restore the working tree to the commit containing the secret file.

Key takeaway

Git's object store is append-only in normal use: blobs, trees, and commits are immutable files collected only by an explicit gc. Deleting branch refs or ref directories removes pointers, not objects, so the history survives and the reflog or fsck recovers it. The same immutability is why a secret committed and later deleted must be rotated: the original blob lives on in every clone that ever fetched the commit.

How to prevent this

If an attacker (or insider) reaches a developer machine, file deletion is reversible. Plan for full-disk compromise.

  • Encrypt developer disks at rest (FileVault, BitLocker, LUKS). Recovered inodes from an encrypted volume are useless without the key.
  • Don't store production credentials in repos at all, even briefly. Use a secrets manager (AWS Secrets Manager, Doppler, 1Password CLI, Vercel env vars) and pull at runtime. Then disk recovery yields nothing useful.
  • For high-value repos, enforce secure_erase / shred on decommission and full-disk wipe (NIST 800-88 purge) on hardware retirement. rm -rf alone leaves recoverable inodes for weeks.

Related reading

Useful tools for Forensics

Where to go next