Initial public release
Full BigBrother network implant - passive SOC + active exploitation. Personal identifiers removed; all capabilities intact. See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user