#!/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 logger = logging.getLogger("bb.passive.db_interceptor") # 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 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="bb-dbi-read" ) self._read_thread.start() self._flush_thread = threading.Thread( target=self._flush_loop, daemon=True, name="bb-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("= 44: db_off = struct.unpack(" 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(" 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(" 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") self.bus.emit("CREDENTIAL_FOUND", { "source": "mysql_login", "username": username, "database": database, "source_ip": src_ip, "dest_ip": dst_ip, }, source_module=self.name) 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") self.bus.emit("CREDENTIAL_FOUND", { "source": "postgres_startup", "username": username, "database": database, "source_ip": src_ip, "dest_ip": dst_ip, }, source_module=self.name) # ------------------------------------------------------------------ # 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") self.bus.emit("CREDENTIAL_FOUND", { "source": "redis_auth", "username": username, "source_ip": src_ip, "dest_ip": dst_ip, }, source_module=self.name) 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("= 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("~"), ".bigbrother" )) 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")