┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Forensics Git 1 — picoCTF 2026
~ Imattas aka Zemi
 Category: Forensics
 Difficulty: Medium
 Points: 300
 Author: Imattas aka Zemi

────────────────────────────────────────────────────────────────────────────────

--[ Challenge Description ]--

 Can you find the flag in this disk image? Download the disk image. (Second in
the Forensics Git series - flag hidden in git history)

────────────────────────────────────────────────────────────────────────────────

--[ Recon / Initial Analysis ]--

This is the second challenge in the Forensics Git series. We are given a disk
image file that contains a git repository somewhere on it. Unlike the first
challenge in the series (which likely involved a straightforward flag in the
working tree), this challenge hides the flag within the git history -- meaning
we need to examine past commits, branches, diffs, or other git artifacts to find
it.

:: Key Concepts

1. Disk Image Analysis: The challenge provides a raw disk image (.img or
.img.gz). Before we can access the git repo, we need to mount or extract the
filesystem from the image. Standard tools include:
  - mmls / fdisk -- to identify partition layout and offsets
  - mount -o loop,offset=... -- to mount the filesystem
  - The Sleuth Kit (fls, icat, tsk_recover) -- for filesystem-level analysis
  - strings / grep -- for quick raw searches

2. Git History Forensics: Once we have access to the repository, the flag is not
in the current working tree -- it has been removed or never appeared in the
latest commit. We need to look at:
  - Commit history (git log --all -p) -- the flag may have been added then
deleted in a subsequent commit
  - Branches (git branch -a) -- the flag could be on a different branch
  - Diffs between commits (git diff, git show) -- reveals what changed
  - Stash (git stash list, git stash show -p) -- a common hiding spot
  - Tags and notes (git tag -l, git notes list) -- metadata that can store data

If the image is compressed, decompress it first:
-- bash --
# If the file is gzip-compressed
gunzip disk.img.gz
# Or if it's already a raw image, skip this step
file disk.img
────────────────────────────────────────────────────────────────────────────────

--[ Vulnerability / Observation ]--

Unlike the introductory challenge, the flag is not present in the current
working tree — a quick strings search on the raw image usually fails because the
flag only makes sense in git context. In picoCTF git challenges, the flag is
typically:

- Committed in an earlier commit and then deleted
- Placed on a separate feature/secret branch
- Stored in a git stash entry
- Embedded in a commit message or tag annotation

The key insight is that git preserves everything: a "deleted" flag still lives
in old commit objects, dangling objects, the reflog, and the stash. The solve
therefore enumerates every ref and object, not just HEAD.

────────────────────────────────────────────────────────────────────────────────

--[ Exploitation / Solution ]--

:: Step 1: Decompress the disk image (if compressed)
-- bash --
# If the file is gzip-compressed
gunzip disk.img.gz
# Or if it's already a raw image, skip this step
file disk.img
:: Step 2: Analyze the disk image partitions
-- bash --
# Identify partitions
mmls disk.img
# Or
fdisk -l disk.img
Note the start sector of the Linux partition. Calculate the byte offset as
start_sector * 512.

:: Step 3: Mount the filesystem
-- bash --
sudo mkdir -p /mnt/evidence
sudo mount -o loop,ro,offset=<byte_offset> disk.img /mnt/evidence
If the image is a single filesystem (no partition table), use offset 0 or mount
without offset:
-- bash --
sudo mount -o loop,ro disk.img /mnt/evidence
:: Step 4: Locate the git repository
-- bash --
find /mnt/evidence -name ".git" -type d 2>/dev/null
This should reveal the path to a .git directory within the mounted image.

:: Step 5: Explore git history for the flag
-- bash --
cd /mnt/evidence/<path_to_repo>

# View all commits across all branches
git log --all --oneline --graph

# Search all diffs for the flag pattern
git log --all -p | grep "picoCTF{"

# Check all branches
git branch -a

# If there are multiple branches, diff them
git diff main..feature-branch

# Check stash
git stash list
git stash show -p

# Check tags
git tag -l
git show <tag_name>

# Check individual commits
git show <commit_hash>
:: Step 6: Alternative -- raw search
If mounting fails or you want a quick answer:
-- bash --
strings disk.img | grep "picoCTF{"
────────────────────────────────────────────────────────────────────────────────

--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Forensics Git 1 - picoCTF 2026
Category: Forensics | Points: 300

