Forensics Git 0 picoCTF 2026 Solution

Published: March 20, 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 1
    Decompress and quick strings check
    Observation
    I noticed the challenge provides a compressed disk image (.img.gz), which meant the raw flag bytes would only be recoverable after decompression, suggesting a quick strings scan of the decompressed image as the fastest possible triage before doing any filesystem work.
    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.

    gzip compression replaces the original byte patterns with entropy-dense compressed data, so strings produces only gzip header noise and no readable ASCII. The flag bytes are not present in the compressed stream in their original form. You must decompress with gunzip first so strings can scan the raw disk bytes.

    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 2
    Browse and extract the disk image with libguestfs
    Observation
    I noticed that mounting a raw disk image requires knowing the partition offset and root access, which suggested using libguestfs tools (virt-ls and virt-copy-out) to safely browse and extract the filesystem contents without offset arithmetic or kernel-level mounting.
    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 disk image typically starts with a partition table (MBR or GPT), and the actual filesystem begins at a sector offset inside the image, not at byte 0. Mounting without the correct offset attaches the wrong bytes and the kernel either rejects the mount or shows an empty or garbled filesystem. You need mmls or fdisk -l to find the start sector and compute the byte offset, making virt-ls the far simpler path.

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

    virt-copy-out copies the named path as a single unit; targeting /home extracts the entire home directory tree, which may include large files and takes much longer. More importantly the output is placed differently than expected depending on the tool version. Targeting the specific subdirectory /home/ctf-player keeps the extraction focused and places just the needed files under /tmp/.

    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 3
    Find and copy the git repository
    Observation
    I noticed the challenge is named 'forensics-git-0' and the disk image contained a home directory called flag_repo, which indicated a git repository was the target and that copying it to a locally owned path would be necessary to run git commands without ownership errors.
    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 may be owned by the original UID from the disk image (e.g. UID 1000 for ctf-player) rather than your current user, and git 2.35.2+ refuses to run in a directory owned by a different user with 'detected dubious ownership'. The error message points to safe.directory but the simpler fix is to cp -r the repo to /tmp where you own the copy, then run git there.

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

    The challenge stores the flag in a git commit message, not in a plaintext file in the working tree. A find-based search locates only files that exist in the current checked-out state. If the flag was added and then deleted in a later commit, or exists only as a commit message, find will return nothing useful. The .git directory is the correct target.

    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 4
    Comprehensive git history search
    Observation
    I noticed the flag was not present in any plaintext file in the working tree, which suggested it had been committed and possibly removed from later history, pointing to a thorough git log --all search across every branch, stash, tag, reflog, 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 only walks commits reachable from the current HEAD branch. If the flag commit is on a different branch, in a detached-HEAD state, or on a remote-tracking ref, it is completely invisible to the command. Adding --all tells git to walk every ref including remote-tracking branches, tags, and stash refs, which is essential when you do not know where the challenge author hid the data.

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

    Challenge authors often store the flag in a dangling blob or a dropped stash entry specifically to trip up solvers who only check the visible history. Dangling objects have no ref pointing to them so they never appear in git log output at all. git fsck --unreachable lists them by hash and git cat-file -p reads their content, making the two-command combo essential for a complete search.

    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 5
    Read flag content
    Observation
    I noticed the git log or fsck output identified a specific commit hash or blob hash containing 'picoCTF', which indicated using git show or git cat-file -p to display the full object content and extract the flag.
    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
  • 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{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 uses a content-addressed object store where every file version, commit, and directory snapshot is identified by the SHA-1 hash of its content. Deleting a file or resetting a branch removes the reference but leaves the underlying object in .git/objects until garbage collection runs, so secrets that were committed and then 'deleted' remain recoverable via git fsck, git reflog, or direct object inspection. This is why pre-commit secret scanning and immediate credential rotation are the only effective responses 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

Want more picoCTF 2026 writeups?

Useful tools for Forensics

What to try next