ffd384f64b
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
402 lines
14 KiB
Python
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))
|