#!/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("sensor.passive.quic_analyzer") # 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")