764 lines
26 KiB
Python
764 lines
26 KiB
Python
"""Build a normalized capture context with tshark through pyshark.
|
|
|
|
pyshark is loaded lazily, and individual packet handlers are isolated so one
|
|
unexpected packet cannot abort the complete analysis.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
from typing import Optional
|
|
|
|
from .context import (
|
|
CaptureContext,
|
|
Conversation,
|
|
DnsQuery,
|
|
HttpRequest,
|
|
HttpResponse,
|
|
TlsSession,
|
|
ArpEvent,
|
|
PlaintextAuth,
|
|
AuthMaterial,
|
|
FileTransfer,
|
|
)
|
|
from . import crackformats
|
|
|
|
# Known clear-text protocols used by extraction heuristics.
|
|
CLEARTEXT_PORTS = {
|
|
21: "ftp",
|
|
23: "telnet",
|
|
25: "smtp",
|
|
80: "http",
|
|
110: "pop3",
|
|
143: "imap",
|
|
389: "ldap",
|
|
161: "snmp",
|
|
69: "tftp",
|
|
}
|
|
|
|
|
|
def build_context_from_pcap(path: str, packet_limit: Optional[int] = None) -> CaptureContext:
|
|
"""Read a capture and return its normalized context."""
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"Capture not found: {path}")
|
|
|
|
try:
|
|
import pyshark # lazy import
|
|
except ImportError as exc: # pragma: no cover
|
|
raise RuntimeError(
|
|
"pyshark or tshark is unavailable. Install tshark or use the backend container."
|
|
) from exc
|
|
|
|
ctx = CaptureContext(path=path)
|
|
cap = pyshark.FileCapture(path, keep_packets=False)
|
|
|
|
conv_index: dict[tuple, Conversation] = {}
|
|
# Transient state correlates NTLM and SMTP authentication across packets.
|
|
state: dict = {"ntlm_chal": {}, "smtp_auth": {}}
|
|
n = 0
|
|
try:
|
|
for pkt in cap:
|
|
n += 1
|
|
if packet_limit and n > packet_limit:
|
|
break
|
|
try:
|
|
_handle_packet(ctx, pkt, conv_index, state)
|
|
except Exception:
|
|
# Skip isolated malformed or unexpected packets.
|
|
continue
|
|
except Exception as exc:
|
|
if exc.__class__.__name__ == "TSharkNotFoundException":
|
|
raise RuntimeError(
|
|
"tshark was not found. Install Wireshark/tshark or use the backend container."
|
|
) from exc
|
|
raise
|
|
finally:
|
|
try:
|
|
cap.close()
|
|
except Exception:
|
|
pass
|
|
|
|
ctx.packet_count = n
|
|
ctx.conversations = list(conv_index.values())
|
|
return ctx
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Packet handlers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _ts(pkt) -> Optional[float]:
|
|
try:
|
|
return float(pkt.sniff_timestamp)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _num(pkt) -> Optional[int]:
|
|
try:
|
|
return int(pkt.number)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _handle_packet(ctx: CaptureContext, pkt, conv_index: dict, state: Optional[dict] = None) -> None:
|
|
if state is None:
|
|
state = {"ntlm_chal": {}, "smtp_auth": {}}
|
|
ts = _ts(pkt)
|
|
if ts is not None:
|
|
ctx.start_time = ts if ctx.start_time is None else min(ctx.start_time, ts)
|
|
ctx.end_time = ts if ctx.end_time is None else max(ctx.end_time, ts)
|
|
|
|
# Count packets by the highest decoded protocol layer.
|
|
try:
|
|
top = pkt.highest_layer
|
|
ctx.protocol_stats[top] = ctx.protocol_stats.get(top, 0) + 1
|
|
except Exception:
|
|
pass
|
|
|
|
layers = {l.layer_name for l in pkt.layers}
|
|
|
|
src_ip = dst_ip = None
|
|
length = 0
|
|
try:
|
|
length = int(pkt.length)
|
|
except Exception:
|
|
pass
|
|
|
|
if "ip" in layers:
|
|
try:
|
|
src_ip = pkt.ip.src
|
|
dst_ip = pkt.ip.dst
|
|
except Exception:
|
|
pass
|
|
elif "ipv6" in layers:
|
|
try:
|
|
src_ip = pkt.ipv6.src
|
|
dst_ip = pkt.ipv6.dst
|
|
except Exception:
|
|
pass
|
|
|
|
# MAC-Adressen / Hosts
|
|
src_mac = dst_mac = None
|
|
if "eth" in layers:
|
|
try:
|
|
src_mac, dst_mac = pkt.eth.src, pkt.eth.dst
|
|
except Exception:
|
|
pass
|
|
|
|
src_port = dst_port = None
|
|
transport = None
|
|
if "tcp" in layers:
|
|
transport = "tcp"
|
|
try:
|
|
src_port = int(pkt.tcp.srcport)
|
|
dst_port = int(pkt.tcp.dstport)
|
|
except Exception:
|
|
pass
|
|
elif "udp" in layers:
|
|
transport = "udp"
|
|
try:
|
|
src_port = int(pkt.udp.srcport)
|
|
dst_port = int(pkt.udp.dstport)
|
|
except Exception:
|
|
pass
|
|
|
|
if src_ip:
|
|
h = ctx.host(src_ip)
|
|
h.bytes_sent += length
|
|
if src_mac:
|
|
h.macs.add(src_mac)
|
|
if dst_port:
|
|
h.ports_contacted.add(dst_port)
|
|
if dst_ip:
|
|
h = ctx.host(dst_ip)
|
|
h.bytes_received += length
|
|
if dst_port:
|
|
h.ports_offered.add(dst_port)
|
|
|
|
# Conversation aggregieren
|
|
if src_ip and dst_ip and transport:
|
|
key = tuple(sorted([(src_ip, src_port), (dst_ip, dst_port)]) + [transport])
|
|
conv = conv_index.get(key)
|
|
if conv is None:
|
|
conv = Conversation(
|
|
src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port,
|
|
protocol=transport, first_seen=ts, last_seen=ts,
|
|
)
|
|
try:
|
|
conv.stream_id = int(pkt.tcp.stream) if transport == "tcp" else None
|
|
except Exception:
|
|
conv.stream_id = None
|
|
conv_index[key] = conv
|
|
conv.packets += 1
|
|
conv.bytes += length
|
|
pnum = _num(pkt)
|
|
if pnum is not None and len(conv.packet_numbers) < 20:
|
|
conv.packet_numbers.append(pnum)
|
|
if ts is not None:
|
|
conv.timestamps.append(ts)
|
|
conv.last_seen = ts if conv.last_seen is None else max(conv.last_seen, ts)
|
|
conv.first_seen = ts if conv.first_seen is None else min(conv.first_seen, ts)
|
|
if src_ip == conv.src_ip:
|
|
conv.bytes_src_to_dst += length
|
|
else:
|
|
conv.bytes_dst_to_src += length
|
|
|
|
# TCP stream IDs support cross-packet authentication correlation.
|
|
stream_id = None
|
|
if transport == "tcp":
|
|
try:
|
|
stream_id = int(pkt.tcp.stream)
|
|
except Exception:
|
|
stream_id = None
|
|
|
|
# Protocol-specific extraction.
|
|
if "arp" in layers:
|
|
_handle_arp(ctx, pkt, ts)
|
|
if "dns" in layers:
|
|
_handle_dns(ctx, pkt, src_ip, dst_ip, ts)
|
|
if "http" in layers:
|
|
_handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "tls" in layers or "ssl" in layers:
|
|
_handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "ftp" in layers:
|
|
_handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "telnet" in layers:
|
|
_handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "pop" in layers:
|
|
_handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "imap" in layers:
|
|
_handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts)
|
|
if "smtp" in layers:
|
|
_handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state)
|
|
if "ntlmssp" in layers:
|
|
_handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state)
|
|
if "kerberos" in layers:
|
|
_handle_kerberos(ctx, pkt, src_ip, dst_ip, ts)
|
|
|
|
# Generic payload extraction finds artifacts that protocol fields may omit.
|
|
_handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts)
|
|
|
|
|
|
def _handle_arp(ctx, pkt, ts):
|
|
try:
|
|
a = pkt.arp
|
|
ctx.arp_events.append(ArpEvent(
|
|
sender_ip=getattr(a, "src_proto_ipv4", None),
|
|
sender_mac=getattr(a, "src_hw_mac", None),
|
|
target_ip=getattr(a, "dst_proto_ipv4", None),
|
|
opcode=int(getattr(a, "opcode", 0) or 0),
|
|
timestamp=ts,
|
|
packet_number=_num(pkt),
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_dns(ctx, pkt, src_ip, dst_ip, ts):
|
|
try:
|
|
d = pkt.dns
|
|
# Ignore DNS records without a query name.
|
|
qname = getattr(d, "qry_name", None)
|
|
if not qname:
|
|
return
|
|
rcode = getattr(d, "flags_rcode", None)
|
|
rcode_map = {"0": "NOERROR", "3": "NXDOMAIN"}
|
|
response_ips = []
|
|
for attr in ("a", "aaaa"):
|
|
response_ips.extend(_all_field_values(d, attr))
|
|
ctx.dns_queries.append(DnsQuery(
|
|
query_name=qname,
|
|
qtype=_dns_qtype_name(getattr(d, "qry_type", None)),
|
|
response_ips=response_ips,
|
|
rcode=rcode_map.get(str(rcode), rcode),
|
|
timestamp=ts,
|
|
src_ip=src_ip,
|
|
dst_ip=dst_ip,
|
|
packet_number=_num(pkt),
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_http(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
try:
|
|
h = pkt.http
|
|
if hasattr(h, "request_method") or hasattr(h, "request_uri"):
|
|
req = HttpRequest(
|
|
method=getattr(h, "request_method", None),
|
|
host=getattr(h, "host", None),
|
|
uri=getattr(h, "request_uri", None),
|
|
user_agent=getattr(h, "user_agent", None),
|
|
authorization=getattr(h, "authorization", None),
|
|
cookies=getattr(h, "cookie", None),
|
|
body=getattr(h, "file_data", None),
|
|
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port, timestamp=ts,
|
|
packet_number=_num(pkt),
|
|
)
|
|
ctx.http_requests.append(req)
|
|
# Preserve visible HTTP text for artifact detection.
|
|
for piece in (req.uri, req.cookies, req.authorization, req.body):
|
|
if piece:
|
|
ctx.text_payloads.append({
|
|
"text": str(piece), "protocol": "http",
|
|
"src_ip": src_ip, "dst_ip": dst_ip,
|
|
"packet_number": _num(pkt), "timestamp": ts,
|
|
})
|
|
if hasattr(h, "response_code"):
|
|
ct = getattr(h, "content_type", None)
|
|
ctx.http_responses.append(HttpResponse(
|
|
status_code=_safe_int(getattr(h, "response_code", None)),
|
|
content_type=ct,
|
|
server=getattr(h, "server", None),
|
|
src_ip=src_ip, dst_ip=dst_ip, timestamp=ts, packet_number=_num(pkt),
|
|
))
|
|
# Record only responses that look like file downloads.
|
|
disposition = getattr(h, "content_disposition", None)
|
|
filename = _filename_from_disposition(disposition)
|
|
if filename or _is_download_content_type(ct):
|
|
ext = os.path.splitext(filename or "")[1].lower() or None
|
|
ctx.file_transfers.append(FileTransfer(
|
|
filename=filename, extension=ext, content_type=ct,
|
|
protocol="http", src_ip=src_ip, dst_ip=dst_ip,
|
|
direction="download", timestamp=ts, packet_number=_num(pkt),
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_tls(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
try:
|
|
layer = getattr(pkt, "tls", None) or getattr(pkt, "ssl", None)
|
|
if layer is None:
|
|
return
|
|
|
|
# TLS 1.3 reports its real version in the supported-versions extension.
|
|
version = (
|
|
getattr(layer, "handshake_extensions_supported_version", None)
|
|
or getattr(layer, "handshake_version", None)
|
|
or getattr(layer, "record_version", None)
|
|
)
|
|
|
|
subject_cn = _first(
|
|
getattr(layer, "x509sat_printablestring", None),
|
|
getattr(layer, "x509ce_dnsname", None),
|
|
)
|
|
issuer_cn = getattr(layer, "x509sat_printablestring_1", None)
|
|
not_after = (
|
|
getattr(layer, "x509af_utctime", None)
|
|
or getattr(layer, "x509af_notafter", None)
|
|
or getattr(layer, "x509af_utctime_1", None)
|
|
)
|
|
|
|
ctx.tls_sessions.append(TlsSession(
|
|
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port or 443,
|
|
version=_tls_version_name(version),
|
|
cipher=getattr(layer, "handshake_ciphersuite", None),
|
|
sni=getattr(layer, "handshake_extensions_server_name", None),
|
|
cert_subject_cn=subject_cn,
|
|
cert_issuer_cn=issuer_cn,
|
|
cert_not_after=not_after,
|
|
cert_self_signed=_cert_is_self_signed(subject_cn, issuer_cn),
|
|
cert_expired=_cert_is_expired(not_after, ctx.end_time or ts),
|
|
timestamp=ts, packet_number=_num(pkt),
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_ftp(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
try:
|
|
f = pkt.ftp
|
|
cmd = getattr(f, "request_command", None)
|
|
arg = getattr(f, "request_arg", None)
|
|
if cmd and cmd.upper() in ("USER", "PASS"):
|
|
pa = _last_plaintext(ctx, "ftp", src_ip, dst_ip, dst_port)
|
|
if cmd.upper() == "USER":
|
|
pa.username = arg
|
|
else:
|
|
pa.password = arg
|
|
pa.timestamp = ts
|
|
pa.packet_number = _num(pkt)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_telnet(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
try:
|
|
data = getattr(pkt.telnet, "data", None)
|
|
if data:
|
|
ctx.text_payloads.append({
|
|
"text": str(data), "protocol": "telnet",
|
|
"src_ip": src_ip, "dst_ip": dst_ip,
|
|
"packet_number": _num(pkt), "timestamp": ts,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_pop3(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
"""POP3 USER/PASS im Klartext."""
|
|
try:
|
|
p = pkt.pop
|
|
cmd = (getattr(p, "request_command", None) or "").upper()
|
|
param = getattr(p, "request_parameter", None)
|
|
if cmd in ("USER", "PASS") and param:
|
|
pa = _last_plaintext(ctx, "pop3", src_ip, dst_ip, dst_port)
|
|
if cmd == "USER":
|
|
pa.username = param
|
|
else:
|
|
pa.password = param
|
|
pa.timestamp = ts
|
|
pa.packet_number = _num(pkt)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_imap(ctx, pkt, src_ip, dst_ip, dst_port, ts):
|
|
"""Extract clear-text IMAP LOGIN credentials."""
|
|
try:
|
|
i = pkt.imap
|
|
line = getattr(i, "request", None) or getattr(i, "request_command", None)
|
|
if not line:
|
|
return
|
|
parts = str(line).split()
|
|
# Accept both tagged and untagged IMAP LOGIN forms.
|
|
idx = next((k for k, tok in enumerate(parts) if tok.upper() == "LOGIN"), None)
|
|
if idx is not None and len(parts) >= idx + 3:
|
|
user = parts[idx + 1].strip('"')
|
|
pw = parts[idx + 2].strip('"')
|
|
pa = _last_plaintext(ctx, "imap", src_ip, dst_ip, dst_port)
|
|
pa.username, pa.password = user, pw
|
|
pa.timestamp = ts
|
|
pa.packet_number = _num(pkt)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _handle_smtp(ctx, pkt, src_ip, dst_ip, dst_port, ts, stream_id, state):
|
|
"""Extract Base64-encoded SMTP AUTH LOGIN or PLAIN credentials."""
|
|
try:
|
|
s = pkt.smtp
|
|
param = getattr(s, "req_parameter", None) or getattr(s, "request_parameter", None)
|
|
cmd = (getattr(s, "req_command", None) or getattr(s, "request_command", None) or "").upper()
|
|
if not param and not cmd:
|
|
return
|
|
pending = state["smtp_auth"].setdefault(stream_id, {"mech": None, "tokens": []})
|
|
|
|
text = f"{cmd} {param}".strip()
|
|
upper = text.upper()
|
|
if "AUTH LOGIN" in upper:
|
|
pending["mech"] = "login"
|
|
return
|
|
if "AUTH PLAIN" in upper:
|
|
pending["mech"] = "plain"
|
|
# AUTH PLAIN may carry its token on the command line.
|
|
tail = text.split()
|
|
if len(tail) >= 3:
|
|
creds = crackformats.decode_smtp_auth_plain(tail[2])
|
|
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
|
|
state["smtp_auth"].pop(stream_id, None)
|
|
return
|
|
|
|
# AUTH LOGIN sends username and password in subsequent lines.
|
|
if pending["mech"] == "login" and param:
|
|
pending["tokens"].append(param)
|
|
if len(pending["tokens"]) >= 2:
|
|
user, pw = crackformats.decode_smtp_auth_login(
|
|
pending["tokens"][0], pending["tokens"][1])
|
|
_emit_smtp(ctx, (user, pw), src_ip, dst_ip, dst_port, ts, pkt)
|
|
state["smtp_auth"].pop(stream_id, None)
|
|
elif pending["mech"] == "plain" and param:
|
|
creds = crackformats.decode_smtp_auth_plain(param)
|
|
_emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt)
|
|
state["smtp_auth"].pop(stream_id, None)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _emit_smtp(ctx, creds, src_ip, dst_ip, dst_port, ts, pkt):
|
|
if not creds:
|
|
return
|
|
user, pw = creds
|
|
if not (user or pw):
|
|
return
|
|
ctx.plaintext_auth.append(PlaintextAuth(
|
|
protocol="smtp", username=user, password=pw,
|
|
src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port,
|
|
timestamp=ts, packet_number=_num(pkt),
|
|
))
|
|
|
|
|
|
def _handle_ntlm(ctx, pkt, src_ip, dst_ip, ts, stream_id, state):
|
|
"""Correlate NTLM challenge and response packets into hashcat material."""
|
|
try:
|
|
n = pkt.ntlmssp
|
|
msg_type = str(getattr(n, "messagetype", "") or "").lower()
|
|
|
|
# Retain the type-2 server challenge for the matching type-3 response.
|
|
server_chal = getattr(n, "ntlmserverchallenge", None)
|
|
if server_chal and ("0x00000002" in msg_type or msg_type.endswith("2")):
|
|
state["ntlm_chal"][stream_id] = str(server_chal)
|
|
|
|
# A type-3 message provides the account and NT response.
|
|
account = getattr(n, "auth_username", None)
|
|
domain = getattr(n, "auth_domain", None)
|
|
nt_resp = (getattr(n, "ntlmv2_response", None)
|
|
or getattr(n, "auth_ntresponse", None))
|
|
|
|
if account or nt_resp:
|
|
chal = state["ntlm_chal"].get(stream_id)
|
|
am = AuthMaterial(
|
|
kind="ntlmssp", account=account, domain=domain,
|
|
src_ip=src_ip, dst_ip=dst_ip, protocol="ntlm",
|
|
timestamp=ts, packet_number=_num(pkt),
|
|
server_challenge=chal, nt_response=str(nt_resp) if nt_resp else None,
|
|
)
|
|
built = crackformats.build_netntlmv2(account, domain, chal, nt_resp)
|
|
if built:
|
|
am.kind = "netntlmv2"
|
|
am.hashcat = built["hashcat"]
|
|
am.hashcat_mode = built["hashcat_mode"]
|
|
am.hash_format = built["format"]
|
|
ctx.auth_material.append(am)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Bound retained payload text for large captures.
|
|
_MAX_TEXT_PAYLOADS = 20000
|
|
|
|
|
|
def _handle_raw_payload(ctx, pkt, layers, src_ip, dst_ip, ts):
|
|
"""Retain bounded TCP or UDP text for generic artifact detection."""
|
|
if len(ctx.text_payloads) >= _MAX_TEXT_PAYLOADS:
|
|
return
|
|
raw = None
|
|
try:
|
|
if "tcp" in layers and hasattr(pkt.tcp, "payload"):
|
|
raw = pkt.tcp.payload
|
|
elif "udp" in layers and hasattr(pkt.udp, "payload"):
|
|
raw = pkt.udp.payload
|
|
elif "data" in layers:
|
|
raw = getattr(pkt.data, "data", None)
|
|
except Exception:
|
|
raw = None
|
|
if not raw:
|
|
return
|
|
|
|
text = _bytes_from_hexish(str(raw))
|
|
if not text or not text.strip():
|
|
return
|
|
try:
|
|
proto = (pkt.highest_layer or "raw").lower()
|
|
except Exception:
|
|
proto = "raw"
|
|
ctx.text_payloads.append({
|
|
"text": text[:16384], "protocol": proto,
|
|
"src_ip": src_ip, "dst_ip": dst_ip,
|
|
"packet_number": _num(pkt), "timestamp": ts,
|
|
})
|
|
|
|
|
|
def _bytes_from_hexish(raw: str) -> Optional[str]:
|
|
"""Convert a hex-formatted pyshark payload to text when possible."""
|
|
s = str(raw).strip()
|
|
compact = s.replace(":", "").replace(".", "").replace(" ", "")
|
|
if compact and len(compact) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in compact):
|
|
try:
|
|
return bytes.fromhex(compact).decode("utf-8", "ignore")
|
|
except Exception:
|
|
return None
|
|
# Some dissectors already expose decoded text.
|
|
return s
|
|
|
|
|
|
def _handle_kerberos(ctx, pkt, src_ip, dst_ip, ts):
|
|
"""Extract Kerberos AS-REP and TGS-REP material for offline analysis."""
|
|
try:
|
|
k = pkt.kerberos
|
|
msg_type = str(getattr(k, "msg_type", "") or "")
|
|
account = getattr(k, "cnamestring", None) or getattr(k, "cnamestring_1", None)
|
|
realm = getattr(k, "realm", None) or getattr(k, "crealm", None)
|
|
etype = getattr(k, "etype", None)
|
|
cipher = getattr(k, "cipher", None) or getattr(k, "enc_part_cipher", None)
|
|
|
|
am = AuthMaterial(
|
|
kind=f"kerberos_{msg_type}" if msg_type else "kerberos",
|
|
account=account, domain=realm,
|
|
src_ip=src_ip, dst_ip=dst_ip, protocol="kerberos",
|
|
timestamp=ts, packet_number=_num(pkt),
|
|
etype=str(etype) if etype is not None else None,
|
|
)
|
|
# Kerberos message types 11 and 13 are AS-REP and TGS-REP.
|
|
built = None
|
|
if msg_type == "11":
|
|
built = crackformats.build_krb5_asrep(account, realm, etype, cipher)
|
|
elif msg_type == "13":
|
|
spn = getattr(k, "snamestring", None)
|
|
built = crackformats.build_krb5_tgsrep(account, realm, spn, etype, cipher)
|
|
if built:
|
|
am.hashcat = built["hashcat"]
|
|
am.hashcat_mode = built["hashcat_mode"]
|
|
am.hash_format = built["format"]
|
|
ctx.auth_material.append(am)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# kleine Helfer
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _last_plaintext(ctx, protocol, src_ip, dst_ip, dst_port) -> PlaintextAuth:
|
|
"""Find or create the authentication record that combines USER and PASS."""
|
|
for pa in reversed(ctx.plaintext_auth):
|
|
if pa.protocol == protocol and pa.src_ip == src_ip and pa.dst_ip == dst_ip:
|
|
if pa.password is None or pa.username is None:
|
|
return pa
|
|
pa = PlaintextAuth(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, dst_port=dst_port)
|
|
ctx.plaintext_auth.append(pa)
|
|
return pa
|
|
|
|
|
|
def _safe_int(v):
|
|
try:
|
|
return int(v)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _tls_version_name(raw) -> Optional[str]:
|
|
mapping = {
|
|
"0x0300": "SSL 3.0", "0x0301": "TLS 1.0", "0x0302": "TLS 1.1",
|
|
"0x0303": "TLS 1.2", "0x0304": "TLS 1.3",
|
|
"768": "SSL 3.0", "769": "TLS 1.0", "770": "TLS 1.1",
|
|
"771": "TLS 1.2", "772": "TLS 1.3",
|
|
}
|
|
if raw is None:
|
|
return None
|
|
return mapping.get(str(raw).lower(), str(raw))
|
|
|
|
|
|
def _first(*vals):
|
|
"""Return the first non-empty dissector field value."""
|
|
for v in vals:
|
|
if v:
|
|
return v
|
|
return None
|
|
|
|
|
|
def _all_field_values(layer, name) -> list[str]:
|
|
"""Return every occurrence of a repeated pyshark field."""
|
|
out: list[str] = []
|
|
try:
|
|
field = getattr(layer, name, None)
|
|
if field is None:
|
|
return out
|
|
allf = getattr(field, "all_fields", None)
|
|
if allf:
|
|
for f in allf:
|
|
v = getattr(f, "show", None) or getattr(f, "showname_value", None)
|
|
if v:
|
|
out.append(str(v))
|
|
else:
|
|
out.append(str(field))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
# tshark often exposes DNS query types as numeric values.
|
|
_DNS_QTYPES = {
|
|
"1": "A", "2": "NS", "5": "CNAME", "6": "SOA", "12": "PTR", "15": "MX",
|
|
"16": "TXT", "28": "AAAA", "33": "SRV", "255": "ANY", "10": "NULL",
|
|
"43": "DS", "48": "DNSKEY", "257": "CAA", "65": "HTTPS", "64": "SVCB",
|
|
}
|
|
|
|
|
|
def _dns_qtype_name(raw) -> Optional[str]:
|
|
if raw is None:
|
|
return None
|
|
s = str(raw).strip()
|
|
return _DNS_QTYPES.get(s, s.upper())
|
|
|
|
|
|
# Content types that indicate a file rather than ordinary web content.
|
|
_DOWNLOAD_CT_MARKERS = (
|
|
"application/octet-stream", "application/x-msdownload", "application/x-dosexec",
|
|
"application/x-executable", "application/vnd.microsoft.portable-executable",
|
|
"application/zip", "application/x-rar", "application/x-7z", "application/x-tar",
|
|
"application/gzip", "application/x-msi", "application/x-sh",
|
|
"application/java-archive", "application/x-elf",
|
|
)
|
|
|
|
|
|
def _is_download_content_type(ct) -> bool:
|
|
if not ct:
|
|
return False
|
|
low = str(ct).lower()
|
|
return any(m in low for m in _DOWNLOAD_CT_MARKERS)
|
|
|
|
|
|
def _filename_from_disposition(disposition) -> Optional[str]:
|
|
"""Extract a filename from a Content-Disposition header."""
|
|
if not disposition:
|
|
return None
|
|
import re as _re
|
|
m = _re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))', str(disposition), _re.IGNORECASE)
|
|
if not m:
|
|
return None
|
|
name = (m.group(1) or m.group(2) or "").strip().strip('"')
|
|
# Remove an optional RFC 5987 character-set prefix.
|
|
if "''" in name:
|
|
name = name.split("''", 1)[1]
|
|
return name or None
|
|
|
|
|
|
def _cert_is_self_signed(subject_cn, issuer_cn) -> Optional[bool]:
|
|
"""Return whether subject and issuer match, or None if data is incomplete."""
|
|
if not subject_cn or not issuer_cn:
|
|
return None
|
|
return str(subject_cn).strip().lower() == str(issuer_cn).strip().lower()
|
|
|
|
|
|
def _cert_is_expired(not_after, now_epoch) -> Optional[bool]:
|
|
"""Compare a parsed certificate expiry with the capture timestamp."""
|
|
if not not_after or now_epoch is None:
|
|
return None
|
|
exp = _parse_cert_time(not_after)
|
|
if exp is None:
|
|
return None
|
|
try:
|
|
return float(now_epoch) > exp
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _parse_cert_time(raw) -> Optional[float]:
|
|
"""Convert common X.509 time formats to a UTC epoch timestamp."""
|
|
import datetime as _dt
|
|
|
|
s = str(raw).strip()
|
|
# tshark may already provide an epoch value.
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
pass
|
|
fmts = (
|
|
"%Y-%m-%d %H:%M:%S", # 2020-01-01 00:00:00
|
|
"%b %d %H:%M:%S %Y GMT", # Jan 1 00:00:00 2020 GMT
|
|
"%y%m%d%H%M%SZ", # UTCTime 200101000000Z
|
|
"%Y%m%d%H%M%SZ", # GeneralizedTime
|
|
"%Y-%m-%dT%H:%M:%SZ", # ISO 8601
|
|
)
|
|
for fmt in fmts:
|
|
try:
|
|
dt = _dt.datetime.strptime(s, fmt).replace(tzinfo=_dt.timezone.utc)
|
|
return dt.timestamp()
|
|
except ValueError:
|
|
continue
|
|
return None
|