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

402 lines
14 KiB
Python

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