- asyncio.Protocol (TCP): _transport: asyncio.Transport | None = None + cast() in connection_made; assert guards in every method that directly accesses the field. Files: pop3, smtp, mqtt, postgres, mssql, mongodb, imap, ldap, redis, mysql, sip, vnc. - asyncio.DatagramProtocol (UDP): _transport: asyncio.DatagramTransport | None = None. Files: snmp, tftp, SIPUDPProtocol. - RDP: assert new_transport is not None after start_tls() to narrow Transport | None. - FTP (Twisted): assert self.transport is not None + targeted type: ignore for imprecise Twisted stubs (misc/override/arg-type/attr-defined), IReactorTCP cast for listenTCP. - conpot: proc.stdout is None guard before iteration. - Bonus fixes surfaced by annotation: - smtp: get_payload(decode=True) bytes narrowing (arg-type on sha256) - postgres: rename shadowed `msg` param to `err_msg` in _handle_startup - mongodb: base64.binascii.Error → import binascii; binascii.Error - imap: result: list[int] = [] (var-annotated)
401 lines
15 KiB
Python
401 lines
15 KiB
Python
#!/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("<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)
|
|
assert new_transport is not None
|
|
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")
|