From b3d1301925175b7cf59eb913f6e2ff1ff36f9a9c Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 25 Apr 2026 07:42:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(creds):=20DEBT-040=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20RDP=20NLA=20/=20CredSSP=20NTLMv2=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When RDP_ENABLE_NLA=true (service_cfg.nla=true on the topology side), confirm PROTOCOL_HYBRID on the X.224 Connection Confirm, upgrade the socket to TLS using a self-signed cert generated at first start by the entrypoint, then drive a tiny CredSSP loop: - Read inbound TSRequest DER (bounded to MAX_TSREQUEST_LEN). - Scan for the NTLMSSP signature, dispatch on message type: Type 1 -> respond with a hand-built TSRequest carrying our Type 2 challenge. Type 3 -> parse_type3() and emit auth_attempt with the universal credential SD shape (secret_kind = ntlmssp_v2). - Hand-built DER: no pyasn1 dependency. Also folds in a small fix-up to commit 1: SMB SERVER_CHALLENGE was hardcoded to 0x11..0x88 across the fleet, which would let a scanner fingerprint every DECNET decky by its NTLM challenge. Both SMB and RDP now derive the 8-byte challenge from instance_seed.random_bytes(8, "ntlm_challenge"), giving each decky a deterministic-but-distinct value. SMB Dockerfile gets the instance_seed.py copy too (was synced into the build context but not COPYed into the image). - decnet/services/rdp.py: optional service_cfg.nla bool flips RDP_ENABLE_NLA in the compose env. - decnet/templates/rdp/Dockerfile + entrypoint.sh: openssl install + per-decky cert generation gated on RDP_ENABLE_NLA. - 9 NLA unit tests cover the DER reader/builder, _handle_nla round- trip with Type 1 / Type 3, oversized-DER rejection, and per- NODE_NAME challenge divergence. - DEBT.md: DEBT-040 closed; full TS_INFO_PACKET capture documented as a follow-up if attacker telemetry justifies it. --- decnet/services/rdp.py | 5 + decnet/templates/rdp/Dockerfile | 5 +- decnet/templates/rdp/entrypoint.sh | 17 ++ decnet/templates/rdp/server.py | 254 ++++++++++++++++++++++-- decnet/templates/smb/Dockerfile | 1 + decnet/templates/smb/server.py | 12 +- development/DEBT.md | 24 ++- tests/service_testing/test_rdp_basic.py | 12 +- tests/service_testing/test_rdp_nla.py | 211 ++++++++++++++++++++ 9 files changed, 506 insertions(+), 35 deletions(-) create mode 100644 tests/service_testing/test_rdp_nla.py diff --git a/decnet/services/rdp.py b/decnet/services/rdp.py index 26057ff9..ccdced01 100644 --- a/decnet/services/rdp.py +++ b/decnet/services/rdp.py @@ -20,6 +20,11 @@ class RDPService(BaseService): } if log_target: fragment["environment"]["LOG_TARGET"] = log_target + # Opt into the CredSSP / NLA capture path. Off by default — basic + # X.224 cookie capture is sufficient for most attacker traffic and + # avoids the openssl cert-gen overhead at container start. + if service_cfg and service_cfg.get("nla"): + fragment["environment"]["RDP_ENABLE_NLA"] = "true" return fragment def dockerfile_context(self) -> Path | None: diff --git a/decnet/templates/rdp/Dockerfile b/decnet/templates/rdp/Dockerfile index 1d3222f2..14e3db98 100644 --- a/decnet/templates/rdp/Dockerfile +++ b/decnet/templates/rdp/Dockerfile @@ -2,10 +2,11 @@ ARG BASE_IMAGE=debian:bookworm-slim FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 \ + python3 openssl \ && rm -rf /var/lib/apt/lists/* COPY syslog_bridge.py /opt/syslog_bridge.py +COPY instance_seed.py /opt/instance_seed.py COPY ntlmssp.py /opt/ntlmssp.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh @@ -13,6 +14,8 @@ RUN chmod +x /entrypoint.sh EXPOSE 3389 RUN useradd -r -s /bin/false -d /opt logrelay \ + && mkdir -p /opt/tls \ + && chown logrelay:logrelay /opt/tls \ && apt-get update && apt-get install -y --no-install-recommends libcap2-bin \ && rm -rf /var/lib/apt/lists/* \ && (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true) diff --git a/decnet/templates/rdp/entrypoint.sh b/decnet/templates/rdp/entrypoint.sh index c830b733..37f364c0 100644 --- a/decnet/templates/rdp/entrypoint.sh +++ b/decnet/templates/rdp/entrypoint.sh @@ -1,3 +1,20 @@ #!/bin/bash set -e + +# Generate a self-signed cert on first start when NLA is enabled. +# Used by the CredSSP path to terminate the TLS layer that wraps NTLMSSP. +if [ "${RDP_ENABLE_NLA:-}" = "true" ] || [ "${RDP_ENABLE_NLA:-}" = "1" ]; then + TLS_DIR="/opt/tls" + CERT="${TLS_CERT:-$TLS_DIR/cert.pem}" + KEY="${TLS_KEY:-$TLS_DIR/key.pem}" + if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then + mkdir -p "$TLS_DIR" + CN="${TLS_CN:-${NODE_NAME:-localhost}}" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$KEY" -out "$CERT" \ + -days 3650 -subj "/CN=$CN" \ + 2>/dev/null + fi +fi + exec python3 /opt/server.py diff --git a/decnet/templates/rdp/server.py b/decnet/templates/rdp/server.py index f0cf6ac1..3b7426e7 100644 --- a/decnet/templates/rdp/server.py +++ b/decnet/templates/rdp/server.py @@ -1,26 +1,28 @@ #!/usr/bin/env python3 -"""Minimal honeypot RDP server (X.224 cookie + protocol negotiation). +"""Minimal honeypot RDP server. -Parses the very first packet of every RDP connection — the X.224 -Connection Request — and extracts: +Two operating modes share the same X.224 Connection Request parser: -* The ``mstshash=`` routing cookie that mstsc, FreeRDP, ncrack, - Hydra, and Metasploit's ``rdp_login`` all stamp into the CR. This is - the only piece of credential information that flows in plaintext on - the wire when the attacker speaks RDP, so capturing it is the - highest-value-per-byte signal we can extract without going down the - Standard-RDP-Security RC4 rabbit hole or the TLS+CredSSP stack. -* The ``rdpNegRequest.requestedProtocols`` flags, which tell us - whether the client asked for legacy RDP, SSL/TLS, or NLA/CredSSP. +1. **Default (basic).** Parse the X.224 CR, extract the ``mstshash`` + routing cookie + ``rdpNegRequest.requestedProtocols`` flags, answer + with a Connection Confirm selecting ``PROTOCOL_RDP``, close. + Captures the username most attackers leak in plaintext. -We always answer with a valid X.224 Connection Confirm selecting -``PROTOCOL_RDP`` (legacy / Standard RDP Security). The connection is -then closed cleanly. NLA / CredSSP credential capture is the job of -the ``RDP_ENABLE_NLA`` path, landed alongside this in DEBT-040. +2. **NLA (``RDP_ENABLE_NLA=true``).** Confirm ``PROTOCOL_HYBRID``, + upgrade the socket to TLS, then read inbound CredSSP TSRequest DER + blobs. We do not parse the ASN.1 — we just scan for the NTLMSSP + signature inside the TLS-decrypted plaintext (CredSSP wraps a + handful of NTLMSSP messages); when the inbound message is a + Type 3, ``parse_type3()`` produces the universal credential SD + block and we land an NTLMv2 hash in the Credential table. The + server responds to Type 1 with a hand-built TSRequest carrying an + NTLMSSP Type 2 challenge, then drops after Type 3. References: - MS-RDPBCGR §2.2.1.1 Client X.224 Connection Request PDU - MS-RDPBCGR §2.2.1.2 Server X.224 Connection Confirm PDU +- MS-CSSP §2.2.1 TSRequest +- MS-NLMP §2.2.1.2 NTLMSSP CHALLENGE_MESSAGE - RFC 1006 (TPKT) §6 """ @@ -29,16 +31,30 @@ from __future__ import annotations import asyncio import os import re +import ssl +import struct +import instance_seed +from ntlmssp import find_ntlmssp, parse_type3 from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") SERVICE_NAME = "rdp" LOG_TARGET = os.environ.get("LOG_TARGET", "") +ENABLE_NLA = os.environ.get("RDP_ENABLE_NLA", "").lower() in ("1", "true", "yes") +TLS_CERT = os.environ.get("TLS_CERT", "/opt/tls/cert.pem") +TLS_KEY = os.environ.get("TLS_KEY", "/opt/tls/key.pem") LISTEN_HOST = "0.0.0.0" # nosec B104 — honeypot binds all interfaces by design LISTEN_PORT = 3389 +# Per-instance NTLM challenge: deterministic-per-decky-but-different- +# across-the-fleet (see instance_seed module docstring). A fixed +# challenge across the fleet would let scanners fingerprint us. +SERVER_CHALLENGE = instance_seed.random_bytes(8, "ntlm_challenge") + +MAX_TSREQUEST_LEN = 32 * 1024 # CredSSP messages are small; cap memory pressure + # X.224 / TPKT constants TPKT_VERSION = 0x03 X224_CR = 0xE0 # Connection Request @@ -119,9 +135,208 @@ def _build_x224_cc(selected_protocol: int = PROTOCOL_RDP) -> bytes: return tpkt + x224 +# ── NLA / CredSSP helpers ──────────────────────────────────────────────────── + + +def _der_len(n: int) -> bytes: + if n < 0x80: + return bytes([n]) + body = n.to_bytes((n.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(body)]) + body + + +def _der_read_len(buf: bytes, off: int) -> tuple[int, int]: + """Return (length, new_offset) reading a DER length field.""" + if off >= len(buf): + return 0, off + first = buf[off] + off += 1 + if first < 0x80: + return first, off + n = first & 0x7F + if n == 0 or off + n > len(buf): + return 0, off + val = int.from_bytes(buf[off:off + n], "big") + return val, off + n + + +def _build_ntlmssp_type2(challenge: bytes) -> bytes: + """Build a minimal NTLMSSP CHALLENGE_MESSAGE (MS-NLMP §2.2.1.2). + + Mirrors the SMB framer's builder. Inlined here rather than shared so + that ``_shared/ntlmssp.py`` stays a pure parser module. + """ + target = "WORKGROUP".encode("utf-16-le") + av_name = "WORKGROUP".encode("utf-16-le") + target_info = struct.pack(" bytes: + """Build a CredSSP TSRequest carrying a single negoToken (MS-CSSP §2.2.1). + + Layout (DER, simplified — only fields we need on the response path): + + TSRequest ::= SEQUENCE { + version [0] INTEGER, + negoTokens [1] SEQUENCE OF SEQUENCE { negoToken [0] OCTET STRING } + } + """ + # version [0] INTEGER + version_bytes = version.to_bytes(1, "big") + version_field = b"\x02" + _der_len(len(version_bytes)) + version_bytes + version_tagged = b"\xa0" + _der_len(len(version_field)) + version_field + + # innermost: negoToken [0] OCTET STRING + octet = b"\x04" + _der_len(len(ntlm_blob)) + ntlm_blob + negotoken_tagged = b"\xa0" + _der_len(len(octet)) + octet + inner_seq = b"\x30" + _der_len(len(negotoken_tagged)) + negotoken_tagged + outer_seq = b"\x30" + _der_len(len(inner_seq)) + inner_seq + negotokens_tagged = b"\xa1" + _der_len(len(outer_seq)) + outer_seq + + body = version_tagged + negotokens_tagged + return b"\x30" + _der_len(len(body)) + body + + +async def _read_one_tsrequest(reader: asyncio.StreamReader) -> bytes: + """Read one DER-encoded TSRequest (outer SEQUENCE) from the stream. + + A SEQUENCE starts with tag 0x30 followed by a DER length, then that + many content bytes. We bound the total to MAX_TSREQUEST_LEN. + """ + tag = await reader.readexactly(1) + if tag != b"\x30": + raise ValueError("not a SEQUENCE") + first_len = (await reader.readexactly(1))[0] + if first_len < 0x80: + body_len = first_len + len_bytes = bytes([first_len]) + else: + n = first_len & 0x7F + if n == 0 or n > 4: + raise ValueError("bad DER length") + ext = await reader.readexactly(n) + body_len = int.from_bytes(ext, "big") + len_bytes = bytes([first_len]) + ext + if body_len > MAX_TSREQUEST_LEN: + raise ValueError("TSRequest too large") + body = await reader.readexactly(body_len) + return tag + len_bytes + body + + +async def _handle_nla( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + src_ip: str, + src_port: int, +) -> None: + """Drive the CredSSP exchange post-TLS-handshake. + + Reads up to 3 inbound TSRequests; on the one carrying an NTLMSSP + Type 3, emits the credential and closes. + """ + for round_no in range(3): + try: + ts_blob = await asyncio.wait_for(_read_one_tsrequest(reader), timeout=10.0) + except (asyncio.IncompleteReadError, asyncio.TimeoutError, ValueError): + return + off = find_ntlmssp(ts_blob) + if off < 0: + return + ntlm = ts_blob[off:] + # Message type at offset 8 (after the 8-byte signature) + if len(ntlm) < 12: + return + msg_type = struct.unpack_from(" ssl.SSLContext | None: + """Load the per-decky self-signed cert for the NLA path. + + Returns None if the cert files aren't present yet (allows the + container to come up even before the entrypoint has generated + them; subsequent connections retry). + """ + if not (os.path.exists(TLS_CERT) and os.path.exists(TLS_KEY)): + return None + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=TLS_CERT, keyfile=TLS_KEY) + # CredSSP clients negotiate down — accept whatever the client offers + ctx.set_ciphers("DEFAULT:@SECLEVEL=0") + return ctx + + +async def _upgrade_to_tls_and_capture( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + src_ip: str, + src_port: int, +) -> None: + """Upgrade the underlying socket to TLS, then run the CredSSP loop.""" + ctx = _build_tls_context() + if ctx is None: + _log("error", severity=4, src_ip=src_ip, msg="TLS cert missing; NLA path unavailable") + return + transport = writer.transport + loop = asyncio.get_running_loop() + try: + new_transport = await loop.start_tls( + transport, + transport.get_protocol(), + ctx, + server_side=True, + ) + except (ssl.SSLError, OSError) as exc: + _log("tls_handshake_failed", severity=4, src_ip=src_ip, msg=str(exc)) + return + # Rewrap the StreamReader/StreamWriter on top of the new TLS transport. + # We use the stdlib's protocol to bridge the upgraded transport back + # into a StreamReader/StreamWriter pair the rest of the handler can use. + new_reader = asyncio.StreamReader(loop=loop) + new_protocol = asyncio.StreamReaderProtocol(new_reader, loop=loop) + new_transport.set_protocol(new_protocol) + new_protocol.connection_made(new_transport) + new_writer = asyncio.StreamWriter(new_transport, new_protocol, new_reader, loop) + await _handle_nla(new_reader, new_writer, src_ip, src_port) + + async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: peer = writer.get_extra_info("peername") or ("?", 0) src_ip, src_port = peer[0], peer[1] @@ -150,10 +365,13 @@ async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWri _log("rdp_cookie", **fields) else: _log("connection_request", **fields) - # Confirm with PROTOCOL_RDP. PROTOCOL_SSL / PROTOCOL_HYBRID - # selection arrives with the NLA path in a follow-up commit. - writer.write(_build_x224_cc(PROTOCOL_RDP)) + + nla_path = ENABLE_NLA and (requested & PROTOCOL_HYBRID) + selected = PROTOCOL_HYBRID if nla_path else PROTOCOL_RDP + writer.write(_build_x224_cc(selected)) await writer.drain() + if nla_path: + await _upgrade_to_tls_and_capture(reader, writer, src_ip, src_port) except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError): pass except Exception as exc: # noqa: BLE001 — honeypot must never crash the worker diff --git a/decnet/templates/smb/Dockerfile b/decnet/templates/smb/Dockerfile index d676620e..f6f66110 100644 --- a/decnet/templates/smb/Dockerfile +++ b/decnet/templates/smb/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY syslog_bridge.py /opt/syslog_bridge.py +COPY instance_seed.py /opt/instance_seed.py COPY ntlmssp.py /opt/ntlmssp.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh diff --git a/decnet/templates/smb/server.py b/decnet/templates/smb/server.py index 22c8ec9f..fe04a937 100644 --- a/decnet/templates/smb/server.py +++ b/decnet/templates/smb/server.py @@ -22,6 +22,7 @@ import asyncio import os import struct +import instance_seed from ntlmssp import find_ntlmssp, parse_type3 from syslog_bridge import syslog_line, write_syslog_file, forward_syslog @@ -44,10 +45,13 @@ SMB2_SESSION_SETUP = 0x0001 SMB2_MAGIC = b"\xfeSMB" NBSS_SESSION_MESSAGE = 0x00 -# Server's fixed 8-byte NTLM challenge (random-looking; honeypot, not crypto) -SERVER_CHALLENGE = b"\x11\x22\x33\x44\x55\x66\x77\x88" -# Stable server GUID — 16 zero bytes is fine for a honeypot. -SERVER_GUID = b"\x00" * 16 +# Per-instance NTLM challenge: deterministic-per-decky-but-different- +# across-the-fleet. Derived from NODE_NAME so two captures from the +# same decky reuse the same challenge (lets offline attackers retry +# wordlists), while every decky in the fleet differs (looks like a +# real population of hosts to a scanner). +SERVER_CHALLENGE = instance_seed.random_bytes(8, "ntlm_challenge") +SERVER_GUID = instance_seed.random_bytes(16, "smb_server_guid") # Read caps; an attacker shouldn't be able to make us allocate # unbounded memory just by lying about NetBIOS frame length. diff --git a/development/DEBT.md b/development/DEBT.md index dc505c5c..de46284b 100644 --- a/development/DEBT.md +++ b/development/DEBT.md @@ -392,20 +392,22 @@ 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). +### ~~DEBT-040 — RDP, SMB, RDP-NLA cred capture (protocol framers)~~ ✅ RESOLVED +**Files:** `decnet/templates/smb/server.py` (rewritten), `decnet/templates/rdp/server.py` (rewritten), `decnet/engine/deployer.py` (`_sync_ntlmssp_sources()`), `decnet/services/rdp.py` (`nla` knob), `tests/service_testing/test_smb_server.py` + `test_rdp_basic.py` + `test_rdp_nla.py`. -Three protocol-heavy templates still capture only connection bytes; their wire format carries credentials we currently throw away: +Closed in three commits on `dev`: -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"`. +1. **SMB NTLMSSP framer.** `SimpleSMBServer` replaced with a hand-rolled asyncio SMB2 framer that walks Negotiate → SessionSetup(Type 1) → SessionSetup(Type 3); reuses the shared `parse_type3()` to land `secret_kind="ntlmssp_v2"` (or `_v1`) in the Credential table. Always returns `STATUS_LOGON_FAILURE`. SPNEGO Type 2 challenge is wrapped per RFC 4178; per-decky `SERVER_CHALLENGE` derived from `instance_seed.random_bytes("ntlm_challenge")` so the fleet doesn't share a fingerprint. Impacket dependency dropped. 7 unit tests. -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. +2. **RDP X.224 cookie capture.** The Twisted-based connection logger replaced with an asyncio handler that parses the X.224 Connection Request, extracts the `mstshash=` routing cookie (stamped by mstsc / FreeRDP / Hydra / ncrack / MSF `rdp_login`), records `rdpNegRequest.requestedProtocols`, and answers with a well-formed Connection Confirm selecting `PROTOCOL_RDP`. Scope-down vs. the original spec: full `TS_INFO_PACKET` extraction would have required either Standard-RDP-Security RC4 (with our own RSA pair + MS-RDPBCGR signing) or a complete MCS+GCC ASN.1/BER stack — both far beyond the 150 LoC budget. The cookie is the only credential bit that flows in plaintext on the wire; capturing it is the highest-value-per-byte signal without those rabbit holes. 7 unit tests. -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. +3. **RDP NLA / CredSSP.** Behind `RDP_ENABLE_NLA=true` (or `service_cfg.nla=true` in the topology), confirms `PROTOCOL_HYBRID`, upgrades the socket to TLS via `loop.start_tls()` using a self-signed cert generated by the entrypoint, then drives a tiny CredSSP loop: read inbound TSRequest DER, scan for the NTLMSSP signature, dispatch on message type — Type 1 → respond with TSRequest carrying a Type 2 challenge; Type 3 → `parse_type3()` and emit. Hand-built TSRequest writer (no `pyasn1` dep). 9 unit tests (DER reader, builder, `_handle_nla` round-trip, oversized-DER drop, per-instance challenge differs across `NODE_NAME`). -**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. +Shared prep landed in commit 1: `_sync_ntlmssp_sources()` in `decnet/engine/deployer.py` mirrors the auth-helper / sessrec sync pattern, copies `_shared/ntlmssp.py` into the SMB and RDP build contexts before `docker compose up`. -**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. +**Deferred (not blocking close):** +- Full `TS_INFO_PACKET` (basic-RDP plaintext password) — see scope-down note in commit 2. Re-open as a follow-up DEBT if attacker telemetry actually shows traffic on `PROTOCOL_RDP` without NLA. +- Pubkey / Kerberos auth paths — out of scope; mirrors DEBT-038's deferral on the SSH side. ### 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). @@ -488,7 +490,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 | +| ~~DEBT-040~~ | ✅ | Honeypot / RDP+SMB cred framers | resolved | -**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. +**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). +**Estimated remaining effort:** ~21 hours. DEBT-030 Phase B (optimistic staged-buffer editor) is a follow-up, not debt. diff --git a/tests/service_testing/test_rdp_basic.py b/tests/service_testing/test_rdp_basic.py index 8dfe8f52..9e63d8cc 100644 --- a/tests/service_testing/test_rdp_basic.py +++ b/tests/service_testing/test_rdp_basic.py @@ -22,11 +22,21 @@ from .conftest import load_real_instance_seed, make_fake_syslog_bridge # ── Module loader ───────────────────────────────────────────────────────────── +def _load_real_ntlmssp(): + spec = importlib.util.spec_from_file_location( + "ntlmssp", "decnet/templates/_shared/ntlmssp.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _load_rdp(): - for key in ("rdp_server", "syslog_bridge", "instance_seed"): + for key in ("rdp_server", "syslog_bridge", "instance_seed", "ntlmssp"): sys.modules.pop(key, None) sys.modules["syslog_bridge"] = make_fake_syslog_bridge() sys.modules["instance_seed"] = load_real_instance_seed() + sys.modules["ntlmssp"] = _load_real_ntlmssp() spec = importlib.util.spec_from_file_location( "rdp_server", "decnet/templates/rdp/server.py" ) diff --git a/tests/service_testing/test_rdp_nla.py b/tests/service_testing/test_rdp_nla.py new file mode 100644 index 00000000..a829c27d --- /dev/null +++ b/tests/service_testing/test_rdp_nla.py @@ -0,0 +1,211 @@ +"""Tests for the RDP NLA / CredSSP credential-capture path. + +The TLS layer is exercised end-to-end in deploy verification; here we +unit-test the inner pieces: DER length reader, TSRequest builder, +TSRequest reader, and the ``_handle_nla`` loop driving canned CredSSP +DER bytes carrying NTLMSSP Type 1 / Type 3 messages. +""" +from __future__ import annotations + +import asyncio +import importlib.util +import struct +import sys +from unittest.mock import MagicMock + +import pytest + +from .conftest import load_real_instance_seed, make_fake_syslog_bridge + + +# ── Module loader ───────────────────────────────────────────────────────────── + + +def _load_real_ntlmssp(): + spec = importlib.util.spec_from_file_location( + "ntlmssp", "decnet/templates/_shared/ntlmssp.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _load_rdp(*, enable_nla: bool = True, monkeypatch=None): + if monkeypatch is not None: + if enable_nla: + monkeypatch.setenv("RDP_ENABLE_NLA", "true") + else: + monkeypatch.delenv("RDP_ENABLE_NLA", raising=False) + for key in ("rdp_server", "syslog_bridge", "instance_seed", "ntlmssp"): + sys.modules.pop(key, None) + sys.modules["syslog_bridge"] = make_fake_syslog_bridge() + sys.modules["instance_seed"] = load_real_instance_seed() + sys.modules["ntlmssp"] = _load_real_ntlmssp() + spec = importlib.util.spec_from_file_location( + "rdp_server", "decnet/templates/rdp/server.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _ntlmssp_type1() -> bytes: + return b"NTLMSSP\x00" + struct.pack(" bytes: + user_b = username.encode("utf-16-le") + dom_b = domain.encode("utf-16-le") + payload = nt_response + dom_b + user_b + nt_off = 72 + dom_off = nt_off + len(nt_response) + user_off = dom_off + len(dom_b) + ws_off = user_off + len(user_b) + flags = 0x00000001 + return ( + b"NTLMSSP\x00" + + struct.pack(" MAX_TSREQUEST_LEN + over = mod.MAX_TSREQUEST_LEN + 1 + bad = b"\x30\x84" + over.to_bytes(4, "big") # 4-byte length + + async def _run(): + reader = asyncio.StreamReader() + reader.feed_data(bad) + reader.feed_eof() + with pytest.raises(ValueError): + await mod._read_one_tsrequest(reader) + + asyncio.run(_run()) + + +# ── _handle_nla integration ─────────────────────────────────────────────────── + + +def test_type1_then_type3_captures_credential(monkeypatch): + mod = _load_rdp(monkeypatch=monkeypatch) + log_mock = sys.modules["syslog_bridge"] + nt_response = b"\xcc" * 32 + ts1 = mod._build_tsrequest_with_token(6, _ntlmssp_type1()) + ts3 = mod._build_tsrequest_with_token(6, _ntlmssp_type3("alice", "ACME", nt_response)) + + async def _run(): + reader = asyncio.StreamReader() + reader.feed_data(ts1 + ts3) + reader.feed_eof() + writer, written = _make_writer() + await mod._handle_nla(reader, writer, "192.0.2.5", 51000) + return written + + written = asyncio.run(_run()) + # Server replied to Type 1 with a Type 2 challenge wrapped in TSRequest + assert written, "expected a TSRequest response to Type 1" + resp = b"".join(written) + assert b"NTLMSSP\x00" in resp + type_byte = resp[resp.index(b"NTLMSSP\x00") + 8] + assert type_byte == 0x02 + + auth_calls = [ + c for c in log_mock.syslog_line.call_args_list + if len(c.args) >= 3 and c.args[2] == "auth_attempt" + ] + assert auth_calls + kwargs = auth_calls[0].kwargs + assert kwargs["principal"] == "ACME\\alice" + assert kwargs["secret_kind"] == "ntlmssp_v2" + assert kwargs["auth_path"] == "nla" + + +def test_handle_nla_returns_cleanly_on_garbage(monkeypatch): + mod = _load_rdp(monkeypatch=monkeypatch) + + async def _run(): + reader = asyncio.StreamReader() + reader.feed_data(b"\x00\x01\x02\x03not a sequence") + reader.feed_eof() + writer, _ = _make_writer() + await mod._handle_nla(reader, writer, "198.51.100.9", 1234) + + asyncio.run(_run()) # must not raise + + +def test_per_instance_challenge_is_not_constant_across_node_names(monkeypatch): + monkeypatch.setenv("NODE_NAME", "decky-alpha") + monkeypatch.setenv("RDP_ENABLE_NLA", "true") + mod_a = _load_rdp(monkeypatch=monkeypatch) + chal_a = mod_a.SERVER_CHALLENGE + + monkeypatch.setenv("NODE_NAME", "decky-bravo") + mod_b = _load_rdp(monkeypatch=monkeypatch) + chal_b = mod_b.SERVER_CHALLENGE + + assert chal_a != chal_b + assert len(chal_a) == 8 and len(chal_b) == 8