┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Piece by Piece — picoCTF 2026
~ Imattas aka Zemi
 Category: General Skills
 Difficulty: Easy
 Points: 50
 Author: Imattas aka Zemi

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

--[ Challenge Description ]--

 After logging in, you will find multiple file parts in your home directory.
These parts need to be combined and extracted to reveal the flag.

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

--[ Recon / Initial Analysis ]--

This is a straightforward Linux/General Skills challenge involving split files
and archive extraction. The challenge tests your ability to:

1. Connect to a remote system via SSH
2. Identify and work with split file parts
3. Reassemble split files using standard Linux tools
4. Extract archives (potentially password-protected)

After connecting via SSH, list the home directory to discover the file parts:
-- bash --
ls -la
ls -la flag*
file flag*
:: Understanding Split Files

Files can be split using the split command in Linux, which divides a file into
smaller pieces. These pieces typically have names like:
- file.zip.001, file.zip.002, file.zip.003, ...
- file.aa, file.ab, file.ac, ...
- flag_part1, flag_part2, flag_part3, ...

To reassemble, you simply concatenate them in order using cat:
cat file.zip.* > file.zip
────────────────────────────────────────────────────────────────────────────────

--[ Vulnerability / Observation ]--

The "vulnerability" here is purely a workflow puzzle: a single archive has been
chopped into ordered pieces that must be concatenated back together before
extraction.

:: Archive Extraction

After reassembly, the resulting file is likely a ZIP archive (possibly
password-protected). Tools to use:
- unzip file.zip -- standard extraction
- unzip -P <password> file.zip -- extraction with password
- 7z x file.zip -- alternative extractor
- file combined_file -- identify the file type first

:: Password Handling

If the ZIP is password-protected, the password may be:
- Provided in the challenge description or a hint file
- A common/simple password
- Hidden somewhere in the file names or directory structure

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

--[ Exploitation / Solution ]--

:: Step-by-step:

1. Start the challenge instance on the picoCTF platform to get SSH credentials.

2. Connect via SSH:
-- bash --
   ssh ctf-player@<hostname> -p <port>
Use the password provided by the challenge.

3. List files in the home directory:
-- bash --
   ls -la
You should see multiple file parts (e.g., flag.zip.001, flag.zip.002, etc.).

4. Identify the file parts and their naming pattern:
-- bash --
   ls -la flag*
   file flag*
5. Combine the parts using cat:
-- bash --
   cat flag.zip.* > flag.zip
Or if named differently:
-- bash --
   cat flag_part* > combined.zip
6. Check the combined file type:
-- bash --
   file flag.zip
7. Extract the archive:
-- bash --
   unzip flag.zip
If password-protected, look for a password hint or try:
-- bash --
   unzip -P <password> flag.zip
8. Read the flag:
-- bash --
   cat flag.txt
────────────────────────────────────────────────────────────────────────────────

--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Piece by Piece - picoCTF 2026
Category: General Skills (50 points)

Description: After logging in, you will find multiple file parts in your
home directory. These parts need to be combined and extracted to reveal the flag.

This script automates the process of:
1. Connecting to the challenge via SSH
2. Listing and identifying split file parts
3. Combining them with cat
4. Extracting the resulting archive
5. Reading the flag

Usage:
    python3 solve.py --host <HOST> --port <PORT> --user <USER> --password <PASS>
    python3 solve.py --local   (if files are already downloaded locally)

Example:
    python3 solve.py --host rescued-float.picoctf.net --port 55123 --user ctf-player --password abc123
