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

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

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

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

690 lines
25 KiB
Python

#!/usr/bin/env python3
"""Passive host discovery via broadcast/multicast protocol observation.
Parses:
- ARP requests/replies (IP-MAC mapping)
- DHCP request/ACK (hostname, vendor class, option 55 fingerprinting)
- mDNS/Bonjour (hostnames, services)
- NetBIOS name queries (NBNS port 137)
- SSDP/UPnP announcements (port 1900)
- LLMNR queries (port 5355)
Publishes HOST_DISCOVERED events. Correlates multiple signals per host
for IP + MAC + hostname + vendor (OUI) + OS guess.
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
class HostDiscovery(BaseModule):
"""Build host inventory from passive network observation."""
name = "host_discovery"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 200
FLUSH_INTERVAL = 60
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._reader_thread: Optional[threading.Thread] = None
self._flusher_thread: Optional[threading.Thread] = None
# In-memory host table: ip -> host_info dict
self._hosts = {}
self._hosts_lock = threading.Lock()
self._pending_updates = []
self._update_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_hosts = 0
# OUI lookup cache: first 3 bytes hex -> vendor
self._oui_cache = {}
# DHCP fingerprint cache: option55 -> os_guess
self._dhcp_fp_cache = {}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._capture_bus = self.config.get("capture_bus")
if not self._capture_bus:
logger.error("HostDiscovery requires capture_bus in config")
return
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
self._db_path = os.path.join(base_dir, "hosts.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
# Load OUI database
oui_path = self.config.get("oui_db", "")
if oui_path and os.path.isfile(oui_path):
self._load_oui_db(oui_path)
# Load DHCP fingerprint database
dhcp_fp_path = self.config.get("dhcp_fingerprints_db", "")
if dhcp_fp_path and os.path.isfile(dhcp_fp_path):
self._load_dhcp_fingerprints(dhcp_fp_path)
# Load BPF filter
bpf_path = self.config.get("bpf_filter_path", "")
bpf_filter = ""
if bpf_path and os.path.isfile(bpf_path):
with open(bpf_path) as f:
lines = [l.strip() for l in f if l.strip() and not l.startswith("#")]
bpf_filter = " ".join(lines)
if not bpf_filter:
bpf_filter = "arp or port 67 or port 68 or port 5353 or port 1900 or port 5355 or port 137 or port 138"
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter=bpf_filter, queue_depth=10000
)
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
self._reader_thread = threading.Thread(
target=self._read_packets, daemon=True, name="sensor-hostdisc-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-hostdisc-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("HostDiscovery started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
self._flush_hosts()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info("HostDiscovery stopped — %d hosts discovered", self._total_hosts)
def status(self) -> dict:
with self._hosts_lock:
host_count = len(self._hosts)
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_hosts": host_count,
"oui_entries": len(self._oui_cache),
"dhcp_fingerprints": len(self._dhcp_fp_cache),
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _init_db(self) -> None:
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._db_conn.execute("PRAGMA journal_mode=WAL")
self._db_conn.execute("PRAGMA synchronous=NORMAL")
self._db_conn.executescript("""
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
mac TEXT,
hostname TEXT,
vendor TEXT,
os_guess TEXT,
dhcp_fingerprint TEXT,
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
source TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_ip ON hosts(ip);
CREATE INDEX IF NOT EXISTS idx_host_mac ON hosts(mac);
CREATE INDEX IF NOT EXISTS idx_host_hostname ON hosts(hostname);
""")
self._db_conn.commit()
def _load_oui_db(self, path: str) -> None:
"""Load OUI vendor database. Expected format: 'AA:BB:CC<tab>Vendor Name' per line."""
try:
conn = sqlite3.connect(path)
rows = conn.execute("SELECT oui, vendor FROM oui").fetchall()
for oui, vendor in rows:
self._oui_cache[oui.upper().replace(":", "").replace("-", "")] = vendor
conn.close()
logger.info("Loaded %d OUI entries", len(self._oui_cache))
except Exception:
# Try plain text format
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t", 1)
if len(parts) == 2:
oui = parts[0].upper().replace(":", "").replace("-", "")
self._oui_cache[oui] = parts[1]
logger.info("Loaded %d OUI entries from text", len(self._oui_cache))
except Exception:
logger.warning("Failed to load OUI database from %s", path)
def _load_dhcp_fingerprints(self, path: str) -> None:
"""Load DHCP option 55 fingerprint database."""
try:
conn = sqlite3.connect(path)
rows = conn.execute("SELECT fingerprint, os_name FROM fingerprints").fetchall()
for fp, os_name in rows:
self._dhcp_fp_cache[fp] = os_name
conn.close()
logger.info("Loaded %d DHCP fingerprints", len(self._dhcp_fp_cache))
except Exception:
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t", 1)
if len(parts) == 2:
self._dhcp_fp_cache[parts[0]] = parts[1]
logger.info("Loaded %d DHCP fingerprints from text", len(self._dhcp_fp_cache))
except Exception:
logger.warning("Failed to load DHCP fingerprints from %s", path)
# ------------------------------------------------------------------
# OUI lookup
# ------------------------------------------------------------------
def _lookup_oui(self, mac: str) -> str:
"""Look up vendor from MAC address OUI (first 3 octets)."""
oui = mac.upper().replace(":", "").replace("-", "")[:6]
return self._oui_cache.get(oui, "")
# ------------------------------------------------------------------
# Host update
# ------------------------------------------------------------------
def _update_host(self, ts: float, ip: str, mac: str = "", hostname: str = "",
vendor: str = "", os_guess: str = "",
dhcp_fingerprint: str = "", source: str = "") -> None:
"""Update or create host entry. Merges new data with existing."""
if not ip or ip == "0.0.0.0" or ip.startswith("255."):
return
is_new = False
with self._hosts_lock:
if ip not in self._hosts:
self._hosts[ip] = {
"ip": ip, "mac": "", "hostname": "", "vendor": "",
"os_guess": "", "dhcp_fingerprint": "",
"first_seen": ts, "last_seen": ts, "source": source,
}
is_new = True
host = self._hosts[ip]
host["last_seen"] = ts
if mac and not host["mac"]:
host["mac"] = mac
if not vendor:
vendor = self._lookup_oui(mac)
if hostname and not host["hostname"]:
host["hostname"] = hostname
if vendor and not host["vendor"]:
host["vendor"] = vendor
if os_guess and not host["os_guess"]:
host["os_guess"] = os_guess
if dhcp_fingerprint and not host["dhcp_fingerprint"]:
host["dhcp_fingerprint"] = dhcp_fingerprint
if source:
existing = host.get("source", "")
if source not in existing:
host["source"] = f"{existing},{source}" if existing else source
if is_new:
self._total_hosts += 1
self.bus.emit("HOST_DISCOVERED", {
"ip": ip, "mac": mac, "hostname": hostname,
"vendor": vendor, "source": source,
}, source_module=self.name)
# ------------------------------------------------------------------
# Packet reader
# ------------------------------------------------------------------
def _read_packets(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, raw_packet = result
try:
self._parse_packet(ts, raw_packet)
except Exception:
pass
def _parse_packet(self, ts: float, raw: bytes) -> None:
"""Route packet to appropriate protocol parser."""
if len(raw) < 14:
return
eth_type = struct.unpack("!H", raw[12:14])[0]
src_mac = ":".join(f"{b:02x}" for b in raw[6:12])
if eth_type == 0x0806: # ARP
self._parse_arp(ts, raw, src_mac)
return
ip_offset = 14
if eth_type == 0x8100: # VLAN
if len(raw) < 18:
return
eth_type = struct.unpack("!H", raw[16:18])[0]
ip_offset = 18
if eth_type != 0x0800:
return
if len(raw) < ip_offset + 20:
return
ip_hdr = raw[ip_offset:]
ihl = (ip_hdr[0] & 0x0F) * 4
ip_proto = ip_hdr[9]
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
if ip_proto == 17: # UDP
udp_offset = ip_offset + ihl
if len(raw) < udp_offset + 8:
return
src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4])
payload = raw[udp_offset + 8:]
if dst_port == 67 or dst_port == 68:
self._parse_dhcp(ts, payload, src_mac)
elif dst_port == 5353 or src_port == 5353:
self._parse_mdns(ts, src_ip, src_mac, payload)
elif dst_port == 137 or src_port == 137:
self._parse_nbns(ts, src_ip, src_mac, payload)
elif dst_port == 1900:
self._parse_ssdp(ts, src_ip, src_mac, payload)
elif dst_port == 5355 or src_port == 5355:
self._parse_llmnr(ts, src_ip, src_mac, payload)
# Register any UDP source
self._update_host(ts, src_ip, mac=src_mac, source="traffic")
# ------------------------------------------------------------------
# Protocol parsers
# ------------------------------------------------------------------
def _parse_arp(self, ts: float, raw: bytes, eth_src_mac: str) -> None:
"""Parse ARP request/reply for IP-MAC mapping."""
if len(raw) < 42: # 14 eth + 28 ARP
return
arp_data = raw[14:]
# ARP: htype(2) ptype(2) hlen(1) plen(1) oper(2) sha(6) spa(4) tha(6) tpa(4)
htype, ptype, hlen, plen, oper = struct.unpack("!HHBBH", arp_data[:8])
if htype != 1 or ptype != 0x0800:
return
sender_mac = ":".join(f"{b:02x}" for b in arp_data[8:14])
sender_ip = socket.inet_ntoa(arp_data[14:18])
target_ip = socket.inet_ntoa(arp_data[24:28])
if sender_ip != "0.0.0.0":
self._update_host(ts, sender_ip, mac=sender_mac, source="arp")
def _parse_dhcp(self, ts: float, payload: bytes, src_mac: str) -> None:
"""Parse DHCP request/ACK for hostname, vendor class, option 55."""
if len(payload) < 240:
return
# DHCP: op(1) htype(1) hlen(1) hops(1) xid(4) secs(2) flags(2)
# ciaddr(4) yiaddr(4) siaddr(4) giaddr(4) chaddr(16) ...
op = payload[0]
yiaddr = socket.inet_ntoa(payload[16:20])
chaddr = ":".join(f"{b:02x}" for b in payload[28:34])
# Parse DHCP options (start at offset 240, after magic cookie)
if payload[236:240] != b"\x63\x82\x53\x63":
return
hostname = ""
vendor_class = ""
option_55 = ""
assigned_ip = yiaddr if yiaddr != "0.0.0.0" else ""
msg_type = 0
offset = 240
while offset < len(payload):
opt = payload[offset]
if opt == 255: # End
break
if opt == 0: # Pad
offset += 1
continue
if offset + 1 >= len(payload):
break
opt_len = payload[offset + 1]
offset += 2
if offset + opt_len > len(payload):
break
opt_data = payload[offset:offset + opt_len]
if opt == 53 and opt_len == 1: # Message Type
msg_type = opt_data[0]
elif opt == 12: # Hostname
hostname = opt_data.decode("ascii", errors="replace").rstrip("\x00")
elif opt == 60: # Vendor Class
vendor_class = opt_data.decode("ascii", errors="replace").rstrip("\x00")
elif opt == 55: # Parameter Request List
option_55 = ",".join(str(b) for b in opt_data)
elif opt == 50 and opt_len == 4: # Requested IP
if not assigned_ip:
assigned_ip = socket.inet_ntoa(opt_data)
offset += opt_len
# DHCP fingerprint lookup
os_guess = ""
if option_55:
os_guess = self._dhcp_fp_cache.get(option_55, "")
if assigned_ip:
self._update_host(
ts, assigned_ip, mac=chaddr, hostname=hostname,
vendor=vendor_class, os_guess=os_guess,
dhcp_fingerprint=option_55, source="dhcp",
)
elif chaddr:
# No IP yet, but we can log the MAC
pass
def _parse_mdns(self, ts: float, src_ip: str, src_mac: str,
payload: bytes) -> None:
"""Parse mDNS for hostname discovery."""
if len(payload) < 12:
return
# mDNS uses standard DNS format on port 5353
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
is_response = bool(flags & 0x8000)
offset = 12
# Parse questions
for _ in range(qdcount):
name, offset = self._read_dns_name(payload, offset)
if offset + 4 > len(payload):
return
offset += 4 # qtype + qclass
# Parse answers (for responses)
if is_response:
for _ in range(ancount):
if offset >= len(payload):
break
name, offset = self._read_dns_name(payload, offset)
if offset + 10 > len(payload):
break
rtype, _, ttl, rdlength = struct.unpack("!HHIH", payload[offset:offset + 10])
offset += 10
if offset + rdlength > len(payload):
break
# Extract hostname from .local names
if name and name.endswith(".local"):
hostname = name.rsplit(".local", 1)[0]
if hostname:
self._update_host(ts, src_ip, mac=src_mac,
hostname=hostname, source="mdns")
offset += rdlength
else:
# For queries, the querier's presence is noted
self._update_host(ts, src_ip, mac=src_mac, source="mdns")
def _parse_nbns(self, ts: float, src_ip: str, src_mac: str,
payload: bytes) -> None:
"""Parse NetBIOS Name Service queries/responses."""
if len(payload) < 12:
return
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
is_response = bool(flags & 0x8000)
offset = 12
# Parse question names
for _ in range(qdcount):
if offset >= len(payload):
break
nbname, offset = self._read_nbns_name(payload, offset)
if offset + 4 > len(payload):
break
offset += 4 # qtype + qclass
if nbname:
self._update_host(ts, src_ip, mac=src_mac,
hostname=nbname, source="nbns")
# Parse answer names
if is_response:
for _ in range(ancount):
if offset >= len(payload):
break
nbname, offset = self._read_nbns_name(payload, offset)
if offset + 10 > len(payload):
break
_, _, _, rdlength = struct.unpack("!HHIH", payload[offset:offset + 10])
offset += 10
if nbname:
self._update_host(ts, src_ip, mac=src_mac,
hostname=nbname, source="nbns")
if offset + rdlength > len(payload):
break
offset += rdlength
@staticmethod
def _read_nbns_name(data: bytes, offset: int) -> tuple:
"""Decode a NetBIOS encoded name. Returns (name, new_offset)."""
if offset >= len(data):
return "", offset
length = data[offset]
offset += 1
if length != 32:
# Skip non-standard length
return "", offset + length + 1 # +1 for trailing null length byte
if offset + 32 > len(data):
return "", offset
encoded = data[offset:offset + 32]
offset += 32
# Skip trailing length byte
if offset < len(data):
offset += 1
# Decode: each pair of bytes encodes one character
name_chars = []
for i in range(0, 32, 2):
ch = ((encoded[i] - ord('A')) << 4) | (encoded[i + 1] - ord('A'))
if 32 <= ch < 127:
name_chars.append(chr(ch))
name = "".join(name_chars).rstrip()
return name, offset
def _parse_ssdp(self, ts: float, src_ip: str, src_mac: str,
payload: bytes) -> None:
"""Parse SSDP/UPnP announcements for device info."""
try:
text = payload.decode("utf-8", errors="ignore")
except Exception:
return
# SSDP uses HTTP-like headers
server = ""
usn = ""
for line in text.split("\r\n"):
lower = line.lower()
if lower.startswith("server:"):
server = line.split(":", 1)[1].strip()
elif lower.startswith("usn:"):
usn = line.split(":", 1)[1].strip()
vendor = server if server else ""
hostname = ""
if usn:
# USN often contains device UUID
pass
self._update_host(ts, src_ip, mac=src_mac, vendor=vendor, source="ssdp")
def _parse_llmnr(self, ts: float, src_ip: str, src_mac: str,
payload: bytes) -> None:
"""Parse LLMNR queries for hostname discovery."""
if len(payload) < 12:
return
# LLMNR uses DNS message format
_, flags, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", payload[:12])
is_response = bool(flags & 0x8000)
offset = 12
for _ in range(qdcount):
name, offset = self._read_dns_name(payload, offset)
if offset + 4 > len(payload):
break
offset += 4
if name:
if is_response:
self._update_host(ts, src_ip, mac=src_mac,
hostname=name, source="llmnr")
else:
# Querier looking for this name
self._update_host(ts, src_ip, mac=src_mac, source="llmnr")
@staticmethod
def _read_dns_name(data: bytes, offset: int) -> tuple:
"""Read a DNS-format name. Returns (name, new_offset)."""
parts = []
jumped = False
saved_offset = offset
jumps = 0
while offset < len(data):
length = data[offset]
if length == 0:
offset += 1
break
if (length & 0xC0) == 0xC0:
if offset + 1 >= len(data):
break
ptr = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
if not jumped:
saved_offset = offset + 2
jumped = True
offset = ptr
jumps += 1
if jumps > 10:
break
continue
offset += 1
if offset + length > len(data):
break
parts.append(data[offset:offset + length].decode("utf-8", errors="replace"))
offset += length
name = ".".join(parts) if parts else ""
return (name, saved_offset if jumped else offset)
# ------------------------------------------------------------------
# Flush hosts to SQLite
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_hosts()
except Exception:
logger.exception("Host flush error")
def _flush_hosts(self) -> None:
"""Write all in-memory hosts to SQLite."""
with self._hosts_lock:
hosts_snapshot = list(self._hosts.values())
if not hosts_snapshot or not self._db_conn:
return
try:
for h in hosts_snapshot:
self._db_conn.execute(
"""INSERT INTO hosts (ip, mac, hostname, vendor, os_guess,
dhcp_fingerprint, first_seen, last_seen, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
mac = COALESCE(NULLIF(excluded.mac, ''), hosts.mac),
hostname = COALESCE(NULLIF(excluded.hostname, ''), hosts.hostname),
vendor = COALESCE(NULLIF(excluded.vendor, ''), hosts.vendor),
os_guess = COALESCE(NULLIF(excluded.os_guess, ''), hosts.os_guess),
dhcp_fingerprint = COALESCE(NULLIF(excluded.dhcp_fingerprint, ''), hosts.dhcp_fingerprint),
last_seen = excluded.last_seen,
source = excluded.source
""",
(h["ip"], h["mac"], h["hostname"], h["vendor"],
h["os_guess"], h["dhcp_fingerprint"],
h["first_seen"], h["last_seen"], h["source"]),
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d hosts", len(hosts_snapshot))