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
463 lines
17 KiB
Python
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")
|