#!/usr/bin/env python3 """Minimal honeypot RDP server. Two operating modes share the same X.224 Connection Request parser: 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. 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 """ 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 X224_CC = 0xD0 # Connection Confirm # rdpNegRequest / Response (MS-RDPBCGR §2.2.1.1.1 / §2.2.1.2.1) TYPE_RDP_NEG_REQ = 0x01 TYPE_RDP_NEG_RSP = 0x02 PROTOCOL_RDP = 0x00000000 PROTOCOL_SSL = 0x00000001 PROTOCOL_HYBRID = 0x00000002 MAX_TPKT_LEN = 8 * 1024 # CR PDUs are tiny; cap to avoid attacker memory pressure _COOKIE_RE = re.compile(rb"Cookie:\s*mstshash=([^\r\n\x00]{1,256})\r\n", re.IGNORECASE) def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) write_syslog_file(line) forward_syslog(line, LOG_TARGET) # ── PDU helpers ─────────────────────────────────────────────────────────────── def _parse_tpkt(buf: bytes) -> bytes | None: """Return the X.224 payload from a single TPKT, or None if malformed.""" if len(buf) < 4 or buf[0] != TPKT_VERSION: return None total_len = int.from_bytes(buf[2:4], "big") if total_len < 7 or total_len > MAX_TPKT_LEN or total_len > len(buf): return None return buf[4:total_len] def _parse_x224_cr(x224: bytes) -> tuple[str | None, int]: """Return (mstshash_cookie, requested_protocols). Cookie is None when absent. requested_protocols is 0 when no rdpNegRequest is included. """ if len(x224) < 7 or x224[1] != X224_CR: return None, 0 # x224[0] = LI (length indicator), x224[1] = CR code (TPDU type) # Variable part follows the fixed 7-byte header. Cookie is ASCII # text terminated by CRLF; rdpNegRequest is the next 8 bytes. var = x224[7:] cookie_match = _COOKIE_RE.search(var) cookie = None if cookie_match: try: cookie = cookie_match.group(1).decode("ascii", errors="replace") except Exception: # noqa: BLE001 cookie = None # rdpNegRequest sits after the cookie's CRLF. Locate by signature # rather than offset since cookie length varies. requested = 0 neg = var if cookie_match: neg = var[cookie_match.end():] if len(neg) >= 8 and neg[0] == TYPE_RDP_NEG_REQ: # Type(1) Flags(1) Length(2 LE) RequestedProtocols(4 LE) requested = int.from_bytes(neg[4:8], "little") return cookie, requested def _build_x224_cc(selected_protocol: int = PROTOCOL_RDP) -> bytes: """Build a TPKT-wrapped X.224 Connection Confirm with rdpNegRsp.""" # rdpNegResponse: Type(1)=0x02 Flags(1)=0x00 Length(2 LE)=0x0008 # SelectedProtocol(4 LE) neg_rsp = bytes([TYPE_RDP_NEG_RSP, 0x00]) + (8).to_bytes(2, "little") + selected_protocol.to_bytes(4, "little") # X.224 CC fixed header: LI=0x0E (14 bytes follow), CC=0xD0, # DST_REF=0, SRC_REF=0x1234 (any), CLASS=0x00 x224 = bytes([0x0E, X224_CC, 0x00, 0x00, 0x12, 0x34, 0x00]) + neg_rsp tpkt = bytes([TPKT_VERSION, 0x00]) + (4 + len(x224)).to_bytes(2, "big") 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] _log("connection", src_ip=src_ip, src_port=src_port) try: # Read TPKT header (4 bytes), then the rest of the PDU hdr = await asyncio.wait_for(reader.readexactly(4), timeout=5.0) if hdr[0] != TPKT_VERSION: return total_len = int.from_bytes(hdr[2:4], "big") if total_len < 7 or total_len > MAX_TPKT_LEN: return rest = await asyncio.wait_for(reader.readexactly(total_len - 4), timeout=5.0) x224 = _parse_tpkt(hdr + rest) if x224 is None: return cookie, requested = _parse_x224_cr(x224) fields: dict = { "src_ip": src_ip, "src_port": src_port, "requested_protocols": requested, } if cookie: fields["username"] = cookie fields["principal"] = cookie _log("rdp_cookie", **fields) else: _log("connection_request", **fields) 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 _log("error", severity=4, src_ip=src_ip, msg=str(exc)) finally: try: writer.close() await writer.wait_closed() except Exception: pass _log("disconnect", src_ip=src_ip, src_port=src_port) async def _main() -> None: _log("startup", msg=f"RDP server starting as {NODE_NAME} on port {LISTEN_PORT}") server = await asyncio.start_server(_handle_client, LISTEN_HOST, LISTEN_PORT) async with server: await server.serve_forever() if __name__ == "__main__": try: asyncio.run(_main()) except KeyboardInterrupt: _log("shutdown")