diff --git a/decnet/templates/rdp/Dockerfile b/decnet/templates/rdp/Dockerfile index 06ed165f..1d3222f2 100644 --- a/decnet/templates/rdp/Dockerfile +++ b/decnet/templates/rdp/Dockerfile @@ -2,13 +2,11 @@ ARG BASE_IMAGE=debian:bookworm-slim FROM ${BASE_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-pip \ + python3 \ && rm -rf /var/lib/apt/lists/* -ENV PIP_BREAK_SYSTEM_PACKAGES=1 -RUN pip3 install --no-cache-dir twisted jinja2 - COPY syslog_bridge.py /opt/syslog_bridge.py +COPY ntlmssp.py /opt/ntlmssp.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/decnet/templates/rdp/server.py b/decnet/templates/rdp/server.py index 2f61d7b0..f0cf6ac1 100644 --- a/decnet/templates/rdp/server.py +++ b/decnet/templates/rdp/server.py @@ -1,22 +1,60 @@ #!/usr/bin/env python3 -""" -Minimal RDP server using Twisted. -Listens on port 3389, logs connection attempts and any credentials sent -in the initial RDP negotiation request. Forwards events as JSON to -LOG_TARGET if set. +"""Minimal honeypot RDP server (X.224 cookie + protocol negotiation). + +Parses the very first packet of every RDP connection — the X.224 +Connection Request — and extracts: + +* 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. + +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. + +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 +- RFC 1006 (TPKT) §6 """ +from __future__ import annotations + +import asyncio import os +import re -from twisted.internet import protocol, reactor -from twisted.python import log as twisted_log from syslog_bridge import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") -SERVICE_NAME = "rdp" +SERVICE_NAME = "rdp" LOG_TARGET = os.environ.get("LOG_TARGET", "") +LISTEN_HOST = "0.0.0.0" # nosec B104 — honeypot binds all interfaces by design +LISTEN_PORT = 3389 +# 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: @@ -25,31 +63,119 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None: forward_syslog(line, LOG_TARGET) -class RDPServerProtocol(protocol.Protocol): - def connectionMade(self): - peer = self.transport.getPeer() - _log("connection", src_ip=peer.host, src_port=peer.port) - # Send a minimal RDP Connection Confirm PDU to keep clients talking - # X.224 Connection Confirm: length=0x0e, type=0xd0 (CC), dst=0, src=0, class=0 - self.transport.write(b"\x03\x00\x00\x0b\x06\xd0\x00\x00\x00\x00\x00") - - def dataReceived(self, data: bytes): - peer = self.transport.getPeer() - _log("data", src_ip=peer.host, src_port=peer.port, bytes=len(data), hex=data[:64].hex()) - # Drop the connection after receiving data — we're just a logger - self.transport.loseConnection() - - def connectionLost(self, reason): - peer = self.transport.getPeer() - _log("disconnect", src_ip=peer.host, src_port=peer.port) +# ── PDU helpers ─────────────────────────────────────────────────────────────── -class RDPServerFactory(protocol.ServerFactory): - protocol = RDPServerProtocol +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 + + +# ── Connection handler ─────────────────────────────────────────────────────── + + +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) + # 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)) + await writer.drain() + 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__": - twisted_log.startLoggingWithObserver(lambda e: None, setStdout=False) - _log("startup", msg=f"RDP server starting as {NODE_NAME} on port 3389") - reactor.listenTCP(3389, RDPServerFactory()) - reactor.run() + try: + asyncio.run(_main()) + except KeyboardInterrupt: + _log("shutdown") diff --git a/tests/service_testing/test_rdp_basic.py b/tests/service_testing/test_rdp_basic.py new file mode 100644 index 00000000..8dfe8f52 --- /dev/null +++ b/tests/service_testing/test_rdp_basic.py @@ -0,0 +1,164 @@ +"""Tests for decnet/templates/rdp/server.py — X.224 CR cookie capture. + +Drives the asyncio handler with an in-memory StreamReader, asserts: +* mstshash cookie in CR is captured as principal/username. +* rdpNegRequest.requestedProtocols is recorded. +* X.224 Connection Confirm is well-formed and selects PROTOCOL_RDP. +* Malformed / oversized TPKT does not crash the handler. +""" +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_rdp(): + for key in ("rdp_server", "syslog_bridge", "instance_seed"): + sys.modules.pop(key, None) + sys.modules["syslog_bridge"] = make_fake_syslog_bridge() + sys.modules["instance_seed"] = load_real_instance_seed() + 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 + + +@pytest.fixture +def rdp_mod(): + return _load_rdp() + + +# ── PDU builders ────────────────────────────────────────────────────────────── + + +def _x224_connection_request(cookie: str | None = None, requested_protocols: int | None = None) -> bytes: + """Build TPKT(X.224 CR [+ Cookie] [+ rdpNegRequest]).""" + var = b"" + if cookie is not None: + var += f"Cookie: mstshash={cookie}\r\n".encode("ascii") + if requested_protocols is not None: + var += ( + bytes([0x01, 0x00]) + + (8).to_bytes(2, "little") + + requested_protocols.to_bytes(4, "little") + ) + li = 6 + len(var) # length indicator covers bytes after itself + x224 = bytes([li, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00]) + var + tpkt = bytes([0x03, 0x00]) + (4 + len(x224)).to_bytes(2, "big") + return tpkt + x224 + + +def _make_streams(): + reader = asyncio.StreamReader() + writer = MagicMock() + written: list[bytes] = [] + writer.write.side_effect = written.append + writer.get_extra_info.return_value = ("203.0.113.42", 49152) + + async def _drained(): + return None + + async def _wait_closed(): + return None + + writer.drain = _drained + writer.wait_closed = _wait_closed + return reader, writer, written + + +def _drive(rdp_mod, request_bytes: bytes): + async def _run(): + reader, writer, written = _make_streams() + reader.feed_data(request_bytes) + reader.feed_eof() + await asyncio.wait_for(rdp_mod._handle_client(reader, writer), timeout=2.0) + return writer, written + + return asyncio.run(_run()) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_cookie_is_captured_as_principal(): + mod = _load_rdp() + log_mock = sys.modules["syslog_bridge"] + _drive(mod, _x224_connection_request(cookie="alice")) + cookie_calls = [ + c for c in log_mock.syslog_line.call_args_list + if len(c.args) >= 3 and c.args[2] == "rdp_cookie" + ] + assert cookie_calls, "expected an rdp_cookie event" + kwargs = cookie_calls[0].kwargs + assert kwargs["principal"] == "alice" + assert kwargs["username"] == "alice" + + +def test_requested_protocols_recorded(): + mod = _load_rdp() + log_mock = sys.modules["syslog_bridge"] + _drive(mod, _x224_connection_request(cookie="bob", requested_protocols=0x03)) # SSL|HYBRID + cookie_calls = [ + c for c in log_mock.syslog_line.call_args_list + if len(c.args) >= 3 and c.args[2] == "rdp_cookie" + ] + assert cookie_calls + assert cookie_calls[0].kwargs["requested_protocols"] == 0x03 + + +def test_connection_confirm_well_formed(rdp_mod): + _, written = _drive(rdp_mod, _x224_connection_request(cookie="charlie")) + blob = b"".join(written) + assert blob[0] == 0x03 # TPKT version + total = int.from_bytes(blob[2:4], "big") + assert total == len(blob) + # X.224 CC type byte at offset 5 + assert blob[5] == 0xD0 + # rdpNegRsp begins at offset 11; SelectedProtocol at offset 15 (4 bytes LE) + selected = int.from_bytes(blob[15:19], "little") + assert selected == 0x00000000 # PROTOCOL_RDP + + +def test_no_cookie_still_replies(rdp_mod): + _, written = _drive(rdp_mod, _x224_connection_request(cookie=None, requested_protocols=0x00)) + assert written, "server must still reply with X.224 CC even without cookie" + blob = b"".join(written) + assert blob[5] == 0xD0 # CC + + +def test_no_cookie_emits_connection_request_event(): + mod = _load_rdp() + log_mock = sys.modules["syslog_bridge"] + _drive(mod, _x224_connection_request(cookie=None)) + types = [ + c.args[2] for c in log_mock.syslog_line.call_args_list + if len(c.args) >= 3 + ] + assert "connection_request" in types + assert "rdp_cookie" not in types + + +def test_oversized_tpkt_is_dropped(rdp_mod): + # TPKT len = 65535 → above MAX_TPKT_LEN; handler must reject without + # waiting for the full body. + bad = bytes([0x03, 0x00, 0xFF, 0xFF]) + _, written = _drive(rdp_mod, bad) + assert written == [] + + +def test_non_tpkt_first_byte_is_dropped(rdp_mod): + bad = b"\x16\x03\x01\x00\x10" + b"\x00" * 11 # looks like TLS ClientHello + _, written = _drive(rdp_mod, bad) + assert written == []