feat(creds): DEBT-040 Phase 2 — RDP X.224 cookie capture

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.
This commit is contained in:
2026-04-25 07:34:42 -04:00
parent 6905c88083
commit a8b9c82c97
3 changed files with 323 additions and 35 deletions

View File

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

View File

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

View File

@@ -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 == []