Files
bigbrother/modules/passive/smb_monitor.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

397 lines
14 KiB
Python

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