┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
DISKO 4 — picoCTF 2026
~ Imattas aka Zemi
 Category: Forensics
 Difficulty: Medium
 Points: 200
 Author: Imattas aka Zemi

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

--[ Challenge Description ]--

 Can you find the flag in this disk image? This time I deleted the file! Let
see you get it now!

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

--[ Recon / Initial Analysis ]--

This is the fourth challenge in the picoCTF DISKO forensics series. The
progression of the series is:

- DISKO 1 (Easy): Flag is a plain string in the disk image -- solvable with
strings | grep picoCTF
- DISKO 2 (Medium): Multiple partitions -- must identify and extract the correct
(Linux) partition, then search for the flag
- DISKO 3 (Medium): Flag is hidden in a compressed file (flag.gz) inside a
mounted FAT32 filesystem
- DISKO 4 (Medium): The flag file has been deleted from the filesystem --
requires file recovery techniques

:: Filesystem Analysis

Based on the DISKO series pattern, the disk image is likely:
- A gzip-compressed raw disk image (.dd.gz)
- Contains a DOS/MBR boot sector
- Uses either FAT32 or ext2/ext4 filesystem
- The flag was stored in a file that has since been deleted

Initial inspection:
-- bash --
gunzip disko-4.dd.gz
file disko-4.dd
fdisk -l disko-4.dd
────────────────────────────────────────────────────────────────────────────────

--[ Vulnerability / Observation ]--

When a file is "deleted" from a filesystem, the data is not immediately erased.
Instead, the filesystem metadata (directory entries, inode references) is
updated to mark the space as available. The actual file content remains on disk
until overwritten by new data.

This means the deleted flag file's content is still recoverable, either through
filesystem-aware undelete tools (which read the residual metadata) or by
carving/searching the raw bytes directly.

:: Recovery Tools

Several tools can recover deleted files from disk images:

1. SleuthKit (`fls` + `icat`): fls lists all files including deleted ones
(marked with *), and icat extracts file content by inode number
2. `tsk_recover`: Automatically recovers all unallocated/deleted files from a
disk image
3. `extundelete`: Specifically for ext3/ext4 filesystems
4. `photorec`/`scalpel`: Carve files based on file signatures regardless of
filesystem state
5. Autopsy/FTK Imager: GUI-based forensic tools

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

--[ Exploitation / Solution ]--

:: Step 1: Decompress the disk image
-- bash --
gunzip disko-4.dd.gz
:: Step 2: Identify the filesystem
-- bash --
file disko-4.dd
fdisk -l disko-4.dd
:: Step 3: List all files including deleted ones
-- bash --
# Using SleuthKit fls (shows deleted files with * prefix)
fls -r -o 2048 disko-4.dd
:: Step 4: Recover deleted files
-- bash --
# Method 1: Using tsk_recover to recover all deleted files
tsk_recover -o 2048 disko-4.dd output_dir/

# Method 2: If you identify the specific inode with fls
icat -o 2048 disko-4.dd <inode_number> > recovered_flag.txt

# Method 3: Using extundelete (for ext filesystems)
extundelete disko-4.dd --restore-all

# Method 4: Simple string search (may still work if data not overwritten)
strings disko-4.dd | grep -i picoCTF
:: Step 5: Read the recovered flag
-- bash --
cat output_dir/flag.txt
# or
cat recovered_flag.txt
────────────────────────────────────────────────────────────────────────────────

--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
DISKO 4 - picoCTF 2026 (Forensics, 200 pts)

Recover a deleted flag file from a disk image.
The file was deleted but the data remains on disk.

Usage:
    python3 solve.py [disk_image.dd.gz]

