Files
DECNET/decnet/templates/_shared/ntlmssp.py

133 lines
4.6 KiB
Python

"""NTLMSSP Type 3 (Authenticate) message parser.
Standalone module shared between any honeypot template that wants to
land NTLM credentials in the universal :class:`Credential` table.
Currently consumed by the SMB and RDP-NLA templates.
The parser is intentionally narrow: only :func:`parse_type3` is public,
and it reads a single Type 3 buffer (the bytes starting with the
``NTLMSSP\\0`` signature). Callers handle SPNEGO unwrapping, SMB
SessionSetup framing, RDP/CredSSP TSRequest parsing, etc.
Reference: MS-NLMP §2.2.1.3 (AUTHENTICATE_MESSAGE).
Cred-shape mapping for the universal Credential model:
- ``principal`` = ``"DOMAIN\\username"`` when domain present, else
bare username. Both decoded UTF-16-LE when NEGOTIATE_UNICODE is set
in the message flags (it always is in modern clients).
- ``secret_kind`` = ``"ntlmssp_v2"`` when the NtChallengeResponse is
≥ 24 bytes (NTLMv2 carries variable-length blob ≥ 16+8 bytes),
``"ntlmssp_v1"`` for the legacy 24-byte fixed response.
- ``secret_b64`` = base64 of the entire NtChallengeResponse bytes.
This is the canonical "hashcat -m 5600" (NTLMv2) or "-m 5500"
(NTLMv1) input.
"""
from __future__ import annotations
import base64
import struct
from typing import Optional
NTLMSSP_SIG = b"NTLMSSP\x00"
NEGOTIATE_UNICODE = 0x00000001
def find_ntlmssp(buf: bytes) -> int:
"""Return the offset of the NTLMSSP signature in ``buf`` or -1.
Useful for callers that have a SPNEGO-wrapped or SMB-embedded blob
and want to skip straight to the inner Type 1/2/3 message without
walking the outer ASN.1.
"""
return buf.find(NTLMSSP_SIG)
def _read_field(buf: bytes, off: int) -> tuple[int, int, int]:
"""Read an NTLMSSP field record: (Len, MaxLen, BufferOffset)."""
if off + 8 > len(buf):
return 0, 0, 0
f_len, f_max, f_off = struct.unpack_from("<HHI", buf, off)
return f_len, f_max, f_off
def _slice(buf: bytes, off: int, length: int) -> bytes:
end = off + length
if off < 0 or end > len(buf) or length < 0:
return b""
return buf[off:end]
def _decode_str(raw: bytes, unicode: bool) -> str:
if unicode:
return raw.decode("utf-16-le", errors="replace")
return raw.decode("ascii", errors="replace")
def parse_type3(blob: bytes) -> Optional[dict]:
"""Parse an NTLMSSP Type 3 (AUTHENTICATE_MESSAGE) buffer.
Returns a dict with the universal credential SD shape ready to
spread into a ``_log(...)`` call::
{
"username": "alice", # service-specific identity
"domain": "ACME", # domain (may be empty)
"principal": "ACME\\\\alice", # hoisted column
"secret_kind": "ntlmssp_v2", # or _v1
"secret_printable": "<hex>", # NT response in hex
"secret_b64": "<base64>", # NT response, lossless
}
Returns ``None`` when ``blob`` is malformed or not a Type 3.
"""
if len(blob) < 32 or not blob.startswith(NTLMSSP_SIG):
return None
msg_type = struct.unpack_from("<I", blob, 8)[0]
if msg_type != 3:
return None
# Field record layout (all from MS-NLMP §2.2.1.3):
# 12 LmChallengeResponseFields
# 20 NtChallengeResponseFields
# 28 DomainNameFields
# 36 UserNameFields
# 44 WorkstationFields
# 52 EncryptedRandomSessionKeyFields
# 60 NegotiateFlags
nt_len, _, nt_off = _read_field(blob, 20)
dom_len, _, dom_off = _read_field(blob, 28)
user_len, _, user_off = _read_field(blob, 36)
if len(blob) < 64:
return None
flags = struct.unpack_from("<I", blob, 60)[0]
unicode = bool(flags & NEGOTIATE_UNICODE)
nt_response = _slice(blob, nt_off, nt_len)
domain = _decode_str(_slice(blob, dom_off, dom_len), unicode)
username = _decode_str(_slice(blob, user_off, user_len), unicode)
if not nt_response:
# No NT response → anonymous bind or malformed; nothing to
# treat as a credential.
return None
# NTLMv2 NTChallengeResponseV2 has a 16-byte HMAC followed by a
# variable-length blob (≥ 28 bytes total in practice). NTLMv1 is
# exactly 24 bytes. Use length to discriminate; close enough for
# cred-classification purposes (the bytes go on hashcat regardless).
secret_kind = "ntlmssp_v1" if len(nt_response) == 24 else "ntlmssp_v2"
if domain:
principal = f"{domain}\\{username}"
else:
principal = username or None
return {
"username": username,
"domain": domain,
"principal": principal,
"secret_kind": secret_kind,
"secret_printable": nt_response.hex(),
"secret_b64": base64.b64encode(nt_response).decode("ascii"),
}