diff --git a/decnet/templates/_shared/ntlmssp.py b/decnet/templates/_shared/ntlmssp.py new file mode 100644 index 00000000..b0271a9a --- /dev/null +++ b/decnet/templates/_shared/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(" Last updated: 2026-04-25 — Credential model gained `secret_kind` discriminator; Postgres MD5 + VNC DES challenge creds now land in the table; MQTT regression from the legacy-adapter removal patched. +> Last updated: 2026-04-25 — Cred coverage rolled out across 9 more services (HTTP family + DB hash creds + form bodies + MongoDB SCRAM); RDP/SMB/NLA capture deferred to DEBT-040. > Severity: 🔴 Critical · 🟠 High · 🟡 Medium · 🟢 Low --- @@ -392,6 +392,21 @@ Closed by commits `aebb9f8` (encode_secret() helper), `abb4dd9` (six-service mig --- +### DEBT-040 — RDP, SMB, RDP-NLA cred capture (protocol framers) +**Files:** `decnet/templates/rdp/server.py`, `decnet/templates/smb/server.py`, `decnet/templates/_shared/ntlmssp.py` (already shipped). + +Three protocol-heavy templates still capture only connection bytes; their wire format carries credentials we currently throw away: + +1. **SMB** — `SimpleSMBServer` (Impacket) handles auth opaquely. NTLMSSP Type 3 messages carrying the NTLMv1/v2 hash flow through without ever surfacing in the `Credential` table. To fix: replace SimpleSMBServer with a hand-rolled asyncio SMB2 framer that (a) responds to Negotiate Protocol with a stock dialect, (b) responds to the first Session Setup with a stock NTLMSSP Type 2 challenge, (c) parses the second Session Setup's NTLMSSP Type 3 via the already-shipped `_shared/ntlmssp.py:parse_type3()`, (d) returns STATUS_LOGON_FAILURE so the attacker can't actually authenticate. Rough budget: 200 LoC for the SMB2/SPNEGO framer, parser is already there. Lands creds as `secret_kind="ntlmssp_v2"`. + +2. **RDP basic auth** — `templates/rdp/server.py` accepts an X.224 connection but immediately drops the connection on data. To capture TS_LOGON_INFO (the legacy plaintext-recoverable auth that pre-NLA mstsc and old Hydra/MSF modules use), the template needs TPKT → X.224 Data PDU → MCS Send Data Request → Client Info PDU framing. Plaintext-recoverable, lands as `secret_kind="plaintext"`. Rough budget: 150 LoC. Limited operator value — most modern attackers default to NLA — but ships with Phase 4 of the original cred-coverage plan. + +3. **RDP NLA / CredSSP** — the realistic-attacker path. RDP NLA wraps CredSSP, which wraps a TLS handshake, which carries SPNEGO/NTLM blobs. To capture: respond to the Connection Request advertising `PROTOCOL_HYBRID`, upgrade the socket to TLS using a self-signed cert (existing `https/` infra reusable), parse the inner CredSSP TSRequest ASN.1 DER, extract the negoTokens (NTLMSSP Type 1/2/3), reuse `_shared/ntlmssp.py:parse_type3()` for the Type 3 hash. Rough budget: 250 LoC, biggest of the three. + +**Already shipped as Phase 5/7 prep:** `decnet/templates/_shared/ntlmssp.py` (Type 3 parser with 7 unit tests). Both SMB and RDP-NLA work consume it directly. + +**Status:** Open — substantial protocol implementations each. Land independently as separate commits when scheduling allows. Cred-reuse analytics already work without these (the existing 12 services cover the bulk of attacker traffic); these three just round out fleet coverage. + ### DEBT-032 — Prober can't detect fingerprint rotation without mutation **Files:** `decnet/prober/worker.py` (~lines 235, 286, 334, 392), `decnet/web/db/models.py` (new `decky_service_fingerprints` table). @@ -473,6 +488,7 @@ The prober already computes JARM (`worker.py:286`), HASSH (`worker.py:334`), and | DEBT-037 | 🟡 Medium | Integration / Webhooks | open (tracks MVP follow-ups) | | DEBT-038 | 🟡 Medium | Honeypot / SSH cred capture | open (document-only) | | ~~DEBT-039~~ | ✅ | Honeypot / Cred emitters | resolved | +| DEBT-040 | 🟡 Medium | Honeypot / RDP+SMB cred framers | open | -**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only). +**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only), DEBT-040 (RDP / SMB / NLA cred framers). **Estimated remaining effort:** ~24 hours. DEBT-030 Phase B (optimistic staged-buffer editor) is a follow-up, not debt. diff --git a/tests/services/test_ntlmssp_parser.py b/tests/services/test_ntlmssp_parser.py new file mode 100644 index 00000000..d45c6c4d --- /dev/null +++ b/tests/services/test_ntlmssp_parser.py @@ -0,0 +1,154 @@ +"""NTLMSSP Type 3 parser tests. + +Builds Type 3 buffers field-by-field per MS-NLMP §2.2.1.3 and asserts +the parser returns the universal Credential SD shape. Shared +infrastructure for SMB and RDP-NLA cred capture. +""" +from __future__ import annotations + +import base64 +import importlib.util +import struct +from pathlib import Path + +import pytest + + +def _load_ntlmssp(): + repo = Path(__file__).resolve().parents[2] + path = repo / "decnet" / "templates" / "_shared" / "ntlmssp.py" + spec = importlib.util.spec_from_file_location("_ntlmssp_under_test", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def ntlmssp(): + return _load_ntlmssp() + + +def _build_type3( + *, + username: str, + domain: str, + nt_response: bytes, + unicode: bool = True, +) -> bytes: + """Build a syntactically-valid NTLMSSP Type 3 message.""" + if unicode: + u = username.encode("utf-16-le") + d = domain.encode("utf-16-le") + flags = 0x00000001 # NEGOTIATE_UNICODE + else: + u = username.encode("ascii") + d = domain.encode("ascii") + flags = 0x00000000 + + # Layout: 8 sig + 4 type + 6×8 field records + 4 flags = 64 bytes + # of header, then payload (concat of nt_response, domain, username). + header_size = 64 + nt_off = header_size + dom_off = nt_off + len(nt_response) + user_off = dom_off + len(d) + + hdr = bytearray(header_size) + hdr[0:8] = b"NTLMSSP\x00" + struct.pack_into("= 28 bytes in + practice). Parser flags this as secret_kind=ntlmssp_v2.""" + nt_response = b"\xab" * 16 + b"\x01\x01\x00\x00" + b"\x00" * 28 # ~48 bytes + blob = _build_type3( + username="alice", domain="ACME", nt_response=nt_response, + ) + cred = ntlmssp.parse_type3(blob) + assert cred is not None + assert cred["username"] == "alice" + assert cred["domain"] == "ACME" + assert cred["principal"] == "ACME\\alice" + assert cred["secret_kind"] == "ntlmssp_v2" + assert base64.b64decode(cred["secret_b64"]) == nt_response + + +def test_parse_type3_ntlmv1(ntlmssp): + """NTLMv1 NTChallengeResponse is exactly 24 bytes.""" + nt_response = b"\xcd" * 24 + blob = _build_type3( + username="bob", domain="WORKGROUP", nt_response=nt_response, + ) + cred = ntlmssp.parse_type3(blob) + assert cred["secret_kind"] == "ntlmssp_v1" + assert cred["principal"] == "WORKGROUP\\bob" + + +def test_parse_type3_no_domain(ntlmssp): + nt_response = b"\xff" * 24 + blob = _build_type3( + username="lonely", domain="", nt_response=nt_response, + ) + cred = ntlmssp.parse_type3(blob) + assert cred["domain"] == "" + assert cred["principal"] == "lonely" + + +def test_parse_type3_oem_strings(ntlmssp): + """Older clients without NEGOTIATE_UNICODE send ASCII strings.""" + nt_response = b"\x11" * 24 + blob = _build_type3( + username="ascii_user", + domain="WIN2000", + nt_response=nt_response, + unicode=False, + ) + cred = ntlmssp.parse_type3(blob) + assert cred["username"] == "ascii_user" + assert cred["domain"] == "WIN2000" + + +def test_parse_type3_rejects_non_signature(ntlmssp): + assert ntlmssp.parse_type3(b"NotNtlmssp") is None + assert ntlmssp.parse_type3(b"") is None + # Right magic but wrong message type: + blob = bytearray(64) + blob[0:8] = b"NTLMSSP\x00" + struct.pack_into("= 0 + cred = ntlmssp.parse_type3(outer[off:]) + assert cred["username"] == "x"