Requires: The Sleuth Kit (apt install sleuthkit), or just Python for basic recovery.
"""

import gzip
import os
import re
import struct
import subprocess
import sys


def find_disk_image():
    """Find the disk image file in the current directory."""
    candidates = []
    for f in os.listdir("."):
        if f.endswith((".dd", ".dd.gz", ".img", ".img.gz", ".raw")):
            candidates.append(f)
    # Also check common names
    for name in ["disko-4.dd.gz", "disko-4.dd", "disko4.dd.gz", "disko4.dd"]:
        if os.path.exists(name):
            return name
    if candidates:
        return candidates[0]
    return None


def decompress_if_needed(filepath):
    """Decompress gzip file if needed, return path to raw disk image."""
    if filepath.endswith(".gz"):
        raw_path = filepath[:-3]  # Remove .gz extension
        if not os.path.exists(raw_path):
            print(f"[*] Decompressing {filepath}...")
            with gzip.open(filepath, "rb") as f_in:
                with open(raw_path, "wb") as f_out:
                    while True:
                        chunk = f_in.read(1024 * 1024)
                        if not chunk:
                            break
                        f_out.write(chunk)
            print(f"[+] Decompressed to {raw_path}")
        return raw_path
    return filepath


def search_strings(disk_path):
    """Search for the flag using strings-style extraction."""
    print(f"[*] Searching for flag strings in {disk_path}...")
    flag_pattern = re.compile(rb"picoCTF\{[^\}]+\}")

    flags_found = set()
    chunk_size = 1024 * 1024  # 1MB chunks

    with open(disk_path, "rb") as f:
        offset = 0
        overlap = b""
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            # Search in overlap + current chunk to catch flags spanning boundaries
            search_data = overlap + chunk
            matches = flag_pattern.findall(search_data)
            for m in matches:
                try:
                    flag = m.decode("utf-8", errors="ignore")
                    flags_found.add(flag)
                    print(f"[+] Found flag: {flag}")
                except:
                    pass
            # Keep last 100 bytes as overlap for boundary-spanning flags
            overlap = chunk[-100:]
            offset += len(chunk)

    return flags_found


def try_sleuthkit_recovery(disk_path):
    """Attempt recovery using The Sleuth Kit tools."""
    flags_found = set()

    # Check if sleuthkit is available
    try:
        subprocess.run(["fls", "-V"], capture_output=True, check=True)
    except (FileNotFoundError, subprocess.CalledProcessError):
        print("[!] SleuthKit not found. Install with: sudo apt install sleuthkit")
        return flags_found

    # Try different partition offsets (common values)
    offsets = [0, 2048, 4096, 1, 63, 128]

    for offset in offsets:
        try:
            # List files including deleted ones
            result = subprocess.run(
                ["fls", "-r", "-o", str(offset), disk_path],
                capture_output=True, text=True, timeout=30
            )

            if result.returncode != 0:
                continue

            print(f"[*] fls succeeded with offset {offset}")
            lines = result.stdout.strip().split("\n")

            for line in lines:
                # Look for deleted files (marked with *) or flag-related files
                if "*" in line or "flag" in line.lower():
                    print(f"[*] Interesting entry: {line}")

                    # Extract inode number
                    inode_match = re.search(r"(\d+)(?:-\d+)?(?:-\d+)?:", line)
                    if inode_match:
                        inode = inode_match.group(1)
                        # Try to recover the file using icat
                        try:
                            icat_result = subprocess.run(
                                ["icat", "-o", str(offset), disk_path, inode],
                                capture_output=True, timeout=10
                            )
                            if icat_result.returncode == 0:
                                content = icat_result.stdout
                                flag_match = re.search(rb"picoCTF\{[^\}]+\}", content)
                                if flag_match:
                                    flag = flag_match.group(0).decode()
                                    flags_found.add(flag)
                                    print(f"[+] Recovered flag from inode {inode}: {flag}")
                        except subprocess.TimeoutExpired:
                            pass

        except subprocess.TimeoutExpired:
            continue
        except Exception as e:
            continue

    return flags_found


def try_tsk_recover(disk_path):
    """Attempt bulk recovery using tsk_recover."""
    flags_found = set()

    try:
        subprocess.run(["tsk_recover", "-h"], capture_output=True)
    except FileNotFoundError:
        print("[!] tsk_recover not found.")
        return flags_found

    output_dir = "recovered_files"
    os.makedirs(output_dir, exist_ok=True)

    offsets = [0, 2048]
    for offset in offsets:
        try:
            subprocess.run(
                ["tsk_recover", "-o", str(offset), disk_path, output_dir],
                capture_output=True, timeout=60
            )
            # Search recovered files for flags
            for root, dirs, files in os.walk(output_dir):
                for fname in files:
                    fpath = os.path.join(root, fname)
                    try:
                        with open(fpath, "rb") as f:
                            content = f.read()
                            flag_match = re.search(rb"picoCTF\{[^\}]+\}", content)
                            if flag_match:
                                flag = flag_match.group(0).decode()
                                flags_found.add(flag)
                                print(f"[+] Found flag in {fpath}: {flag}")
                    except:
                        pass
        except (subprocess.TimeoutExpired, Exception):
            continue

    return flags_found


def try_extundelete(disk_path):
    """Attempt recovery using extundelete (for ext filesystems)."""
    flags_found = set()

    try:
        subprocess.run(["extundelete", "--help"], capture_output=True)
    except FileNotFoundError:
        print("[!] extundelete not found. Install with: sudo apt install extundelete")
        return flags_found

    try:
        result = subprocess.run(
            ["extundelete", disk_path, "--restore-all"],
            capture_output=True, text=True, timeout=60
        )
        # Check RECOVERED_FILES directory
        recovered_dir = "RECOVERED_FILES"
        if os.path.exists(recovered_dir):
            for root, dirs, files in os.walk(recovered_dir):
                for fname in files:
                    fpath = os.path.join(root, fname)
                    try:
                        with open(fpath, "rb") as f:
                            content = f.read()
                            flag_match = re.search(rb"picoCTF\{[^\}]+\}", content)
                            if flag_match:
                                flag = flag_match.group(0).decode()
                                flags_found.add(flag)
                                print(f"[+] Found flag in {fpath}: {flag}")
                    except:
                        pass
    except (subprocess.TimeoutExpired, Exception) as e:
        print(f"[!] extundelete error: {e}")

    return flags_found


def main():
    # Determine disk image path
    if len(sys.argv) > 1:
        disk_image = sys.argv[1]
    else:
        disk_image = find_disk_image()
        if not disk_image:
            print("[!] No disk image found. Usage: python3 solve.py <disk_image>")
            print("[!] Expected file: disko-4.dd.gz or disko-4.dd")
            sys.exit(1)

    print(f"[*] Using disk image: {disk_image}")

    # Decompress if needed
    raw_image = decompress_if_needed(disk_image)

    # Print file info
    try:
        result = subprocess.run(["file", raw_image], capture_output=True, text=True)
        print(f"[*] File type: {result.stdout.strip()}")
    except:
        pass

    all_flags = set()

    # Method 1: Direct string search (fastest, often works for deleted files)
    print("\n[=== Method 1: String Search ===]")
    flags = search_strings(raw_image)
    all_flags.update(flags)

    # Method 2: SleuthKit recovery
    print("\n[=== Method 2: SleuthKit (fls + icat) ===]")
    flags = try_sleuthkit_recovery(raw_image)
    all_flags.update(flags)

    # Method 3: tsk_recover bulk recovery
    print("\n[=== Method 3: tsk_recover ===]")
    flags = try_tsk_recover(raw_image)
    all_flags.update(flags)

    # Method 4: extundelete
    print("\n[=== Method 4: extundelete ===]")
    flags = try_extundelete(raw_image)
    all_flags.update(flags)

    # Summary
    print("\n" + "=" * 60)
    if all_flags:
        print(f"[+] Found {len(all_flags)} flag(s):")
        for flag in sorted(all_flags):
            print(f"    {flag}")
    else:
        print("[!] No flags found automatically.")
        print("[*] Manual steps to try:")
        print(f"    1. strings {raw_image} | grep picoCTF")
        print(f"    2. fls -r -o 2048 {raw_image}")
        print(f"    3. tsk_recover -o 2048 {raw_image} output/")
        print(f"    4. Mount and inspect: sudo mount -o loop,ro {raw_image} /mnt/disk")


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

--[ Key Takeaways ]--

- "Deleting" a file only frees its metadata — the underlying data persists on
disk until overwritten, which is what makes forensic recovery possible.
- The SleuthKit workflow is the canonical approach: fls -r lists files (deleted
ones flagged with *) and icat extracts content by inode; tsk_recover automates
bulk recovery.
- Filesystem-specific tools (extundelete for ext3/ext4) and signature carvers
(photorec, scalpel) provide alternatives when metadata is gone.
- A plain strings | grep picoCTF over the raw image often still works if the
flag bytes were never overwritten — always try the cheap method first.
- Disk images frequently come gzip-compressed and require the correct partition
offset (commonly 2048) when using SleuthKit tools.