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.