From 828165783e3dbfcd10a739eff8581242c78175c0 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 27 Apr 2026 10:12:30 -0400 Subject: [PATCH] feat(templates): standalone NTLMSSP Type 3 parser + decnet-init wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * decnet/templates/{rdp,smb}/ntlmssp.py — minimal Type 3 (Authenticate) parser shared between the SMB and RDP-NLA templates. Lands NTLM creds in the universal Credential table with secret_kind=ntlmssp_v1 / ntlmssp_v2 and secret_b64 = base64 of the NtChallengeResponse so the bounty pipeline can feed the right hashcat mode. * scripts/decnet-init.sh — convenience wrapper around `sudo decnet init --force` that targets the current working directory; saves operators retyping the install paths during dev iterations. --- decnet/templates/rdp/ntlmssp.py | 132 ++++++++++++++++++++++++++++++++ decnet/templates/smb/ntlmssp.py | 132 ++++++++++++++++++++++++++++++++ scripts/decnet-init.sh | 3 + 3 files changed, 267 insertions(+) create mode 100644 decnet/templates/rdp/ntlmssp.py create mode 100644 decnet/templates/smb/ntlmssp.py create mode 100755 scripts/decnet-init.sh diff --git a/decnet/templates/rdp/ntlmssp.py b/decnet/templates/rdp/ntlmssp.py new file mode 100644 index 00000000..b0271a9a --- /dev/null +++ b/decnet/templates/rdp/ntlmssp.py @@ -0,0 +1,132 @@ +"""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(" 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": "", # NT response in hex + "secret_b64": "", # 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(" 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(" 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": "", # NT response in hex + "secret_b64": "", # 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("