#!/usr/bin/env python3 """Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert. Hintergrund (Live-Test 3.9.): guacd meldet "RDP server closed/refused connection: SSL/TLS connection failed (untrusted/self-signed certificate?)" und die Anwendung reicht das als "Ziel nicht erreichbar (Code 519)" weiter. Der Klammerzusatz ist eine VERMUTUNG von guacd, kein Befund -- guacd setzt diesen festen Text fuer FreeRDPs Fehlercode ERRCONNECT_TLS_CONNECT_FAILED. Dass es NICHT am Zertifikat liegt, zeigt das guacd-Journal selbst: guacd protokolliert beim Pruefen eines Zertifikats immer eine der beiden Zeilen "Certificate validation bypassed" (ignore-cert greift) oder "Certificate validation failed". Steht KEINE von beiden im Journal, wurde der Zertifikats-Rueckruf nie erreicht -- der Handshake ist also schon vorher gescheitert, bei der Aushandlung von Protokollversion und Cipher. Die Hosteinstellung "Zertifikat ignorieren" kann daran nichts aendern. Genau das prueft dieses Skript. Es macht, was FreeRDP macht: 1. TCP zu :3389 2. X.224-Verbindungsanfrage mit RDP_NEG_REQ (MS-RDPBCGR 2.2.1.1) -- das ist die RDP-eigene Vorstufe, ohne die kein RDP-Server TLS spricht. Deshalb funktioniert "openssl s_client -connect ziel:3389" hier auch nicht: es faengt sofort mit ClientHello an und laeuft ins Leere. 3. Auswerten, welche Sicherheitsstufe der Server auswaehlt 4. TLS-Handshake auf derselben Verbindung -- und zwar MEHRFACH, mit verschiedenen OpenSSL-Vorgaben: a) System-Standard: exakt das, was guacd/FreeRDP auf diesem Host bekommen. Debian 12 setzt in /etc/ssl/openssl.cnf MinProtocol = TLSv1.2 und CipherString = DEFAULT@SECLEVEL=2. b) Alles erlaubt (SECLEVEL=0, ab TLS 1.0) c/d) TLS 1.0 bzw. TLS 1.2 einzeln erzwungen Der Vergleich sagt, ob die OpenSSL-Vorgaben des JUMPHOSTS die Ursache sind (a scheitert, b klappt) oder das Ziel selbst (alles scheitert). Braucht nichts ausser Python 3 -- kein X11, kein xfreerdp, kein nmap. Aufruf: python3 scripts/diagnose_rdp_tls.py 10.0.0.12 """ from __future__ import annotations import argparse import socket import ssl import struct import sys # MS-RDPBCGR 2.2.1.1.1 -- requestedProtocols PROTOCOL_RDP = 0x00000000 # "Native RDP", ohne TLS PROTOCOL_SSL = 0x00000001 # TLS PROTOCOL_HYBRID = 0x00000002 # CredSSP/NLA (setzt TLS voraus) PROTOCOL_RDSTLS = 0x00000004 PROTOCOL_HYBRID_EX = 0x00000008 _PROTOCOL_NAMES = { PROTOCOL_RDP: "Native RDP (ohne TLS)", PROTOCOL_SSL: "TLS", PROTOCOL_HYBRID: "CredSSP/NLA (ueber TLS)", PROTOCOL_RDSTLS: "RDSTLS", PROTOCOL_HYBRID_EX: "CredSSP Early User Auth", } # MS-RDPBCGR 2.2.1.2.2 -- failureCode im RDP_NEG_FAILURE _FAILURE_CODES = { 0x01: "SSL_REQUIRED_BY_SERVER -- der Server verlangt TLS, es wurde keines angeboten", 0x02: "SSL_NOT_ALLOWED_BY_SERVER -- der Server erlaubt kein TLS (Sicherheitsebene 'RDP')", 0x03: "SSL_CERT_NOT_ON_SERVER -- der Server hat kein Zertifikat fuer TLS", 0x04: "INCONSISTENT_FLAGS", 0x05: "HYBRID_REQUIRED_BY_SERVER -- der Server verlangt NLA", 0x06: "SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER", } def _x224_connection_request(requested: int) -> bytes: """TPKT + X.224 Connection Request mit angehaengtem RDP_NEG_REQ.""" neg_req = struct.pack("BBHHB", 6 + len(neg_req), 0xE0, 0, 0, 0) + neg_req return struct.pack(">BBH", 0x03, 0x00, 4 + len(x224)) + x224 def _read_tpkt(sock: socket.socket) -> bytes: header = _recv_exact(sock, 4) if header[0] != 0x03: raise ValueError(f"Keine TPKT-Antwort (erstes Byte 0x{header[0]:02x}) -- spricht dort wirklich RDP?") (length,) = struct.unpack(">H", header[2:4]) return header + _recv_exact(sock, length - 4) def _recv_exact(sock: socket.socket, count: int) -> bytes: buf = b"" while len(buf) < count: chunk = sock.recv(count - len(buf)) if not chunk: raise ConnectionError("Gegenstelle hat die Verbindung waehrend der Aushandlung geschlossen") buf += chunk return buf def negotiate(sock: socket.socket, requested: int) -> int: """Fuehrt die X.224-Aushandlung durch, gibt das gewaehlte Protokoll zurueck.""" sock.sendall(_x224_connection_request(requested)) response = _read_tpkt(sock) body = response[4:] if len(body) < 7: raise ValueError("X.224-Antwort zu kurz") payload = body[7:] if not payload: # Kein RDP_NEG_RSP: aeltere Server antworten so und meinen "Native RDP". return PROTOCOL_RDP kind = payload[0] if kind == 0x03: (code,) = struct.unpack(" ssl.SSLContext: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # Wie guacd mit ignore-cert: keinerlei Pruefung. Damit ist ausgeschlossen, # dass ein Ergebnis hier an der Zertifikatspruefung haengt. ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE if variant == "system": return ctx # erbt /etc/ssl/openssl.cnf -- genau wie guacd if variant == "relaxed": ctx.minimum_version = ssl.TLSVersion.TLSv1 ctx.set_ciphers("ALL:@SECLEVEL=0") return ctx if variant in ("tls1.0", "tls1.1", "tls1.2", "tls1.3"): version = { "tls1.0": ssl.TLSVersion.TLSv1, "tls1.1": ssl.TLSVersion.TLSv1_1, "tls1.2": ssl.TLSVersion.TLSv1_2, "tls1.3": ssl.TLSVersion.TLSv1_3, }[variant] ctx.set_ciphers("ALL:@SECLEVEL=0") ctx.minimum_version = version ctx.maximum_version = version return ctx raise ValueError(variant) def try_handshake(host: str, port: int, requested: int, variant: str, timeout: float): """Gibt (erfolg, text) zurueck.""" try: with socket.create_connection((host, port), timeout=timeout) as sock: selected = negotiate(sock, requested) if selected == PROTOCOL_RDP and requested != PROTOCOL_RDP: return (False, "Server waehlt 'Native RDP' -- er bietet auf dieser Stufe gar kein TLS an") ctx = _context(variant) with ctx.wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert(binary_form=True) info = f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte" return (True, info) except ssl.SSLError as exc: return (False, f"{type(exc).__name__}: {exc.reason if getattr(exc, 'reason', None) else exc}") except (OSError, ValueError) as exc: return (False, f"{type(exc).__name__}: {exc}") def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("host", help="Adresse des RDP-Ziels, z.B. 10.0.0.12") parser.add_argument("--port", type=int, default=3389) parser.add_argument("--timeout", type=float, default=10.0) args = parser.parse_args() import ssl as _ssl print(f"Jumphost: {_ssl.OPENSSL_VERSION}") try: with open("/etc/ssl/openssl.cnf", encoding="utf-8") as fh: relevant = [ line.strip() for line in fh if line.strip().startswith(("MinProtocol", "CipherString", "MaxProtocol", "Options")) ] print(" /etc/ssl/openssl.cnf: " + ("; ".join(relevant) if relevant else "keine Protokoll-/Cipher-Vorgaben")) except OSError: print(" /etc/ssl/openssl.cnf nicht lesbar") print(f"Ziel: {args.host}:{args.port}\n") # 1) Welche Sicherheitsstufe waehlt der Server, wenn man ihm alles anbietet? print("== Schritt 1: X.224-Aushandlung ==") requested = PROTOCOL_SSL | PROTOCOL_HYBRID try: with socket.create_connection((args.host, args.port), timeout=args.timeout) as sock: selected = negotiate(sock, requested) print(f" Angeboten: TLS + CredSSP/NLA -> Server waehlt: " f"{_PROTOCOL_NAMES.get(selected, hex(selected))}") except Exception as exc: print(f" FEHLGESCHLAGEN: {exc}") print("\n Damit erübrigt sich der TLS-Test -- die RDP-Vorstufe scheitert bereits.") return 1 if selected == PROTOCOL_RDP: print("\n BEFUND: Das Ziel bietet auf der ausgehandelten Stufe kein TLS an.") print(" Am Windows-Ziel die Sicherheitsebene auf 'Verhandeln' oder 'SSL' stellen") print(" (GPO: 'Bestimmte Sicherheitsebene fuer RDP-Verbindungen anfordern').") return 1 # 2) TLS-Handshake unter verschiedenen OpenSSL-Vorgaben print("\n== Schritt 2: TLS-Handshake (Zertifikatspruefung ueberall AUS, wie ignore-cert) ==") varianten = [ ("system", "System-Standard (das, was guacd/FreeRDP bekommt)"), ("relaxed", "Alles erlaubt (SECLEVEL=0, ab TLS 1.0)"), ("tls1.0", "nur TLS 1.0"), ("tls1.1", "nur TLS 1.1"), ("tls1.2", "nur TLS 1.2"), ("tls1.3", "nur TLS 1.3"), ] ergebnisse = {} for variant, label in varianten: ok, text = try_handshake(args.host, args.port, requested, variant, args.timeout) ergebnisse[variant] = ok print(f" [{'OK ' if ok else 'FEHL'}] {label}\n {text}") # 3) Auswertung print("\n== Befund ==") if ergebnisse["system"]: print(" Der TLS-Handshake gelingt mit den System-Vorgaben. Die Ursache liegt") print(" dann NICHT in der TLS-Schicht, sondern eine Stufe weiter (CredSSP/NLA,") print(" Anmeldedaten) oder in guacd/FreeRDP selbst.") print(" Naechster Schritt: guacd_log_level auf 'debug' und erneut verbinden.") return 0 if ergebnisse["relaxed"]: moeglich = [v for v in ("tls1.0", "tls1.1", "tls1.2", "tls1.3") if ergebnisse[v]] print(" EINDEUTIG: Der Handshake scheitert NUR an den OpenSSL-Vorgaben DIESES") print(" Jumphosts, nicht am Ziel und nicht am Zertifikat.") print(f" Das Ziel spricht: {', '.join(moeglich) if moeglich else '(nur mit SECLEVEL=0)'}") print(" Debian 12 setzt systemweit MinProtocol=TLSv1.2 und DEFAULT@SECLEVEL=2;") print(" guacd erbt das. Abhilfe: eine eigene OPENSSL_CONF NUR fuer die") print(" guacd-Unit (nicht systemweit lockern).") return 1 print(" Der Handshake scheitert in JEDER Variante -- die Ursache liegt am Ziel") print(" (10.0.0.12) oder dazwischen, nicht an den OpenSSL-Vorgaben des Jumphosts.") print(" Am Windows-Ziel pruefen: SCHANNEL-Registry (aktivierte TLS-Versionen),") print(" Cipher-Suite-Richtlinie, und ob ein RDP-Zertifikat hinterlegt ist.") return 1 if __name__ == "__main__": sys.exit(main())