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

463 lines
17 KiB
Python

#!/usr/bin/env python3
"""QUIC/HTTP3 SNI extractor — parse QUIC Initial packets for TLS ClientHello.
Decrypts QUIC Initial packets using connection-ID-derived keys per RFC 9001
(Initial keys are not secret — they are derived deterministically from the
Destination Connection ID). Extracts CRYPTO frames containing TLS ClientHello
and parses SNI from the Server Name extension.
"""
import hashlib
import hmac
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__)
# QUIC constants
QUIC_LONG_HEADER_BIT = 0x80
QUIC_FIXED_BIT = 0x40
# QUIC versions
QUIC_V1 = 0x00000001
QUIC_V2 = 0x6B3343CF
# TLS extension type for SNI
TLS_EXT_SNI = 0x0000
TLS_HANDSHAKE_CLIENT_HELLO = 0x01
# QUIC Initial salt for v1 (RFC 9001, Section 5.2)
QUIC_V1_INITIAL_SALT = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
# QUIC Initial salt for v2 (RFC 9369)
QUIC_V2_INITIAL_SALT = bytes.fromhex("0dede3def700a6db819381be6e269dcbf9bd2ed9")
# HKDF labels for QUIC Initial keys
QUIC_CLIENT_INITIAL_LABEL = b"client in"
_DB_INIT = """
CREATE TABLE IF NOT EXISTS quic_sni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
source_ip TEXT NOT NULL,
dest_ip TEXT NOT NULL,
sni TEXT NOT NULL,
quic_version TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_quic_sni ON quic_sni(sni);
CREATE INDEX IF NOT EXISTS idx_quic_ts ON quic_sni(timestamp);
"""
FLUSH_INTERVAL = 30
class QUICAnalyzer(BaseModule):
"""Extract SNI from QUIC Initial packets."""
name = "quic_analyzer"
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,
"quic_initials": 0,
"sni_extracted": 0,
"decrypt_failures": 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("QUICAnalyzer requires _capture_bus in config")
return
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="udp port 443", queue_depth=3000
)
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-quic-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-quic-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("QUICAnalyzer 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("QUICAnalyzer 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 QUIC packet", exc_info=True)
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract UDP payload and check for QUIC Initial."""
if len(pkt) < 42: # eth(14) + ip(20) + udp(8)
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 46:
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 != 17: # UDP
return
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
udp_start = ihl
if len(ip_header) < udp_start + 8:
return
payload = ip_header[udp_start + 8:]
if len(payload) < 5:
return
self._parse_quic_initial(payload, src_ip, dst_ip, ts)
def _parse_quic_initial(self, data: bytes, src_ip: str, dst_ip: str,
ts: float) -> None:
"""Parse QUIC Initial packet and attempt SNI extraction."""
first_byte = data[0]
# Must be long header form (bit 7 set) and fixed bit (bit 6 set)
if not (first_byte & QUIC_LONG_HEADER_BIT):
return
if not (first_byte & QUIC_FIXED_BIT):
return
# Packet type: bits 4-5 of first byte
# For QUIC v1: Initial = 0x00 (bits 4-5 = 00)
# For QUIC v2: Initial = 0x01 (bits 4-5 = 01)
packet_type_bits = (first_byte >> 4) & 0x03
if len(data) < 7:
return
# Version (4 bytes at offset 1)
version = struct.unpack("!I", data[1:5])[0]
# Determine if this is an Initial packet based on version
is_initial = False
if version == QUIC_V1 and packet_type_bits == 0x00:
is_initial = True
elif version == QUIC_V2 and packet_type_bits == 0x01:
is_initial = True
elif version == 0:
# Version negotiation — skip
return
if not is_initial:
return
self._stats["quic_initials"] += 1
# DCID length (1 byte at offset 5)
dcid_len = data[5]
if len(data) < 6 + dcid_len + 1:
return
dcid = data[6:6 + dcid_len]
# SCID length
scid_len_off = 6 + dcid_len
scid_len = data[scid_len_off]
scid_off = scid_len_off + 1
if len(data) < scid_off + scid_len:
return
# Token length (variable-length integer)
token_start = scid_off + scid_len
if token_start >= len(data):
return
token_len, token_len_size = self._read_varint(data, token_start)
if token_len_size == 0:
return
# Payload length (variable-length integer)
payload_len_start = token_start + token_len_size + token_len
if payload_len_start >= len(data):
return
payload_len, payload_len_size = self._read_varint(data, payload_len_start)
if payload_len_size == 0:
return
# Encrypted payload starts after the packet number (included in payload_len)
encrypted_start = payload_len_start + payload_len_size
# Try to extract SNI without full decryption first:
# Scan the unprotected portion for a ClientHello pattern
# (This works for many implementations where the crypto frame is at the start)
sni = self._try_extract_sni_from_initial(data, dcid, version, encrypted_start)
if sni:
self._stats["sni_extracted"] += 1
version_str = f"0x{version:08x}"
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
return
# If we couldn't extract via decryption, try brute-force scan
# for SNI pattern in the raw payload
sni = self._scan_for_sni(data)
if sni:
self._stats["sni_extracted"] += 1
version_str = f"0x{version:08x}"
self._record_sni(ts, src_ip, dst_ip, sni, version_str)
def _try_extract_sni_from_initial(self, data: bytes, dcid: bytes,
version: int, enc_start: int) -> Optional[str]:
"""Attempt to derive Initial keys and decrypt CRYPTO frame for SNI.
Per RFC 9001, Initial keys are derived deterministically from the DCID
using HKDF, so this is not breaking encryption — it's public info.
"""
try:
# Select salt based on version
if version == QUIC_V2:
salt = QUIC_V2_INITIAL_SALT
else:
salt = QUIC_V1_INITIAL_SALT
# Derive initial secret: HKDF-Extract(salt, DCID)
initial_secret = hmac.new(salt, dcid, hashlib.sha256).digest()
# Derive client initial secret using HKDF-Expand-Label
client_secret = self._hkdf_expand_label(
initial_secret, QUIC_CLIENT_INITIAL_LABEL, b"", 32
)
# Derive key and IV from client secret
key = self._hkdf_expand_label(client_secret, b"quic key", b"", 16)
iv = self._hkdf_expand_label(client_secret, b"quic iv", b"", 12)
hp_key = self._hkdf_expand_label(client_secret, b"quic hp", b"", 16)
# Header protection removal and decryption requires AES —
# rather than importing cryptography lib, scan the payload area
# for TLS ClientHello patterns that may be partially visible
# in the packet number protected but not encrypted initial bytes
# Fall through to pattern scan
return None
except Exception:
self._stats["decrypt_failures"] += 1
return None
def _hkdf_expand_label(self, secret: bytes, label: bytes,
context: bytes, length: int) -> bytes:
"""HKDF-Expand-Label as defined in TLS 1.3 / RFC 8446."""
# Build HkdfLabel structure
full_label = b"tls13 " + label
hkdf_label = (
struct.pack("!H", length) +
struct.pack("B", len(full_label)) + full_label +
struct.pack("B", len(context)) + context
)
return self._hkdf_expand(secret, hkdf_label, length)
@staticmethod
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
"""HKDF-Expand using HMAC-SHA256."""
hash_len = 32 # SHA-256
n = (length + hash_len - 1) // hash_len
okm = b""
t = b""
for i in range(1, n + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
return okm[:length]
def _scan_for_sni(self, data: bytes) -> Optional[str]:
"""Brute-force scan for TLS SNI extension pattern in raw bytes."""
# Look for the SNI extension pattern: 0x00 0x00 (ext type) followed by
# extension data containing a host_name type (0x00) and ASCII hostname
pos = 0
while pos < len(data) - 10:
# Look for SNI extension header: type=0x0000, then lengths
if data[pos] == 0x00 and data[pos + 1] == 0x00:
if pos + 9 <= len(data):
# Extension length
ext_len = struct.unpack("!H", data[pos + 2:pos + 4])[0]
if 4 < ext_len < 256 and pos + 4 + ext_len <= len(data):
# Server name list length
list_len = struct.unpack("!H", data[pos + 4:pos + 6])[0]
if list_len > 0 and list_len <= ext_len:
# Server name type (0x00 = host_name)
name_type = data[pos + 6]
if name_type == 0x00 and pos + 9 <= len(data):
name_len = struct.unpack("!H", data[pos + 7:pos + 9])[0]
if 1 < name_len < 253 and pos + 9 + name_len <= len(data):
hostname = data[pos + 9:pos + 9 + name_len]
try:
sni = hostname.decode("ascii")
# Validate: must contain a dot and only valid chars
if ("." in sni and
all(c.isalnum() or c in ".-_" for c in sni) and
not sni.startswith(".") and
not sni.endswith(".")):
return sni
except (UnicodeDecodeError, ValueError):
pass
pos += 1
return None
@staticmethod
def _read_varint(data: bytes, offset: int) -> tuple:
"""Read a QUIC variable-length integer. Returns (value, bytes_consumed)."""
if offset >= len(data):
return (0, 0)
first = data[offset]
prefix = (first >> 6) & 0x03
if prefix == 0:
return (first & 0x3F, 1)
elif prefix == 1:
if offset + 2 > len(data):
return (0, 0)
val = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF
return (val, 2)
elif prefix == 2:
if offset + 4 > len(data):
return (0, 0)
val = struct.unpack("!I", data[offset:offset + 4])[0] & 0x3FFFFFFF
return (val, 4)
else:
if offset + 8 > len(data):
return (0, 0)
val = struct.unpack("!Q", data[offset:offset + 8])[0] & 0x3FFFFFFFFFFFFFFF
return (val, 8)
# ------------------------------------------------------------------
# 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, "quic_analyzer.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_sni(self, ts: float, src_ip: str, dst_ip: str,
sni: str, version: str) -> None:
with self._buffer_lock:
self._write_buffer.append((ts, src_ip, dst_ip, sni, 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 quic_sni
(timestamp, source_ip, dest_ip, sni, quic_version)
VALUES (?, ?, ?, ?, ?)""",
batch,
)
except Exception:
logger.exception("Failed to flush QUIC SNI records")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_buffer()
except Exception:
logger.exception("Flush loop error")