ffd384f64b
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
474 lines
16 KiB
Python
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()
|