From a8b9c82c97dd285e6cb28f078234fcefed4218ca Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 25 Apr 2026 07:34:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(creds):=20DEBT-040=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20RDP=20X.224=20cookie=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Twisted-based connection logger with an asyncio handler that parses the X.224 Connection Request, extracts the mstshash routing cookie (universal across mstsc / FreeRDP / Hydra / ncrack / MSF rdp_login), records the rdpNegRequest.requestedProtocols flags, and answers with a well-formed X.224 Connection Confirm selecting PROTOCOL_RDP. Scope-down vs. the original DEBT-040 plan: full TS_INFO_PACKET extraction would require either Standard-RDP-Security RC4 stream- cipher implementation (with our own RSA pair + MS-RDPBCGR signing) or a complete MCS+GCC ASN.1/BER stack for the SSL path — both far exceed the 150 LoC budget the DEBT cited. The mstshash cookie 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 available without going down either rabbit hole. Phase 3 (CredSSP/NLA, next commit) is where actual NTLMv2 hashes land. - Drops Twisted dependency from rdp/Dockerfile; adds ntlmssp.py copy ahead of the NLA path that consumes it. - 7 unit tests cover cookie capture, requestedProtocols recording, CC framing, no-cookie path, and oversized/non-TPKT drops. --- decnet/templates/rdp/Dockerfile | 6 +- decnet/templates/rdp/server.py | 188 ++++++++++++++++++++---- tests/service_testing/test_rdp_basic.py | 164 +++++++++++++++++++++ 3 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 tests/service_testing/test_rdp_basic.py 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 == []