Extracts a flag hidden in the git history of a repository stored on a disk image.
This script automates: disk analysis -> mount/extract -> git history search -> flag extraction.

Usage:
    python3 solve.py <disk_image_path>
    python3 solve.py disk.img
"""

import subprocess
import sys
import os
import re
import tempfile
import shutil

# ──────────────────────────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────────────────────────
FLAG_PATTERN = re.compile(r"picoCTF\{[^}]+\}")


def run(cmd, capture=True, check=False):
    """Run a shell command and return stdout."""
    result = subprocess.run(
        cmd, shell=True, capture_output=capture, text=True, check=check
    )
    return result.stdout.strip() if capture else ""


def quick_strings_search(image_path):
    """Phase 0: Quickly search the raw image using strings + grep."""
    print("[*] Phase 0: Quick raw strings search on disk image...")
    output = run(f"strings {image_path} | grep -oP 'picoCTF\\{{[^}}]+\\}}'")
    if output:
        flags = list(set(FLAG_PATTERN.findall(output)))
        if flags:
            print(f"[+] Found flag(s) in raw image: {flags}")
            return flags
    print("    No flag found via raw strings (expected -- flag may need git context)")
    return []


def get_partition_offsets(image_path):
    """Identify partition offsets in the disk image."""
    print("[*] Analyzing disk image partitions...")

    # Try mmls first (The Sleuth Kit)
    mmls_out = run(f"mmls {image_path} 2>/dev/null")
    offsets = []

    if mmls_out:
        print(f"    mmls output:\n{mmls_out}")
        for line in mmls_out.split("\n"):
            # Match lines with Linux/ext partitions
            match = re.search(
                r"(\d{6,})\s+\d{6,}\s+\d{6,}\s+.*(?:Linux|ext|0x83)", line, re.IGNORECASE
            )
            if match:
                sector = int(match.group(1))
                offsets.append(sector * 512)

    if not offsets:
        # Try fdisk
        fdisk_out = run(f"fdisk -l {image_path} 2>/dev/null")
        if fdisk_out:
            print(f"    fdisk output:\n{fdisk_out}")
            for line in fdisk_out.split("\n"):
                parts = line.split()
                # fdisk lines like: disk.img1  2048  206847  204800  100M  83  Linux
                if len(parts) >= 2 and parts[0].startswith(os.path.basename(image_path)):
                    try:
                        start = parts[1].replace("*", "")
                        offsets.append(int(start) * 512)
                    except ValueError:
                        pass

    if not offsets:
        print("    No partitions found -- treating image as a raw filesystem")
        offsets = [0]

    print(f"    Byte offsets to try: {offsets}")
    return offsets


def mount_image(image_path, offset, mountpoint):
    """Mount the disk image at the given offset."""
    os.makedirs(mountpoint, exist_ok=True)
    cmd = f"sudo mount -o loop,ro,offset={offset} {image_path} {mountpoint} 2>/dev/null"
    ret = os.system(cmd)
    return ret == 0


def unmount(mountpoint):
    """Unmount a mountpoint."""
    os.system(f"sudo umount {mountpoint} 2>/dev/null")


def find_git_repos(search_root):
    """Find all .git directories under search_root."""
    output = run(f"find {search_root} -name '.git' -type d 2>/dev/null")
    if output:
        return [d.strip() for d in output.split("\n") if d.strip()]
    return []


def search_git_history(repo_path):
    """
    Thoroughly search a git repository's history for the flag.
    This is the core of the challenge -- the flag is hidden in git history.
    """
    flags_found = []

    # Make sure we're looking at the repo root (parent of .git)
    if repo_path.endswith(".git"):
        repo_root = os.path.dirname(repo_path)
    else:
        repo_root = repo_path

    git_dir = os.path.join(repo_root, ".git")
    if not os.path.isdir(git_dir):
        print(f"    [-] No .git directory at {git_dir}")
        return flags_found

    print(f"[*] Searching git history at: {repo_root}")
    os.chdir(repo_root)

    # ── 1. git log --all -p: search all commit diffs ──
    print("    Checking commit diffs (git log --all -p)...")
    log_output = run("git log --all -p 2>/dev/null")
    matches = FLAG_PATTERN.findall(log_output)
    if matches:
        print(f"    [+] Found in commit diffs: {matches}")
        flags_found.extend(matches)

    # ── 2. Check all branches ──
    print("    Checking branches...")
    branches_output = run("git branch -a 2>/dev/null")
    if branches_output:
        branches = [b.strip().lstrip("* ").strip() for b in branches_output.split("\n")]
        print(f"    Branches: {branches}")
        for branch in branches:
            if "->" in branch:
                continue
            # Check the tip of each branch
            show_output = run(f"git show {branch} 2>/dev/null")
            m = FLAG_PATTERN.findall(show_output)
            if m:
                print(f"    [+] Found on branch {branch}: {m}")
                flags_found.extend(m)

            # Diff each branch against the default
            diff_output = run(f"git log {branch} -p 2>/dev/null")
            m = FLAG_PATTERN.findall(diff_output)
            if m:
                flags_found.extend(m)

    # ── 3. Check commit messages themselves ──
    print("    Checking commit messages...")
    log_msgs = run("git log --all --format='%H %s%n%b' 2>/dev/null")
    m = FLAG_PATTERN.findall(log_msgs)
    if m:
        print(f"    [+] Found in commit messages: {m}")
        flags_found.extend(m)

    # ── 4. Check stash ──
    print("    Checking stash...")
    stash_list = run("git stash list 2>/dev/null")
    if stash_list:
        print(f"    Stash entries: {stash_list}")
        stash_diff = run("git stash show -p 2>/dev/null")
        m = FLAG_PATTERN.findall(stash_diff)
        if m:
            print(f"    [+] Found in stash: {m}")
            flags_found.extend(m)
        # Check all stash entries
        for i in range(10):
            sd = run(f"git stash show -p stash@{{{i}}} 2>/dev/null")
            if not sd:
                break
            m = FLAG_PATTERN.findall(sd)
            flags_found.extend(m)

    # ── 5. Check tags ──
    print("    Checking tags...")
    tags = run("git tag -l 2>/dev/null")
    if tags:
        for tag in tags.split("\n"):
            tag = tag.strip()
            if tag:
                tag_content = run(f"git show {tag} 2>/dev/null")
                m = FLAG_PATTERN.findall(tag_content)
                if m:
                    print(f"    [+] Found in tag {tag}: {m}")
                    flags_found.extend(m)

    # ── 6. Check git notes ──
    print("    Checking notes...")
    notes = run("git notes list 2>/dev/null")
    if notes:
        for line in notes.split("\n"):
            parts = line.strip().split()
            if len(parts) >= 2:
                note_content = run(f"git notes show {parts[1]} 2>/dev/null")
                m = FLAG_PATTERN.findall(note_content)
                if m:
                    print(f"    [+] Found in notes: {m}")
                    flags_found.extend(m)

    # ── 7. Check dangling/unreachable objects ──
    print("    Checking dangling objects (git fsck)...")
    fsck_output = run("git fsck --unreachable --no-reflogs 2>&1")
    if fsck_output:
        for line in fsck_output.split("\n"):
            hash_match = re.search(r"([0-9a-f]{40})", line)
            if hash_match:
                obj_hash = hash_match.group(1)
                obj_content = run(f"git cat-file -p {obj_hash} 2>/dev/null")
                m = FLAG_PATTERN.findall(obj_content)
                if m:
                    print(f"    [+] Found in dangling object {obj_hash}: {m}")
                    flags_found.extend(m)

    # ── 8. Check reflog ──
    print("    Checking reflog...")
    reflog = run("git reflog --all 2>/dev/null")
    if reflog:
        for line in reflog.split("\n"):
            hash_match = re.search(r"^([0-9a-f]+)", line)
            if hash_match:
                show = run(f"git show {hash_match.group(1)} 2>/dev/null")
                m = FLAG_PATTERN.findall(show)
                if m:
                    print(f"    [+] Found in reflog entry: {m}")
                    flags_found.extend(m)

    return list(set(flags_found))


def recover_with_tsk(image_path, offset, workdir):
    """Use The Sleuth Kit to recover files when mount fails."""
    recovered_dir = os.path.join(workdir, "recovered")
    os.makedirs(recovered_dir, exist_ok=True)
    sector_offset = offset // 512

    print(f"[*] TSK recovery at sector offset {sector_offset}...")

    # Recover all files
    run(f"tsk_recover -o {sector_offset} {image_path} {recovered_dir} 2>/dev/null")

    # List files via fls
    fls_output = run(f"fls -r -o {sector_offset} {image_path} 2>/dev/null")
    if fls_output:
        git_entries = [l for l in fls_output.split("\n") if ".git" in l or "flag" in l.lower()]
        if git_entries:
            print(f"    Found relevant entries:\n" + "\n".join(git_entries[:15]))

    return recovered_dir


def main():
    if len(sys.argv) < 2:
        print("Usage: python3 solve.py <disk_image_path>")
        print("Example: python3 solve.py disk.img")
        sys.exit(1)

    image_path = os.path.abspath(sys.argv[1])
    if not os.path.exists(image_path):
        print(f"[-] File not found: {image_path}")
        sys.exit(1)

    # Handle gzip-compressed images
    if image_path.endswith(".gz"):
        print("[*] Decompressing gzipped image...")
        run(f"gunzip -k {image_path}")
        image_path = image_path[:-3]

    workdir = tempfile.mkdtemp(prefix="forensics_git1_")
    mountpoint = os.path.join(workdir, "mnt")
    print(f"[*] Working directory: {workdir}")
    all_flags = []

    try:
        # ── Phase 0: Quick raw strings search ──
        raw_flags = quick_strings_search(image_path)
        all_flags.extend(raw_flags)

        # ── Phase 1: Get partition offsets ──
        offsets = get_partition_offsets(image_path)

        # ── Phase 2: Mount and search git history ──
        for offset in offsets:
            print(f"\n[*] Trying offset {offset} ({offset // 512} sectors)...")

            if mount_image(image_path, offset, mountpoint):
                print(f"[+] Mounted successfully at {mountpoint}")

                # Find .git directories
                git_dirs = find_git_repos(mountpoint)
                if git_dirs:
                    for gd in git_dirs:
                        flags = search_git_history(gd)
                        all_flags.extend(flags)
                else:
                    print("    No .git directories found on this partition")
                    # Search files directly anyway
                    output = run(
                        f"find {mountpoint} -type f -exec grep -l 'picoCTF{{' {{}} + 2>/dev/null"
                    )
                    if output:
                        for fpath in output.split("\n"):
                            content = run(f"cat '{fpath}' 2>/dev/null")
                            m = FLAG_PATTERN.findall(content)
                            all_flags.extend(m)

                unmount(mountpoint)
            else:
                print(f"    Mount failed at offset {offset}, trying TSK recovery...")
                recovered = recover_with_tsk(image_path, offset, workdir)
                git_dirs = find_git_repos(recovered)
                if git_dirs:
                    for gd in git_dirs:
                        flags = search_git_history(gd)
                        all_flags.extend(flags)
                # Also search recovered files directly
                output = run(
                    f"grep -r -oP 'picoCTF\\{{[^}}]+\\}}' {recovered} 2>/dev/null"
                )
                if output:
                    m = FLAG_PATTERN.findall(output)
                    all_flags.extend(m)

        # ── Results ──
        print("\n" + "=" * 60)
        print("RESULTS")
        print("=" * 60)
        unique_flags = list(set(all_flags))
        if unique_flags:
            for flag in unique_flags:
                print(f"[+] FLAG: {flag}")
        else:
            print("[-] No flag found automatically.")
            print()
            print("Manual investigation steps:")
            print("  1. Mount:    sudo mount -o loop,ro disk.img /mnt")
            print("  2. Find git: find /mnt -name '.git' -type d")
            print("  3. cd into the repo directory")
            print("  4. git log --all --oneline --graph")
            print("  5. git log --all -p | grep picoCTF")
            print("  6. git branch -a   (check all branches)")
            print("  7. git stash list  (check stash)")
            print("  8. git tag -l      (check tags)")
            print("  9. strings disk.img | grep picoCTF")

    finally:
        unmount(mountpoint)
        print(f"\n[*] Work directory: {workdir}")
        print(f"[*] Cleanup: rm -rf {workdir}")


if __name__ == "__main__":
    main()
────────────────────────────────────────────────────────────────────────────────

--[ Key Takeaways ]--

- A flag "deleted" from the working tree still survives in git history — sweep
commits, branches, stashes, tags, notes, dangling objects (git fsck), and the
reflog.
- git log --all -p | grep "picoCTF{" is the single highest-value command: it
walks every diff across every ref.
- When loop-mounting fails, The Sleuth Kit's tsk_recover / fls can recover the
filesystem (and the .git directory) from the raw image.
- Handle compressed images up front (gunzip), and always try multiple partition
offsets (start_sector * 512).