Skip to main content

Forensics Git 0 picoCTF 2026 Solution

Mount a disk image and comb through a git repository's full history and hidden object store to find the flag.

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

Description

Can you find the flag in this disk image?

Download and decompress the disk image.

Use libguestfs tools to explore and extract the git repository inside.

bash
gunzip disk.img.gz
bash
virt-ls -a disk.img /

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 download is a compressed disk image, so nothing is readable until it is decompressed. Once it is, a strings scan is the fastest triage before touching the filesystem.
    Extract the disk image, then run strings as a fast pre-check for an obvious flag before mounting.
    bash
    gunzip disk.img.gz
    bash
    strings disk.img | grep picoCTF
    What didn't work first

    Tried: Running strings on the compressed disk.img.gz file before decompressing it.

    Compression replaces the original byte patterns with dense data, so strings finds only gzip header noise. The flag bytes do not exist in their original form inside the compressed stream. Decompress first.

    Tried: Grepping for the flag with a lowercase 'picoctf' pattern instead of 'picoCTF'.

    grep is case-sensitive by default, so 'picoctf' misses the mixed-case prefix the flag actually uses. Adding the -i flag would work, but the simplest fix is to match the exact case 'picoCTF' that all picoCTF flags share.

    Learn more

    strings scans a binary file for sequences of printable ASCII characters of a minimum length (default 4). It is the fastest possible triage tool for a disk image: if the flag is stored in plaintext anywhere in the image (in a file, in git object data, or even in slack space) strings | grep picoCTF finds it immediately without mounting or parsing the filesystem.

    Disk images are raw byte-for-byte copies of storage devices. A gzip-compressed image (.img.gz) must be decompressed first because the compression transforms the bytes, making strings useless on the compressed file. After decompression, the image contains the same layout as the original disk, including partition tables, filesystem metadata, and file data.

    In real forensic investigations this triage step is called a keyword search and is one of the first steps in any examination. Tools like Autopsy and Bulk Extractor automate keyword searches across entire disk images and can find strings in compressed or carved files as well.

  2. Step 2Browse and extract the disk image with libguestfs
    Observation
    Mounting a raw image means knowing the partition offset and having root. libguestfs tools browse and extract the filesystem without either.
    Use virt-ls to list the filesystem contents and virt-copy-out to extract the repository directory to a local writable path.
    bash
    virt-ls -a disk.img /
    bash
    virt-ls -a disk.img /home
    bash
    sudo virt-copy-out -a disk.img /home/ctf-player /tmp/
    bash
    ls /tmp/ctf-player

    Expected output

    bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
    ctf-player
    flag_repo
    What didn't work first

    Tried: Trying to mount the disk image with 'sudo mount -o loop disk.img /mnt' without specifying a partition offset.

    A raw image usually opens with a partition table, and the filesystem starts at a sector offset rather than byte 0. Mount without that offset and the kernel reads partition metadata as a superblock, then rejects it or shows garbage. Finding the start sector takes mmls or fdisk, which is why virt-ls is simpler.

    Tried: Running virt-copy-out targeting /home instead of the specific /home/ctf-player subdirectory.

    virt-copy-out copies the named path whole, so targeting /home pulls the entire tree, large files included, and takes far longer. Its output placement also varies by version. Name the specific subdirectory and you get just what you need.

    Learn more

    libguestfs is a library and set of command-line tools for accessing and modifying virtual machine disk images without mounting them. virt-ls lists files and directories inside an image (similar to ls), and virt-copy-out extracts files or directories from an image to the host filesystem. Neither tool requires computing partition offsets or running as root in the same way that mount -o loop,offset=N does.

    This approach is safer for forensic work because it never mounts the image as a kernel filesystem, so the kernel never writes access-time (atime) or journal metadata back to the image. The image file itself remains byte-for-byte identical before and after the operation.

    The alternative approach of using mmls (The Sleuth Kit) to find the partition start sector and then mount -o loop,offset=$((512 * start_sector)) also works but requires root and offset arithmetic. For CTF purposes, virt-ls and virt-copy-out are faster and less error-prone.

  3. Step 3Find and copy the git repository
    Observation
    The challenge name says git, and the image has a home directory called flag_repo. Copy it somewhere you own, or git will refuse on ownership grounds.
    Locate the .git directory in the extracted files and copy it to a writable location. Git may refuse to run due to an ownership mismatch on the copied directory; add a safe.directory exception to fix it.
    bash
    find /tmp/ctf-player -name '.git' -type d
    bash
    cp -r /tmp/ctf-player/<repo_path> /tmp/repo
    bash
    cd /tmp/repo
    bash
    # If git refuses with 'detected dubious ownership':
    bash
    git config --global --add safe.directory /tmp/repo
    bash
    git status
    What didn't work first

    Tried: Running git commands directly inside the virt-copy-out output path without copying to /tmp first.

    The extracted directory keeps the original UID from the image rather than yours, and modern git refuses to run in a directory owned by someone else, calling the ownership dubious. The message points at safe.directory, but copying the repo somewhere you own is simpler.

    Tried: Using find to search for files named 'flag' or '*.txt' instead of looking for the .git directory.

    The flag is in a commit message, not a file in the working tree, and find only sees the current checked-out state. Anything added and later deleted, or living only in a message, is invisible to it. Search .git instead.

    Learn more

    A git repository stores its entire history in the hidden .git directory at the root of the working tree. This directory contains the object store (.git/objects/), refs (.git/refs/), the HEAD pointer, config, and optional extras like stash and notes. Everything needed to reconstruct any version of every file ever committed is inside .git.

    Mounted loop devices are usually read-only or have ownership restrictions that prevent running git commands in-place. Copying the repository to /tmp (or another writable path) before running git commands avoids permission errors and also protects the mounted evidence from accidental modification. After copying, git commands run against the local copy as if it were a normal repository.

    In digital forensics, working on a copy rather than the original is called working on a forensic duplicate. The original evidence (the disk image) remains intact and can be re-examined if the working copy is corrupted. This is a core principle of the ACPO Good Practice Guide and similar forensic standards.

  4. Step 4Comprehensive git history search
    Observation
    No plaintext file in the working tree holds the flag, so it was committed and later removed. Search every branch, stash, tag, reflog entry, and dangling object.
    In this challenge the flag phrase is stored in a git commit message. Run git log --all to read all commit messages across every branch. If git log is not enough, fall back to dangling and lost-found objects.
    bash
    # 1. Reachable history first - cheap, structured, often holds the flag
    bash
    git log --all -p | grep -A2 picoCTF
    bash
    git branch -a
    bash
    git tag -l
    bash
    for c in $(git log --all --format=%H); do git notes show "$c" 2>/dev/null; done
    bash
    git reflog
    bash
    git stash list && git stash show -p
    bash
    bash
    # 2. Dangling and lost-found objects
    bash
    git fsck --unreachable 2>&1 | grep blob
    bash
    git fsck --lost-found
    bash
    grep -rB1 picoCTF .git/lost-found/other/
    What didn't work first

    Tried: Running 'git log -p | grep picoCTF' without the --all flag.

    Without --all, git log walks only what the current branch reaches. A commit on another branch, in a detached HEAD, or on a remote-tracking ref is invisible. --all walks every ref, remote branches and tags and stashes included, which matters when you do not know where the author hid it.

    Tried: Skipping git fsck and assuming the flag must be in a reachable commit or file.

    Authors often park the flag in a dangling blob or a dropped stash precisely to catch solvers who check only the visible history. Nothing points to those objects, so git log never shows them. git fsck lists them by hash and git cat-file reads them.

    Learn more

    Git's content-addressed object store means that deleted data is not truly gone until it is garbage-collected. Every commit, tree, blob, and tag is stored as an object identified by the SHA-1 hash of its content. Deleting a file in a commit creates a new commit that does not reference the old blob, but the blob itself remains in .git/objects/ until git gc prunes it. The same applies to deleted branches, dropped stash entries, and amended commits: the old objects persist as dangling (unreachable) objects.

    The eight locations to check are:

    • Commit history (git log --all -p): shows diffs across all branches
    • Branches (git branch -a): lists local and remote-tracking branches
    • Stash (git stash list): saved work-in-progress snapshots
    • Tags (git tag -l): annotated tags can hold arbitrary messages
    • Notes (git notes list): metadata attached to commits outside the commit object
    • Reflog (git reflog): every movement of HEAD and branch tips, including force-pushes and resets
    • Dangling blobs (git fsck --unreachable): objects with no path to any ref
    • Lost-and-found (git fsck --lost-found): writes dangling objects to .git/lost-found/

    This breadth of hiding locations makes git repositories a rich source of sensitive data in real investigations. Developers often accidentally commit API keys, passwords, or private keys, then delete them in a follow-up commit, but the data remains in history. Tools like truffleHog, git-secrets, and gitleaks scan repositories for secrets in all of these locations.

  5. Step 5Read flag content
    Observation
    The log or fsck output names a specific commit or blob hash. git show or git cat-file prints its full content.
    Once a suspicious commit or blob is found, show its full content.
    bash
    git show <commit>
    bash
    git cat-file -p <blob-hash>
    bash
    cat <flag-file>
    Learn more

    git show <hash> displays the content and metadata of any git object: for commits it shows the diff; for blobs it shows the raw file content; for trees it lists the directory entries; for tags it shows the tag message and the tagged object. git cat-file -p <hash> does the same but works with raw hashes and is slightly more low-level: useful when you have a hash from git fsck output and want to inspect it without knowing the object type.

    Understanding git's object model at this level is valuable for both forensics and everyday development. Knowing that every version of every file is a blob object with a deterministic hash, and that commit objects reference tree objects (directory snapshots) which reference blob objects, explains why git is so reliable for history, and why deleted data persists until git gc --prune=now --aggressive explicitly removes unreachable objects.

    For more on the underlying disk-image and shell techniques, 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{g17_1n_7h3_d15k_...}

The flag is hidden in a git commit message on the disk image. Run git log --all to find it.

Key takeaway

Git addresses content by hash: every file version, commit, and directory snapshot is named by the hash of its bytes. Deleting a file or resetting a branch drops the reference and leaves the object in .git/objects until garbage collection runs, so a committed and then deleted secret stays recoverable through fsck, the reflog, or direct object inspection. Pre-commit scanning and immediate rotation are the only real answers to an accidental commit.

How to prevent this

Git history is permanent. Treat any commit (even one you delete) as published.

  • Run a pre-commit hook with gitleaks, trufflehog, or detect-secrets to block secrets before they enter history. Add the same scan to CI as a backstop.
  • If a secret slips in, rotate it immediately. git filter-repo + git push --force rewrites history but does not erase clones, mirrors, GitHub forks, or backups. Assume compromise; rotation is the only real fix.
  • Block /.git/ at the edge (nginx location ~ /\.git { deny all; } or equivalent). Exposed .git directories on production servers are still a top-10 finding in pentests in 2026.

Related reading

Useful tools for Forensics

Where to go next