"""

import argparse
import subprocess
import os
import glob
import zipfile
import re
import sys
import shutil

try:
    import paramiko
    HAS_PARAMIKO = True
except ImportError:
    HAS_PARAMIKO = False


def run_ssh_command(client, command):
    """Execute a command over SSH and return stdout."""
    stdin, stdout, stderr = client.exec_command(command)
    output = stdout.read().decode(errors='replace')
    error = stderr.read().decode(errors='replace')
    return output, error


def solve_remote(host, port, username, password):
    """
    Solve the challenge remotely via SSH.
    Connects, finds file parts, combines them, extracts, and reads the flag.
    """
    if not HAS_PARAMIKO:
        print("[!] paramiko not installed. Install with: pip install paramiko")
        print("[*] Falling back to system SSH command...")
        return solve_remote_system_ssh(host, port, username, password)

    print(f"[*] Connecting to {username}@{host}:{port}...")
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    try:
        client.connect(host, port=port, username=username, password=password, timeout=30)
    except Exception as e:
        print(f"[!] SSH connection failed: {e}")
        return None

    print("[+] Connected!")

    # Step 1: List files in home directory
    output, _ = run_ssh_command(client, "ls -la ~")
    print(f"[*] Home directory contents:\n{output}")

    # Step 2: Identify file parts
    output, _ = run_ssh_command(client, "ls ~ | sort")
    files = [f.strip() for f in output.strip().split('\n') if f.strip()]
    print(f"[*] Files found: {files}")

    # Identify patterns of split files
    # Common patterns: file.zip.001, file.aa, file_part1, etc.

    # Try to find the base name and pattern
    zip_parts = [f for f in files if re.match(r'.*\.\d{3}$', f)]       # .001, .002, ...
    split_parts = [f for f in files if re.match(r'.*\.a[a-z]$', f)]     # .aa, .ab, ...
    part_files = [f for f in files if re.match(r'.*part\d+.*', f, re.I)] # part1, part2, ...
    generic_parts = [f for f in files if re.match(r'.*\.(part\d+|p\d+|chunk\d+)', f, re.I)]

    if zip_parts:
        parts = sorted(zip_parts)
        base_name = re.sub(r'\.\d{3}$', '', parts[0])
        combine_cmd = f"cat ~/{base_name}.* > /tmp/combined_file"
    elif split_parts:
        parts = sorted(split_parts)
        base_name = re.sub(r'\.a[a-z]$', '', parts[0])
        combine_cmd = f"cat ~/{base_name}.* > /tmp/combined_file"
    elif part_files:
        parts = sorted(part_files)
        # Try glob-based combination
        common_prefix = os.path.commonprefix(parts)
        combine_cmd = f"cat ~/{common_prefix}* > /tmp/combined_file"
    elif generic_parts:
        parts = sorted(generic_parts)
        common_prefix = os.path.commonprefix(parts)
        combine_cmd = f"cat ~/{common_prefix}* > /tmp/combined_file"
    else:
        # Fallback: try combining all non-hidden files
        non_hidden = [f for f in files if not f.startswith('.')]
        print(f"[*] No obvious pattern found. Trying all files: {non_hidden}")
        parts = sorted(non_hidden)
        if len(parts) > 1:
            file_list = ' '.join(f'~/{f}' for f in parts)
            combine_cmd = f"cat {file_list} > /tmp/combined_file"
        else:
            combine_cmd = f"cp ~/{parts[0]} /tmp/combined_file" if parts else None

    if not parts:
        print("[!] No file parts found!")
        client.close()
        return None

    print(f"[*] Identified {len(parts)} file parts: {parts}")

    # Step 3: Combine the parts
    print(f"[*] Combining: {combine_cmd}")
    output, error = run_ssh_command(client, combine_cmd)
    if error:
        print(f"[!] Combine error: {error}")

    # Step 4: Identify the combined file type
    output, _ = run_ssh_command(client, "file /tmp/combined_file")
    print(f"[*] File type: {output.strip()}")

    # Step 5: Extract the archive
    flag = None

    if 'zip' in output.lower():
        # Try without password first
        output, error = run_ssh_command(client, "cd /tmp && unzip -o combined_file 2>&1")
        print(f"[*] Unzip output: {output}")

        if 'password' in output.lower() or 'password' in error.lower():
            # Try common passwords
            common_passwords = ['picoCTF', 'password', 'flag', 'ctf', 'pico', '1234', 'admin']
            for pwd in common_passwords:
                output, error = run_ssh_command(client,
                    f"cd /tmp && unzip -o -P '{pwd}' combined_file 2>&1")
                if 'extracting' in output.lower() or 'inflating' in output.lower():
                    print(f"[+] ZIP password: {pwd}")
                    break

    elif 'gzip' in output.lower():
        run_ssh_command(client, "cd /tmp && mv combined_file combined_file.gz && gunzip combined_file.gz")
    elif 'tar' in output.lower():
        run_ssh_command(client, "cd /tmp && tar xf combined_file")
    elif 'xz' in output.lower():
        run_ssh_command(client, "cd /tmp && mv combined_file combined_file.xz && unxz combined_file.xz")
    elif 'bzip2' in output.lower():
        run_ssh_command(client, "cd /tmp && mv combined_file combined_file.bz2 && bunzip2 combined_file.bz2")

    # Step 6: Find and read the flag
    # Search for flag in extracted files
    output, _ = run_ssh_command(client, "ls -la /tmp/")
    print(f"[*] /tmp contents after extraction:\n{output}")

    # Look for flag files
    output, _ = run_ssh_command(client, "find /tmp -name 'flag*' -o -name '*.txt' 2>/dev/null | head -20")
    flag_files = [f.strip() for f in output.strip().split('\n') if f.strip()]

    for ff in flag_files:
        output, _ = run_ssh_command(client, f"cat '{ff}'")
        flag_match = re.search(r'picoCTF\{[^}]+\}', output)
        if flag_match:
            flag = flag_match.group(0)
            print(f"\n[+] FLAG found in {ff}: {flag}")
            break

    if not flag:
        # Broader search: grep for the flag pattern in all extracted files
        output, _ = run_ssh_command(client,
            "grep -r 'picoCTF{' /tmp/ 2>/dev/null | head -5")
        flag_match = re.search(r'picoCTF\{[^}]+\}', output)
        if flag_match:
            flag = flag_match.group(0)
            print(f"\n[+] FLAG: {flag}")

    if not flag:
        # Try reading the combined file directly
        output, _ = run_ssh_command(client, "strings /tmp/combined_file | grep picoCTF")
        flag_match = re.search(r'picoCTF\{[^}]+\}', output)
        if flag_match:
            flag = flag_match.group(0)
            print(f"\n[+] FLAG (from strings): {flag}")

    client.close()

    if not flag:
        print("[!] Flag not found automatically. Try connecting manually via SSH.")
    return flag


def solve_remote_system_ssh(host, port, username, password):
    """Fallback: use system ssh/sshpass command."""
    print("[*] Attempting solution with system SSH...")
    print(f"[*] Run these commands manually:")
    print(f"    ssh {username}@{host} -p {port}")
    print(f"    Password: {password}")
    print(f"    ls -la")
    print(f"    cat *.zip.* > combined.zip  (or appropriate pattern)")
    print(f"    unzip combined.zip")
    print(f"    cat flag.txt")
    return None


def solve_local(directory="."):
    """
    Solve locally if files have been downloaded.
    Combines split files and extracts the archive.
    """
    print(f"[*] Looking for file parts in: {os.path.abspath(directory)}")

    # Find file parts
    all_files = sorted(os.listdir(directory))
    print(f"[*] Files: {all_files}")

    # Identify split file patterns
    zip_parts = sorted([f for f in all_files if re.match(r'.*\.\d{3}$', f)])
    split_parts = sorted([f for f in all_files if re.match(r'.*\.a[a-z]$', f)])
    part_files = sorted([f for f in all_files if re.match(r'.*part\d+.*', f, re.I)])

    if zip_parts:
        parts = zip_parts
    elif split_parts:
        parts = split_parts
    elif part_files:
        parts = part_files
    else:
        print("[!] No obvious split file pattern found.")
        return None

    print(f"[+] Found {len(parts)} file parts: {parts}")

    # Combine parts
    combined_path = os.path.join(directory, "combined_file")
    with open(combined_path, 'wb') as outf:
        for part in parts:
            part_path = os.path.join(directory, part)
            with open(part_path, 'rb') as inf:
                outf.write(inf.read())

    print(f"[+] Combined file written to: {combined_path}")
    print(f"[+] Combined file size: {os.path.getsize(combined_path)} bytes")

    # Identify file type
    try:
        result = subprocess.run(['file', combined_path], capture_output=True, text=True)
        file_type = result.stdout.strip()
        print(f"[*] File type: {file_type}")
    except FileNotFoundError:
        file_type = ""

    # Extract
    extract_dir = os.path.join(directory, "extracted")
    os.makedirs(extract_dir, exist_ok=True)

    if 'zip' in file_type.lower() or combined_path.endswith('.zip'):
        try:
            with zipfile.ZipFile(combined_path, 'r') as zf:
                # Try without password
                try:
                    zf.extractall(extract_dir)
                    print(f"[+] Extracted to: {extract_dir}")
                except RuntimeError:
                    # Password protected
                    common_passwords = [b'picoCTF', b'password', b'flag', b'ctf', b'pico', b'1234']
                    for pwd in common_passwords:
                        try:
                            zf.extractall(extract_dir, pwd=pwd)
                            print(f"[+] ZIP password: {pwd.decode()}")
                            break
                        except RuntimeError:
                            continue
        except zipfile.BadZipFile:
            print("[!] Not a valid ZIP. Trying other formats...")
            subprocess.run(['tar', 'xf', combined_path, '-C', extract_dir],
                           capture_output=True)
    elif 'tar' in file_type.lower() or 'gzip' in file_type.lower():
        subprocess.run(['tar', 'xf', combined_path, '-C', extract_dir],
                       capture_output=True)

    # Find flag
    for root, dirs, files_list in os.walk(extract_dir):
        for fname in files_list:
            fpath = os.path.join(root, fname)
            try:
                with open(fpath, 'r', errors='replace') as f:
                    content = f.read()
                flag_match = re.search(r'picoCTF\{[^}]+\}', content)
                if flag_match:
                    flag = flag_match.group(0)
                    print(f"\n[+] FLAG found in {fpath}: {flag}")
                    return flag
            except Exception:
                continue

    # Try strings on combined file
    try:
        result = subprocess.run(['strings', combined_path], capture_output=True, text=True)
        flag_match = re.search(r'picoCTF\{[^}]+\}', result.stdout)
        if flag_match:
            print(f"\n[+] FLAG (from strings): {flag_match.group(0)}")
            return flag_match.group(0)
    except FileNotFoundError:
        pass

    print("[!] Flag not found automatically.")
    return None


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Piece by Piece Solver - picoCTF 2026")
    parser.add_argument("--host", type=str, help="SSH hostname")
    parser.add_argument("--port", type=int, default=22, help="SSH port")
    parser.add_argument("--user", type=str, default="ctf-player", help="SSH username")
    parser.add_argument("--password", type=str, help="SSH password")
    parser.add_argument("--local", action="store_true",
                        help="Solve locally (files already downloaded)")
    parser.add_argument("--dir", type=str, default=".",
                        help="Directory containing local file parts")
    args = parser.parse_args()

    if args.local:
        flag = solve_local(args.dir)
    elif args.host and args.password:
        flag = solve_remote(args.host, args.port, args.user, args.password)
    else:
        print("Usage:")
        print("  Remote: python3 solve.py --host <HOST> --port <PORT> --password <PASS>")
        print("  Local:  python3 solve.py --local --dir <directory>")
        print()
        print("Example:")
        print("  python3 solve.py --host rescued-float.picoctf.net --port 55123 --password abc123")
        sys.exit(1)

    if flag:
        print(f"\n{'='*50}")
        print(f"FLAG: {flag}")
        print(f"{'='*50}")
────────────────────────────────────────────────────────────────────────────────

--[ Key Takeaways ]--

- Split files are concatenated back in lexical order with cat parts.* > combined
-- ordering matters, so glob patterns like .001, .aa, part1 sort correctly.
- Always run file on the reassembled blob to identify the archive format (ZIP,
gzip, tar, xz, bzip2) before choosing an extractor.
- Password-protected ZIPs can be tackled with unzip -P <password>; common CTF
passwords (picoCTF, flag, password) are worth trying.
- After extraction, grep -r 'picoCTF{' or strings | grep picoCTF quickly locates
the flag.