Forensics Git 2 picoCTF 2026 Solution

Published: March 20, 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 1
    Decompress and quick strings check
    Observation
    I noticed the challenge stated the deletion routine was interrupted before it could finish, which suggested the flag bytes might still be intact in the raw disk image and a quick strings grep could surface them without any filesystem mounting.
    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.

    Foremost and PhotoRec look for known file-type magic bytes (JPEG, ZIP, etc.) but git loose objects are zlib-compressed blobs with no recognized carving signature - carvers will silently skip them. The strings grep is faster and works directly because the flag text itself is uncompressed ASCII inside the git blob content, visible in the raw image bytes.

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

    A disk image contains thousands of matches for generic words like 'flag' or 'secret' from kernel strings, library symbols, and filesystem metadata, making the output unmanageable. The picoCTF prefix is unique enough to return only the flag line directly, so the targeted grep is essential for fast results.

    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 2
    Detect partition layout and mount
    Observation
    I noticed the disk image had a .gz extension and is a raw disk image rather than a filesystem image, which suggested it contains a partition table and requires mmls to find the correct byte offset before Linux can mount it.
    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 disk image containing a partition table is not itself a mountable filesystem - it starts with the MBR or GPT header, not ext4 superblock bytes. Linux will report 'wrong fs type, bad option, bad superblock' because it reads offset 0, which is partition metadata. You must use mmls to find the start sector and pass the correct byte offset via the offset= mount option.

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

    fdisk -l works and shows the same start sector, but it reports sectors without clearly showing the unit size (usually 512 bytes). mmls from The Sleuth Kit is more explicit and is standard in forensic workflows, making the offset calculation less error-prone. Either tool gives the right start sector, but mmls output is easier to parse for the mount command.

    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 3
    Recreate missing ref directories so git works again
    Observation
    I noticed git reported 'not a git repository' even though a .git directory was present, which pointed to the challenge hint that only refs/heads and refs/tags were deleted, leaving those two empty directories as the only missing structural pieces.
    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.

    The mounted disk image is read-only (loop mount defaults), so mkdir -p .git/refs/heads will fail with 'Read-only file system'. Copying the repo to /tmp gives you a writable working directory where you can recreate the missing ref directories. Always copy the recovered artifacts before modifying the git metadata.

    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 repo directory would overwrite key metadata files (HEAD, config) and potentially alter the object database, destroying the forensic state. The damage is minimal - only two empty directories are missing - so a targeted mkdir -p is the correct fix. git init is too destructive and may make subsequent git log or reflog commands show a blank history.

    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 4
    Use git reflog to find old HEAD positions
    Observation
    I noticed that refs/heads was deleted but .git/logs/HEAD is stored in a completely separate directory structure, which suggested the reflog would still contain the full commit history including entries for commits 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 all refs (branches, tags, HEAD). Because refs/heads was deleted, there are no branch refs pointing into the history, so git log --all returns only the current HEAD commit or nothing. The reflog (.git/logs/HEAD) is independent of the ref graph and records every past HEAD position regardless of whether a branch ref still exists.

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

    git fsck --lost-found does create .git/lost-found/other/ with dangling blob files, but without knowing which blob contains the flag you still need to identify the right commit first. Starting with git reflog is faster because it shows commit messages ('Add secret hideout chat log') that directly name the relevant commit, letting you jump straight to git checkout rather than scanning dozens of anonymous blob files.

    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 5
    Checkout the old commit that added the secret file
    Observation
    I noticed the reflog showed an entry titled 'Add secret hideout chat log' followed by 'Remove secret hideout log', which confirmed the file existed in an earlier commit and that checking out that specific hash would restore logs/3.txt 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 titled 'Remove secret hideout log' - it deliberately deletes logs/3.txt. Checking out HEAD gives a working tree with the file absent. You must check out the earlier commit hash labeled 'Add secret hideout chat log' to get the state where the file exists.

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

    HEAD points to the removal commit where logs/3.txt is not part of the tree, so git show HEAD:logs/3.txt returns 'fatal: Path logs/3.txt does not exist in HEAD'. You must reference the earlier commit hash explicitly - either via git checkout or git show <add-commit-hash>:logs/3.txt.

    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 6
    Extract the flag
    Observation
    I noticed that after checking out the old commit, logs/3.txt appeared in the working tree as a plain file, and the git object database also retained the corresponding blob, which meant reading the flag was possible with either a plain cat or git cat-file -p on the blob hash.
    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
  • 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.
  • 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.

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 content-addressable object store is append-only in normal use: blobs, trees, and commits are written as immutable files and only garbage-collected by explicit 'git gc'. Deleting branch refs or ref-namespace directories removes pointers to objects, not the objects themselves, so the entire history survives and is recoverable via the reflog or 'git fsck'. This same immutability that enables recovery is why secrets committed and then 'deleted' in a later commit must be rotated: the original blob persists 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

Want more picoCTF 2026 writeups?

Useful tools for Forensics

What to try next