┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Timeline 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? Wrap what you find in the picoCTF
flag format. Download the disk image. (Harder version of Timeline 0 -- may
require deeper filesystem timeline analysis)

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

--[ Recon / Initial Analysis ]--

This is the second challenge in the "Timeline" forensics series. While Timeline
0 likely involved straightforward file extraction from a disk image, Timeline 1
requires filesystem timeline analysis -- constructing and examining a
chronological timeline of file system events (creation, modification, access,
and deletion) to locate the flag.

:: Key Concepts

1. MAC timestamps: Every file in a filesystem records three key timestamps --
Modification, Access, and Change (or creation on some systems). These are
collectively known as MAC times.
2. The Sleuth Kit (TSK): A suite of command-line forensic tools for analyzing
disk images and file systems. Key tools include:
  - mmls -- list partitions in a disk image
  - fsstat -- display file system information
  - fls -- list files (including deleted) in a disk image
  - icat -- extract a file by its inode number
  - mactime -- generate a chronological timeline from a body file
3. Body file format: The intermediate format used by fls -m and consumed by
mactime to produce human-readable timelines.
4. Deleted file recovery: When a file is deleted, the directory entry is removed
but the inode and data blocks may remain. fls shows deleted entries marked with
*.

The overall workflow:

1. Identify the partition layout with mmls
2. Determine the filesystem type with fsstat
3. Generate a body file with fls -m / -r -o <offset>
4. Create the timeline with mactime -b <bodyfile>
5. Analyze the timeline for anomalies, suspicious filenames, or hidden patterns
6. Extract suspicious files with icat
7. Reconstruct the flag

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

--[ Vulnerability / Observation ]--

Timeline 1, being worth 300 points (vs. a simpler Timeline 0), likely hides the
flag in one of these forensic artifacts:

1. Deleted files: The flag may have been written to a file that was subsequently
deleted. The file contents are still recoverable via inode.
2. Temporal anomalies: The flag could be embedded in file timestamps themselves
(e.g., encoded in modification times).
3. Hidden in metadata: The flag might appear in file names, extended attributes,
or alternate data streams visible only through timeline analysis.
4. Fragmented across files: Parts of the flag may be scattered across multiple
files, and the correct reassembly order is determined by sorting on timestamps.
5. Slack space or unallocated areas: The flag could reside in disk slack space
between file boundaries or in unallocated blocks.

The key observation that separates this from Timeline 0 is that simply listing
files is not enough — you must inspect deleted entries, examine the order files
were created/modified, and potentially reassemble fragments based on their
timestamps.

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

--[ Exploitation / Solution ]--

:: Step 1: Examine the disk image
-- bash --
# Identify the disk image type
file disk.img

# List partitions and find the offset
mmls disk.img
Example output might show a Linux partition starting at sector 2048. The offset
in sectors is important for all subsequent commands.

:: Step 2: Examine the filesystem
-- bash --
# Get filesystem details (use -o for the partition offset in sectors)
fsstat -o 2048 disk.img
This confirms the filesystem type (e.g., ext4) and provides metadata such as
block size, inode count, and volume label.

:: Step 3: Generate the timeline body file
-- bash --
# Create a body file with all file metadata including deleted files
# -m / sets the mount point prefix
# -r enables recursive listing
# -o specifies the partition offset in sectors
fls -m / -r -o 2048 disk.img > body.txt
:: Step 4: Generate the human-readable timeline
-- bash --
# Convert the body file to a sorted chronological timeline
mactime -b body.txt > timeline.txt

# Search the timeline for flag-related content
grep -i "flag\|pico\|ctf\|secret\|hidden" timeline.txt
:: Step 5: Look for deleted files and anomalies
-- bash --
# List all deleted files (marked with *)
fls -r -d -o 2048 disk.img

# Search for interesting filenames
fls -r -p -o 2048 disk.img | grep -i "flag\|secret\|hidden\|txt\|key"
:: Step 6: Extract suspicious files
-- bash --
# Extract a file by inode number (e.g., inode 42)
icat -o 2048 disk.img 42

