Files
bigbrother/core/kill_switch.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

474 lines
16 KiB
Python

#!/usr/bin/env python3
"""Kill switch — full evidence wipe and shutdown.
Sequence:
1. Stop all modules + subprocesses (bettercap, tcpdump, Responder)
2. Send corrective ARP if ARP spoof was active
3. Destroy LUKS header (milliseconds)
4. Shred encryption keys + credential database
5. dd zero-fill data partition
6. Clear RAM
7. Reboot (boot flag auto-completes if interrupted)
Trigger methods: CLI, SSH, BLE, dead man's switch.
Boot flag at /var/run/.bb_wipe ensures wipe completes if interrupted.
"""
import os
import sys
import time
import signal
import shutil
import struct
import socket
import logging
import subprocess
from pathlib import Path
from typing import Optional
from core.bus import EventBus
logger = logging.getLogger(__name__)
# Boot flag — if this file exists on boot, resume wipe
WIPE_FLAG = "/var/run/.bb_wipe"
# Paths to wipe (relative to install root or absolute)
DEFAULT_SENSITIVE_PATHS = [
"storage/creds/",
"storage/config/",
"storage/intel/",
"storage/audit/",
"storage/dns_logs/",
"storage/sni_logs/",
"storage/logs/",
"storage/tool_logs/",
"storage/pcaps/",
"storage/baseline/",
]
SENSITIVE_FILES = [
"storage/config/encryption.key",
"storage/creds/credentials.db",
"storage/config/luks_header.bak",
]
class KillSwitch:
"""Full wipe orchestrator.
Call execute() to perform the complete wipe sequence.
Call check_boot_flag() on startup to resume an interrupted wipe.
"""
def __init__(self, bus: EventBus, config: dict,
engine=None, tool_manager=None):
self.bus = bus
self.config = config
self.engine = engine
self.tool_manager = tool_manager
self._install_path = config.get("device", {}).get(
"install_path", "/opt/.cache/bb"
)
self._luks_device = config.get("security", {}).get(
"luks_device", "/dev/mapper/sensor-data"
)
self._luks_partition = config.get("security", {}).get(
"luks_partition", ""
)
self._arp_spoof_active = False
self._spoof_gateway = None
self._spoof_targets = []
self._interface = config.get("network", {}).get(
"primary_interface", "eth0"
)
# ------------------------------------------------------------------
# Main entry
# ------------------------------------------------------------------
def execute(self, reason: str = "manual") -> None:
"""Execute the full kill switch sequence. Does not return."""
logger.critical("KILL SWITCH ACTIVATED — reason: %s", reason)
# Set boot flag first so reboot completes the wipe
self._set_boot_flag()
self.bus.emit("KILL_SWITCH",
{"reason": reason, "stage": "initiated"},
source_module="kill_switch")
# Phase 1: Stop everything
self._stop_all_processes()
# Phase 2: Corrective ARP
self._send_corrective_arp()
# Phase 3: Destroy LUKS header
self._destroy_luks()
# Phase 4: Shred sensitive files
self._shred_sensitive_files()
# Phase 5: Zero-fill data partition
self._zero_fill_partition()
# Phase 6: Clear RAM
self._clear_ram()
# Phase 7: Remove boot flag and reboot
self._clear_boot_flag()
self._reboot()
def check_boot_flag(self) -> bool:
"""Check if a wipe was interrupted and should resume.
Call this on boot. Returns True if wipe was resumed.
"""
if os.path.exists(WIPE_FLAG):
logger.critical("Boot flag detected — resuming interrupted wipe")
self.execute(reason="interrupted_resume")
return True
return False
# ------------------------------------------------------------------
# ARP state (called by active modules to register spoof state)
# ------------------------------------------------------------------
def set_arp_spoof_state(self, active: bool, gateway: str = None,
targets: list = None, interface: str = None) -> None:
"""Register current ARP spoof state for corrective ARP on kill."""
self._arp_spoof_active = active
self._spoof_gateway = gateway
self._spoof_targets = targets or []
if interface:
self._interface = interface
# ------------------------------------------------------------------
# Phase 1: Stop processes
# ------------------------------------------------------------------
def _stop_all_processes(self) -> None:
"""Stop all modules and managed subprocesses."""
logger.info("Kill switch phase 1: stopping all processes")
if self.engine:
try:
self.engine.stop_all(timeout=3.0)
except Exception:
logger.exception("Error stopping modules")
if self.tool_manager:
try:
self.tool_manager.stop_all()
except Exception:
logger.exception("Error stopping tools")
# Kill any remaining tool PIDs
for pid in self.tool_manager.get_all_pids():
try:
os.kill(pid, signal.SIGKILL)
except (OSError, ProcessLookupError):
pass
# ------------------------------------------------------------------
# Phase 2: Corrective ARP
# ------------------------------------------------------------------
def _send_corrective_arp(self) -> None:
"""Send corrective gratuitous ARPs if ARP spoof was active."""
if not self._arp_spoof_active or not self._spoof_gateway:
return
logger.info("Kill switch phase 2: sending corrective ARP")
try:
gw_ip = self._spoof_gateway
gw_mac = self._resolve_mac(gw_ip)
if not gw_mac:
logger.warning("Could not resolve gateway MAC for corrective ARP")
return
for target_ip in self._spoof_targets:
target_mac = self._resolve_mac(target_ip)
if target_mac:
self._send_arp_reply(
src_mac=gw_mac, src_ip=gw_ip,
dst_mac=target_mac, dst_ip=target_ip,
interface=self._interface
)
# Also restore gateway's ARP cache
for target_ip in self._spoof_targets:
target_mac = self._resolve_mac(target_ip)
if target_mac:
self._send_arp_reply(
src_mac=target_mac, src_ip=target_ip,
dst_mac=gw_mac, dst_ip=gw_ip,
interface=self._interface
)
except Exception:
logger.exception("Corrective ARP failed")
@staticmethod
def _resolve_mac(ip: str) -> Optional[str]:
"""Resolve IP to MAC from /proc/net/arp."""
try:
with open("/proc/net/arp", "r") as f:
for line in f:
parts = line.split()
if len(parts) >= 4 and parts[0] == ip:
mac = parts[3]
if mac != "00:00:00:00:00:00":
return mac
except (FileNotFoundError, PermissionError):
pass
return None
@staticmethod
def _send_arp_reply(src_mac: str, src_ip: str, dst_mac: str, dst_ip: str,
interface: str) -> None:
"""Send a single ARP reply using raw socket."""
try:
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0806))
sock.bind((interface, 0))
src_mac_bytes = bytes.fromhex(src_mac.replace(":", ""))
dst_mac_bytes = bytes.fromhex(dst_mac.replace(":", ""))
src_ip_bytes = socket.inet_aton(src_ip)
dst_ip_bytes = socket.inet_aton(dst_ip)
# Ethernet header + ARP reply
frame = (
dst_mac_bytes + # Dst MAC
src_mac_bytes + # Src MAC
b"\x08\x06" + # EtherType: ARP
b"\x00\x01" + # Hardware type: Ethernet
b"\x08\x00" + # Protocol type: IPv4
b"\x06" + # Hardware size: 6
b"\x04" + # Protocol size: 4
b"\x00\x02" + # Opcode: reply
src_mac_bytes + # Sender MAC
src_ip_bytes + # Sender IP
dst_mac_bytes + # Target MAC
dst_ip_bytes # Target IP
)
# Send 3 times for reliability
for _ in range(3):
sock.send(frame)
time.sleep(0.1)
sock.close()
except Exception as e:
logger.warning("ARP reply send failed: %s", e)
# ------------------------------------------------------------------
# Phase 3: LUKS header destruction
# ------------------------------------------------------------------
def _destroy_luks(self) -> None:
"""Destroy the LUKS header, making encrypted data unrecoverable."""
logger.info("Kill switch phase 3: destroying LUKS header")
if not self._luks_partition:
logger.warning("No LUKS partition configured, skipping")
return
# Close the LUKS device first
try:
subprocess.run(
["cryptsetup", "close", self._luks_device.split("/")[-1]],
timeout=5, capture_output=True
)
except Exception:
pass
# Overwrite LUKS header (first 16MB covers all key slots)
try:
subprocess.run(
["dd", "if=/dev/urandom", f"of={self._luks_partition}",
"bs=1M", "count=16", "conv=notrunc"],
timeout=30, capture_output=True, check=True
)
logger.info("LUKS header destroyed")
except Exception as e:
logger.error("LUKS header destruction failed: %s", e)
# ------------------------------------------------------------------
# Phase 4: Shred sensitive files
# ------------------------------------------------------------------
def _shred_sensitive_files(self) -> None:
"""Securely delete sensitive files and directories."""
logger.info("Kill switch phase 4: shredding sensitive files")
base = Path(self._install_path)
# Shred specific files first
for rel_path in SENSITIVE_FILES:
fpath = base / rel_path
if fpath.exists():
self._shred_file(str(fpath))
# Then directories
for rel_path in DEFAULT_SENSITIVE_PATHS:
dpath = base / rel_path
if dpath.exists():
for fpath in dpath.rglob("*"):
if fpath.is_file():
self._shred_file(str(fpath))
try:
shutil.rmtree(str(dpath), ignore_errors=True)
except Exception:
pass
# State database
state_db = os.path.expanduser("~/.implant/state.db")
for f in [state_db, state_db + "-wal", state_db + "-shm"]:
if os.path.exists(f):
self._shred_file(f)
@staticmethod
def _shred_file(path: str) -> None:
"""Overwrite file with random data, then unlink."""
try:
size = os.path.getsize(path)
with open(path, "r+b") as f:
# 3 passes: random, zeros, random
for pattern in [None, b"\x00", None]:
f.seek(0)
remaining = size
while remaining > 0:
chunk = min(remaining, 65536)
if pattern is None:
data = os.urandom(chunk)
else:
data = pattern * chunk
f.write(data)
remaining -= chunk
f.flush()
os.fsync(f.fileno())
os.unlink(path)
except Exception as e:
# Fallback: just delete
try:
os.unlink(path)
except Exception:
pass
# ------------------------------------------------------------------
# Phase 5: Zero-fill
# ------------------------------------------------------------------
def _zero_fill_partition(self) -> None:
"""Zero-fill the data partition."""
logger.info("Kill switch phase 5: zero-filling data partition")
storage_dir = os.path.join(self._install_path, "storage")
if not os.path.isdir(storage_dir):
storage_dir = self._install_path
try:
# Write zeros until disk full
zero_file = os.path.join(storage_dir, ".wipe")
subprocess.run(
["dd", "if=/dev/zero", f"of={zero_file}",
"bs=1M", "status=none"],
timeout=600, capture_output=True
)
except Exception:
pass
finally:
try:
os.unlink(os.path.join(storage_dir, ".wipe"))
except Exception:
pass
# ------------------------------------------------------------------
# Phase 6: Clear RAM
# ------------------------------------------------------------------
@staticmethod
def _clear_ram() -> None:
"""Drop caches and clear as much RAM as possible."""
logger.info("Kill switch phase 6: clearing RAM")
try:
# Drop page cache, dentries, inodes
with open("/proc/sys/vm/drop_caches", "w") as f:
f.write("3")
except (PermissionError, FileNotFoundError):
pass
# smem or sdmem would be better but may not be installed
try:
subprocess.run(["sdmem", "-f", "-ll"], timeout=60,
capture_output=True)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# ------------------------------------------------------------------
# Phase 7: Reboot
# ------------------------------------------------------------------
@staticmethod
def _reboot() -> None:
"""Reboot the system."""
logger.info("Kill switch phase 7: rebooting")
try:
subprocess.run(["reboot"], timeout=5)
except Exception:
try:
os.system("reboot")
except Exception:
pass
# ------------------------------------------------------------------
# Boot flag
# ------------------------------------------------------------------
@staticmethod
def _set_boot_flag() -> None:
"""Create boot flag to resume wipe if interrupted."""
try:
Path(WIPE_FLAG).parent.mkdir(parents=True, exist_ok=True)
Path(WIPE_FLAG).touch()
except Exception:
pass
@staticmethod
def _clear_boot_flag() -> None:
"""Remove boot flag after successful wipe."""
try:
os.unlink(WIPE_FLAG)
except FileNotFoundError:
pass
# ------------------------------------------------------------------
# Dead man's switch
# ------------------------------------------------------------------
def start_dead_man_switch(self, interval_hours: int = 24) -> None:
"""Start dead man's switch timer.
If not reset within interval_hours, trigger kill switch.
Intended to be reset by operator check-in via C2.
"""
self._dms_interval = interval_hours * 3600
self._dms_last_checkin = time.time()
import threading
def _dms_loop():
while True:
time.sleep(60)
elapsed = time.time() - self._dms_last_checkin
if elapsed > self._dms_interval:
logger.critical("Dead man's switch triggered (no check-in for %dh)",
interval_hours)
self.execute(reason="dead_man_switch")
t = threading.Thread(target=_dms_loop, daemon=True, name="sensor-dms")
t.start()
def reset_dead_man_switch(self) -> None:
"""Reset the dead man's switch timer (operator check-in)."""
self._dms_last_checkin = time.time()