feat(creds): DEBT-040 Phase 3 — RDP NLA / CredSSP NTLMv2 capture

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.
This commit is contained in:
2026-04-25 07:42:52 -04:00
parent a8b9c82c97
commit b3d1301925
9 changed files with 506 additions and 35 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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=<user>`` 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("<HH", 1, len(av_name)) + av_name + struct.pack("<HH", 0, 0)
flags = 0x00828201 # UNICODE | NTLM | TARGET_INFO | always_sign
target_off = 56
info_off = target_off + len(target)
return (
b"NTLMSSP\x00"
+ struct.pack("<I", 2)
+ struct.pack("<HHI", len(target), len(target), target_off)
+ struct.pack("<I", flags)
+ challenge
+ b"\x00" * 8
+ struct.pack("<HHI", len(target_info), len(target_info), info_off)
+ b"\x00" * 8
+ target + target_info
)
def _build_tsrequest_with_token(version: int, ntlm_blob: bytes) -> 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("<I", ntlm, 8)[0]
if msg_type == 1:
# Type 1 → respond with TSRequest carrying Type 2 challenge
type2 = _build_ntlmssp_type2(SERVER_CHALLENGE)
resp = _build_tsrequest_with_token(version=6, ntlm_blob=type2)
writer.write(resp)
await writer.drain()
continue
if msg_type == 3:
# Type 3 → credential lands here
cred = parse_type3(ntlm)
if cred:
_log(
"auth_attempt",
src_ip=src_ip,
src_port=src_port,
auth_path="nla",
**cred,
)
return
# Unknown type → drop
return
# ── Connection handler ───────────────────────────────────────────────────────
def _build_tls_context() -> 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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=<user>` 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.

View File

@@ -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"
)

View File

@@ -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("<I", 1) + struct.pack("<I", 0xE2088297) + b"\x00" * 24
def _ntlmssp_type3(username: str, domain: str, nt_response: bytes) -> 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("<I", 3)
+ struct.pack("<HHI", 0, 0, ws_off)
+ struct.pack("<HHI", len(nt_response), len(nt_response), nt_off)
+ struct.pack("<HHI", len(dom_b), len(dom_b), dom_off)
+ struct.pack("<HHI", len(user_b), len(user_b), user_off)
+ struct.pack("<HHI", 0, 0, ws_off)
+ struct.pack("<HHI", 0, 0, ws_off)
+ struct.pack("<I", flags)
+ b"\x00" * 8
+ payload
)
def _make_writer():
writer = MagicMock()
written: list[bytes] = []
writer.write.side_effect = written.append
async def _drained():
return None
writer.drain = _drained
return writer, written
# ── Builder / reader unit tests ───────────────────────────────────────────────
def test_der_len_short_form(monkeypatch):
mod = _load_rdp(monkeypatch=monkeypatch)
assert mod._der_len(0) == b"\x00"
assert mod._der_len(0x7F) == b"\x7f"
def test_der_len_long_form(monkeypatch):
mod = _load_rdp(monkeypatch=monkeypatch)
assert mod._der_len(0x80) == b"\x81\x80"
assert mod._der_len(0x100) == b"\x82\x01\x00"
def test_tsrequest_with_token_round_trip(monkeypatch):
mod = _load_rdp(monkeypatch=monkeypatch)
payload = b"NTLMSSP\x00" + b"\x02" + b"\x00" * 31
blob = mod._build_tsrequest_with_token(version=6, ntlm_blob=payload)
# Outer SEQUENCE
assert blob[0] == 0x30
# Find the inner OCTET STRING content, confirm payload is intact
assert payload in blob
def test_read_one_tsrequest_returns_full_blob(monkeypatch):
mod = _load_rdp(monkeypatch=monkeypatch)
payload = mod._build_tsrequest_with_token(6, b"NTLMSSP\x00" + b"\x03" + b"\x00" * 200)
async def _run():
reader = asyncio.StreamReader()
reader.feed_data(payload)
reader.feed_eof()
return await mod._read_one_tsrequest(reader)
out = asyncio.run(_run())
assert out == payload
def test_read_one_tsrequest_rejects_oversized(monkeypatch):
mod = _load_rdp(monkeypatch=monkeypatch)
# Hand-craft a SEQUENCE with body length > 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