# Search all strings in the disk image as a fallback
strings -a disk.img | grep -i "pico\|flag\|ctf"
:: Step 7: Analyze timeline patterns

For Timeline 1 specifically, the flag may require examining the order or content
of files based on their timestamps:
-- bash --
# Sort timeline by date and look for files created in rapid succession
# (may indicate programmatic flag planting)
cat timeline.txt | sort | less

# Check for unusual timestamp patterns (e.g., files with identical timestamps)
cat timeline.txt | awk '{print $1, $2}' | sort | uniq -c | sort -rn | head
:: Step 8: Reconstruct the flag

If the flag is split across multiple files sorted by timestamp:
-- bash --
# Extract each file's content in chronological order and concatenate
for inode in $(cat timeline.txt | grep "flag_part" | awk '{print $NF}' | cut -d'-' -f1); do
    icat -o 2048 disk.img $inode
done
Wrap the recovered string in the flag format: picoCTF{recovered_string}

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

--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Timeline 1 - picoCTF 2026 (Forensics, 300 pts)

Automated solver for the Timeline 1 disk image forensics challenge.
This script performs filesystem timeline analysis on the provided disk
image to locate and extract the flag.

The approach:
  1. Identify partitions using mmls
  2. List all files (including deleted) using fls
  3. Generate a mactime timeline
  4. Search for flag indicators in filenames, file contents, and timestamps
  5. Extract and reconstruct the flag

Prerequisites:
  - The Sleuth Kit (apt install sleuthkit)
  - Python 3.6+

Usage:
  python3 solve.py disk.img
  python3 solve.py --image disk.img --output flag.txt
  python3 solve.py disk.img --strings-only   # fallback: strings search
