Add 9 advanced passive modules: VLAN, network mapper, auth tracker, SMB, cloud tokens, LDAP, RDP, QUIC, DB interceptor

Phase 2 batch 2: protocol-specific passive modules for deep network
visibility. Each module subscribes to capture_bus, parses protocol
structures with struct (no scapy), buffers SQLite writes, and publishes
bus events for cross-module correlation.

- vlan_discovery: 802.1Q/DTP/CDP/LLDP/STP/EAPOL layer-2 parsing
- network_mapper: communication graph with role classification and DOT output
- auth_flow_tracker: Kerberos/NTLM/SSH/LDAP auth correlation per user
- smb_monitor: SMB2/3 tree connect, file access, GPP/SYSVOL detection
- cloud_token_harvester: regex scan cleartext HTTP for AWS/JWT/OAuth/SAML/GCP tokens
- ldap_harvester: BER/ASN.1 LDAP parsing for AD object inventory and LAPS detection
- rdp_monitor: RDP cookie + CredSSP NTLM extraction, admin workstation detection
- quic_analyzer: QUIC Initial packet parsing with RFC 9001 key derivation for SNI
- db_interceptor: MSSQL TDS, MySQL, PostgreSQL, Redis RESP, MongoDB OP_MSG parsing
This commit is contained in:
n0mad1k
2026-03-18 13:31:18 -04:00
parent 1c85244e9c
commit ab27aa968b
10 changed files with 4113 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
#!/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("bb.passive.rdp_monitor")
# 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
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="bb-rdp-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="bb-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("~"), ".bigbrother"
))
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")