"""

import argparse
import subprocess
import sys
import re
import os
import tempfile


def run_cmd(cmd, check=False):
    """Run a shell command and return stdout."""
    result = subprocess.run(
        cmd, shell=True, capture_output=True, text=True
    )
    if check and result.returncode != 0:
        print(f"[!] Command failed: {cmd}", file=sys.stderr)
        print(f"    stderr: {result.stderr.strip()}", file=sys.stderr)
    return result.stdout, result.stderr, result.returncode


def check_dependencies():
    """Verify that required tools are installed."""
    tools = ['mmls', 'fls', 'icat', 'mactime', 'fsstat', 'strings']
    missing = []
    for tool in tools:
        _, _, rc = run_cmd(f"which {tool}")
        if rc != 0:
            missing.append(tool)
    if missing:
        print(f"[!] Missing tools: {', '.join(missing)}")
        print("[*] Install The Sleuth Kit: sudo apt install sleuthkit")
        if set(missing) - {'strings'}:
            return False
    return True


def find_partitions(image_path):
    """Use mmls to find partitions and their offsets."""
    print("[*] Analyzing partition table with mmls...")
    stdout, stderr, rc = run_cmd(f"mmls '{image_path}'")

    if rc != 0:
        # mmls failed -- image might be a raw filesystem without a partition table
        print("[*] mmls failed -- trying direct filesystem access (offset=0)")
        return [{'start': 0, 'desc': 'raw filesystem'}]

    partitions = []
    for line in stdout.splitlines():
        # Parse mmls output lines like: 002:  000    0002048    0001024    Linux (0x83)
        match = re.search(
            r'(\d+):\s+\d+\s+(\d+)\s+(\d+)\s+(.+)',
            line
        )
        if match:
            slot = match.group(1)
            start = int(match.group(2))
            length = int(match.group(3))
            desc = match.group(4).strip()

            # Skip meta entries and unallocated space
            if 'Unalloc' in desc or 'Meta' in desc or length == 0:
                continue

            partitions.append({
                'slot': slot,
                'start': start,
                'length': length,
                'desc': desc,
            })

    if not partitions:
        # Fallback: try offset 0
        print("[*] No usable partitions found -- trying offset 0")
        partitions = [{'start': 0, 'desc': 'raw'}]

    for p in partitions:
        print(f"    Partition: offset={p['start']}, desc={p.get('desc', 'N/A')}")

    return partitions


def get_fs_type(image_path, offset):
    """Use fsstat to determine the filesystem type."""
    stdout, _, rc = run_cmd(f"fsstat -o {offset} '{image_path}' 2>/dev/null | head -5")
    if rc == 0 and stdout:
        print(f"[*] Filesystem info (offset {offset}):")
        for line in stdout.strip().splitlines()[:3]:
            print(f"    {line}")
    return stdout


def list_files(image_path, offset):
    """Use fls to recursively list all files including deleted."""
    print(f"[*] Listing files with fls (offset={offset})...")
    stdout, _, rc = run_cmd(f"fls -r -p -o {offset} '{image_path}'")
    if rc != 0:
        return []

    files = []
    for line in stdout.splitlines():
        # Parse fls output: type inode filename
        # e.g., r/r 45: home/user/flag.txt
        # Deleted files: r/r * 46(realloc): home/user/deleted.txt
        match = re.match(r'([rd]/[rd])\s+(\*?)\s*(\d+)(?:\(realloc\))?:\s+(.+)', line)
        if match:
            ftype = match.group(1)
            deleted = match.group(2).strip() == '*'
            inode = int(match.group(3))
            path = match.group(4).strip()
            files.append({
                'type': ftype,
                'deleted': deleted,
                'inode': inode,
                'path': path,
            })

    print(f"    Found {len(files)} files ({sum(1 for f in files if f['deleted'])} deleted)")
    return files


def generate_timeline(image_path, offset, tmpdir):
    """Generate a mactime timeline from the disk image."""
    body_file = os.path.join(tmpdir, 'body.txt')
    timeline_file = os.path.join(tmpdir, 'timeline.txt')

    print(f"[*] Generating body file...")
    run_cmd(f"fls -m / -r -o {offset} '{image_path}' > '{body_file}'")

    print(f"[*] Generating timeline with mactime...")
    run_cmd(f"mactime -b '{body_file}' > '{timeline_file}'")

    try:
        with open(timeline_file, 'r') as f:
            timeline = f.read()
        print(f"    Timeline: {len(timeline.splitlines())} entries")
        return timeline
    except Exception:
        return ""


def extract_file(image_path, offset, inode):
    """Extract a file from the disk image by inode number."""
    stdout, _, rc = run_cmd(f"icat -o {offset} '{image_path}' {inode}")
    if rc == 0:
        return stdout
    return ""


def search_strings(image_path):
    """Fallback: search for flag-like strings in the raw image."""
    print("[*] Searching raw strings in disk image...")
    stdout, _, _ = run_cmd(f"strings -a '{image_path}'")

    flag_pattern = re.compile(r'picoCTF\{[^}]+\}')
    flags = flag_pattern.findall(stdout)
    if flags:
        return flags

    # Search for partial flag indicators
    partial_pattern = re.compile(r'(?:flag|pico|ctf|secret|hidden).*', re.IGNORECASE)
    partials = partial_pattern.findall(stdout)
    return partials[:20] if partials else []


def search_for_flag(image_path, offset, files, timeline):
    """Search files, timeline, and raw content for the flag."""
    flag_pattern = re.compile(r'picoCTF\{[^}]+\}')

    # Strategy 1: Search filenames for flag indicators
    print("\n[*] Strategy 1: Searching filenames...")
    suspicious_files = []
    for f in files:
        name_lower = f['path'].lower()
        if any(kw in name_lower for kw in ['flag', 'secret', 'hidden', 'key', 'pico', 'ctf']):
            suspicious_files.append(f)
            marker = " [DELETED]" if f['deleted'] else ""
            print(f"    Found: {f['path']} (inode {f['inode']}){marker}")

    # Strategy 2: Extract and check suspicious files
    print("\n[*] Strategy 2: Extracting suspicious files...")
    for f in suspicious_files:
        content = extract_file(image_path, offset, f['inode'])
        if content:
            match = flag_pattern.search(content)
            if match:
                return match.group()
            # Check if content itself looks like a flag value (without wrapper)
            stripped = content.strip()
            if stripped and len(stripped) < 200:
                print(f"    {f['path']}: {stripped[:100]}")

    # Strategy 3: Search deleted files
    print("\n[*] Strategy 3: Checking deleted files...")
    deleted_files = [f for f in files if f['deleted']]
    for f in deleted_files:
        content = extract_file(image_path, offset, f['inode'])
        if content:
            match = flag_pattern.search(content)
            if match:
                return match.group()
            if 'pico' in content.lower() or 'flag' in content.lower():
                print(f"    Interesting deleted file: {f['path']}: {content.strip()[:100]}")

    # Strategy 4: Check ALL files for flag content
    print("\n[*] Strategy 4: Scanning all file contents...")
    for f in files:
        if f['type'].startswith('r'):  # regular files only
            content = extract_file(image_path, offset, f['inode'])
            if content:
                match = flag_pattern.search(content)
                if match:
                    return match.group()

    # Strategy 5: Search timeline for encoded flag in filenames
    print("\n[*] Strategy 5: Analyzing timeline for patterns...")
    if timeline:
        match = flag_pattern.search(timeline)
        if match:
            return match.group()

        # Look for files created in a suspicious pattern
        # (e.g., single-character filenames that spell out the flag)
        timeline_lines = timeline.strip().splitlines()
        short_names = []
        for line in timeline_lines:
            # mactime format: date,size,type,mode,uid,gid,inode,name
            parts = line.split('\t') if '\t' in line else line.split()
            if parts:
                name = parts[-1] if len(parts) > 1 else ''
                basename = os.path.basename(name)
                if len(basename) == 1 and basename.isalnum():
                    short_names.append((line, basename))

        if short_names:
            candidate = ''.join(c for _, c in short_names)
            print(f"    Single-char files in timeline order: {candidate}")
            if len(candidate) >= 5:
                return f"picoCTF{{{candidate}}}"

    # Strategy 6: Search file paths that may encode the flag
    print("\n[*] Strategy 6: Checking file paths for encoded flag...")
    all_names = sorted(files, key=lambda f: f['inode'])
    for f in all_names:
        basename = os.path.basename(f['path'])
        if re.match(r'^[A-Za-z0-9_{}]+$', basename) and 'pico' in basename.lower():
            print(f"    Suspicious filename: {basename}")

    # Strategy 7: Concatenate file contents in timeline order
    print("\n[*] Strategy 7: Checking files in chronological order...")
    # Files with numeric or sequential names may contain flag fragments
    fragment_files = [f for f in files if re.match(r'.*\d+.*\.txt$', f['path'])]
    if fragment_files:
        fragment_files.sort(key=lambda x: x['path'])
        combined = ""
        for f in fragment_files:
            content = extract_file(image_path, offset, f['inode'])
            combined += content.strip()
        if combined:
            match = flag_pattern.search(combined)
            if match:
                return match.group()
            if len(combined) < 200:
                print(f"    Combined fragments: {combined}")

    # Strategy 8: Raw strings search
    print("\n[*] Strategy 8: Raw strings search (fallback)...")
    results = search_strings(image_path)
    for r in results:
        if flag_pattern.match(r):
            return r
        print(f"    {r[:100]}")

    return None


def main():
    parser = argparse.ArgumentParser(
        description='Timeline 1 solver - picoCTF 2026 Forensics (300 pts)'
    )
    parser.add_argument(
        'image', nargs='?', default=None,
        help='Path to the disk image file'
    )
    parser.add_argument(
        '--image', dest='image_flag',
        help='Path to the disk image file (alternative)'
    )
    parser.add_argument(
        '--output', '-o',
        help='Output file for the flag'
    )
    parser.add_argument(
        '--strings-only', action='store_true',
        help='Only search raw strings (skip TSK analysis)'
    )
    parser.add_argument(
        '--offset', type=int, default=None,
        help='Manually specify partition offset in sectors'
    )

    args = parser.parse_args()
    image_path = args.image or args.image_flag

    print("=" * 60)
    print("  Timeline 1 - picoCTF 2026 Solver")
    print("  Forensics | 300 pts")
    print("=" * 60)
    print()

    if not image_path:
        # Try to find a disk image in the current directory
        for candidate in ['disk.img', 'disk.dd', 'timeline.img', 'image.img',
                          'disk.raw', 'challenge.img']:
            if os.path.exists(candidate):
                image_path = candidate
                break

        if not image_path:
            import glob
            imgs = glob.glob('*.img') + glob.glob('*.dd') + glob.glob('*.raw')
            if imgs:
                image_path = imgs[0]

    if not image_path or not os.path.exists(image_path):
        print("[!] No disk image found.")
        print("[*] Usage: python3 solve.py <disk_image>")
        print("[*] Download the disk image from the challenge page first.")
        sys.exit(1)

    print(f"[*] Disk image: {image_path}")
    print(f"[*] Image size: {os.path.getsize(image_path)} bytes")
    print()

    # Check for required tools
    if not check_dependencies():
        print("[!] Cannot proceed without The Sleuth Kit tools.")
        print("[*] Falling back to strings search...")
        results = search_strings(image_path)
        for r in results:
            print(f"    {r}")
        sys.exit(1)

    # Strings-only mode
    if args.strings_only:
        results = search_strings(image_path)
        for r in results:
            print(f"    {r}")
        sys.exit(0)

    # Step 1: Find partitions
    if args.offset is not None:
        partitions = [{'start': args.offset, 'desc': 'user-specified'}]
    else:
        partitions = find_partitions(image_path)

    flag = None

    with tempfile.TemporaryDirectory() as tmpdir:
        for part in partitions:
            offset = part['start']
            print(f"\n{'=' * 40}")
            print(f"[*] Analyzing partition at offset {offset}")
            print(f"{'=' * 40}")

            # Get filesystem info
            get_fs_type(image_path, offset)

            # List all files
            files = list_files(image_path, offset)
            if not files:
                print("[*] No files found at this offset, skipping...")
                continue

            # Generate timeline
            timeline = generate_timeline(image_path, offset, tmpdir)

            # Search for the flag
            flag = search_for_flag(image_path, offset, files, timeline)

            if flag:
                break

    print("\n" + "=" * 60)
    if flag:
        # Ensure flag is wrapped in picoCTF{} format
        if not flag.startswith('picoCTF{'):
            flag = f"picoCTF{{{flag}}}"
        print(f"[+] FLAG: {flag}")
        if args.output:
            with open(args.output, 'w') as f:
                f.write(flag + '\n')
            print(f"[+] Flag written to {args.output}")
    else:
        print("[-] Flag not found automatically.")
        print("[*] Manual investigation steps:")
        print("    1. Generate timeline: fls -m / -r -o <offset> disk.img > body.txt")
        print("    2. Create timeline:   mactime -b body.txt > timeline.txt")
        print("    3. Search timeline:   grep -i 'flag\\|pico\\|secret' timeline.txt")
        print("    4. List deleted files: fls -r -d -o <offset> disk.img")
        print("    5. Extract by inode:  icat -o <offset> disk.img <inode_number>")
        print("    6. Check slack space: blkls -o <offset> disk.img | strings")
        print("    7. Strings fallback:  strings -a disk.img | grep -i pico")
    print("=" * 60)


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

--[ Key Takeaways ]--

- Higher-point forensics challenges reward going beyond a plain file listing —
inspect deleted files, slack space, and timestamp ordering.
- fls -r -d lists deleted entries (marked *); their inodes are often still
recoverable with icat.
- fsstat confirms the filesystem type and metadata before committing to a
timeline approach.
- A flag may be fragmented across files whose correct reassembly order is given
by their MAC timestamps in the mactime output.
- Watch for anomalous timestamp clusters (many files with identical or
rapid-succession times), which indicate programmatic flag planting.
- blkls | strings and strings -a are useful fallbacks for content in unallocated
or slack space.