#!/usr/bin/env python3 """Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert, und ermittelt die MILDESTE Lockerung, die ihn gelingen laesst. 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 schon vorher gescheitert, bei der Aushandlung von Protokollversion, Cipher-Suite und Signaturverfahren. Die Hosteinstellung "Zertifikat ignorieren" kann daran nichts aendern. Was dieses Skript macht -- dasselbe wie FreeRDP: 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 taugt "openssl s_client -connect ziel:3389" hier NICHT als Gegentest: es faengt sofort mit dem ClientHello an und laeuft ins Leere. 3. Auswerten, welche Sicherheitsstufe der Server waehlt. 4. TLS-Handshake auf derselben Verbindung, mehrfach, mit aufsteigend staerkerer Lockerung der OpenSSL-Vorgaben -- von "unveraendert" bis "alles erlaubt". Die Zertifikatspruefung ist ueberall AUS (wie ignore-cert), damit kein Ergebnis daran haengen kann. Die Reihenfolge ist Absicht: gemeldet wird die ERSTE (= mildeste) Variante, die funktioniert, zusammen mit den passenden Ansible-Variablen. Auf einem Jumphost pauschal auf SECLEVEL=0 zu gehen, wenn SECLEVEL=1 genuegt, waere unnoetig. 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 subprocess import sys import tempfile import warnings # ssl.TLSVersion.TLSv1/TLSv1_1 sind in neueren Python-Fassungen als veraltet # markiert. Hier werden sie absichtlich benutzt -- um festzustellen, OB das # Ziel sie noch spricht. Die Warnungen wuerden die Ausgabe nur zumuellen. warnings.filterwarnings("ignore", category=DeprecationWarning, module=__name__) # 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", } _VERSIONS = { "TLSv1": ssl.TLSVersion.TLSv1, "TLSv1.1": ssl.TLSVersion.TLSv1_1, "TLSv1.2": ssl.TLSVersion.TLSv1_2, "TLSv1.3": ssl.TLSVersion.TLSv1_3, } class Variante: """Eine zu testende OpenSSL-Einstellung. `ansible` ist None, wenn die Variante keine Empfehlung darstellt (System-Standard, oder reine Versionstests zur Information). """ def __init__(self, key, label, *, ciphers=None, min_version=None, max_version=None, ansible=None, kandidat=False): self.key = key self.label = label self.ciphers = ciphers self.min_version = min_version self.max_version = max_version self.ansible = ansible self.kandidat = kandidat # kommt als Empfehlung in Frage? def context(self) -> ssl.SSLContext: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # Wie guacd mit ignore-cert: keinerlei Pruefung. Damit ist # ausgeschlossen, dass ein Ergebnis an der Zertifikatspruefung haengt. ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE if self.ciphers: ctx.set_ciphers(self.ciphers) if self.min_version: ctx.minimum_version = _VERSIONS[self.min_version] if self.max_version: ctx.maximum_version = _VERSIONS[self.max_version] return ctx # Reihenfolge = aufsteigende Staerke der Lockerung. Die erste funktionierende # Variante mit kandidat=True ist die Empfehlung. VARIANTEN = [ Variante("system", "System-Standard (genau das, was guacd/FreeRDP heute bekommt)"), Variante("max12", "unveraendert streng, aber hoechstens TLS 1.2", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 2, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("sec1", "SECLEVEL=1 (SHA-1-Signaturen wieder erlaubt), bis TLS 1.3", ciphers="DEFAULT@SECLEVEL=1", kandidat=True, ansible={"guacd_tls_security_level": 1, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": ""}), Variante("sec1_max12", "SECLEVEL=1 und hoechstens TLS 1.2", ciphers="DEFAULT@SECLEVEL=1", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 1, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("sec0_max12", "SECLEVEL=0, ab TLS 1.2, hoechstens TLS 1.2", ciphers="DEFAULT@SECLEVEL=0", min_version="TLSv1.2", max_version="TLSv1.2", kandidat=True, ansible={"guacd_tls_security_level": 0, "guacd_tls_min_protocol": "TLSv1.2", "guacd_tls_max_protocol": "TLSv1.2"}), Variante("relaxed", "alles erlaubt: ALL@SECLEVEL=0, ab TLS 1.0", ciphers="ALL@SECLEVEL=0", min_version="TLSv1", kandidat=True, ansible={"guacd_tls_security_level": 0, "guacd_tls_min_protocol": "TLSv1", "guacd_tls_max_protocol": ""}), ] # Nur zur Information: welche Versionen spricht das Ziel ueberhaupt? VERSIONSTESTS = [ Variante(f"nur {name}", f"nur {name}", ciphers="ALL@SECLEVEL=0", min_version=name, max_version=name) for name in ("TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3") ] 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 _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 _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 negotiate(sock: socket.socket, requested: int) -> int: """X.224-Aushandlung; gibt das vom Server gewaehlte Protokoll zurueck.""" sock.sendall(_x224_connection_request(requested)) body = _read_tpkt(sock)[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(" list[str]: """Eckdaten des Serverzertifikats -- zur Information, NICHT als Ursache. Wichtige Einordnung (in einem Gegentest am 3.9. nachgeprueft): das Signaturverfahren des Zertifikats ist hier NICHT der Ausloeser. Die Zertifikatspruefung ist ausgeschaltet (wie bei ignore-cert), und ein selbstsigniertes Zertifikat, dessen Signatur niemand prueft, kommt auch mit SHA-1 und Sicherheitsstufe 2 anstandslos durch -- gegengeprueft gegen einen eigens dafuer aufgesetzten Server mit SHA-1-Zertifikat. Was bei einem Abbruch, der erst unterhalb von Sicherheitsstufe 1 verschwindet, tatsaechlich passiert, ist eine Stufe subtiler: OpenSSL 3.0 hat SHA-1 als SIGNATURVERFAHREN ab Stufe 1 verboten und nimmt es deshalb nicht mehr in die signature_algorithms-Erweiterung des ClientHello auf. Bei TLS 1.2 mit ECDHE muss der Server aber den ServerKeyExchange mit einem Verfahren signieren, das der Client angeboten hat. Kann das Windows-Ziel nur mit SHA-1 signieren -- etwa weil in der SCHANNEL-Registry unter "Hashes\\SHA256" Enabled=0 steht oder eine Haertungsvorlage die neueren Hashverfahren abgeschaltet hat --, findet es nichts Verwendbares und legt auf. SCHANNEL schickt dabei ein TCP-RST statt einer sauberen handshake_failure-Warnung: genau das "Connection reset by peer" oben. Diese Angaben helfen trotzdem beim Einordnen des Ziels (Alter, Herkunft, Schluessellaenge des Zertifikats). """ with tempfile.NamedTemporaryFile(suffix=".der") as fh: fh.write(der) fh.flush() try: ausgabe = subprocess.run( ["openssl", "x509", "-inform", "DER", "-in", fh.name, "-noout", "-text"], capture_output=True, text=True, timeout=15, ).stdout except (OSError, subprocess.SubprocessError) as exc: return [f" (Zertifikat nicht auswertbar: {exc})"] sig = kex = subject = None for zeile in ausgabe.splitlines(): z = zeile.strip() if z.startswith("Signature Algorithm:") and sig is None: sig = z.split(":", 1)[1].strip() elif z.startswith("Public-Key:") and kex is None: kex = z.split(":", 1)[1].strip() elif z.startswith("Subject:") and subject is None: subject = z.split(":", 1)[1].strip() return [ f" Inhaber: {subject or '(unbekannt)'}", f" Schluessel: {kex or '(unbekannt)'}", f" Signaturverfahren: {sig or '(unbekannt)'}", " (nur zur Einordnung -- bei abgeschalteter Pruefung nicht die Fehlerursache)", ] def try_handshake(host, port, requested, variante: Variante, timeout): 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' -- kein TLS auf dieser Stufe") with variante.context().wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert(binary_form=True) _ZERTIFIKAT["der"] = cert return (True, f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte") except ssl.SSLError as exc: grund = getattr(exc, "reason", None) or exc return (False, f"{type(exc).__name__}: {grund}") except ConnectionResetError: # Wichtig zu unterscheiden: KEIN lokaler OpenSSL-Abbruch, sondern die # Gegenstelle legt auf. SCHANNEL (Windows) schickt ein TCP-RST statt # einer sauberen handshake_failure-Warnung, wenn im ClientHello nichts # Verwendbares steht -- also ein Aushandlungsproblem, kein Netzfehler. return (False, "Connection reset by peer -- das Ziel hat aufgelegt " "(nichts Passendes im ClientHello gefunden)") 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() 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", "MaxProtocol", "CipherString", "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") 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}\n") print(" Damit eruebrigt 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 print("\n== Schritt 2: TLS-Handshake, aufsteigend gelockert ==") print(" (Zertifikatspruefung ueberall AUS -- wie bei ignore-cert)\n") ergebnisse = {} for v in VARIANTEN: ok, text = try_handshake(args.host, args.port, requested, v, args.timeout) ergebnisse[v.key] = ok print(f" [{'OK ' if ok else 'FEHL'}] {v.label}\n {text}") print("\n== Schritt 3: welche TLS-Versionen spricht das Ziel? ==") gesprochen = [] for v in VERSIONSTESTS: ok, text = try_handshake(args.host, args.port, requested, v, args.timeout) if ok: gesprochen.append(v.label.replace("nur ", "")) print(f" [{'OK ' if ok else 'FEHL'}] {v.label}: {text}") if _ZERTIFIKAT.get("der"): print("\n== Schritt 4: das vom Ziel vorgelegte Zertifikat ==") for zeile in beschreibe_zertifikat(_ZERTIFIKAT["der"]): print(zeile) print("\n== Befund ==") if ergebnisse["system"]: print(" Der TLS-Handshake gelingt bereits mit den System-Vorgaben.") print(" Die Ursache liegt dann NICHT in der TLS-Schicht, sondern eine Stufe") print(" weiter (CredSSP/NLA, Anmeldedaten) oder in guacd/FreeRDP selbst.") print(" Naechster Schritt: guacd_log_level auf 'debug', erneut verbinden,") print(" 'journalctl -u guacd -n 200' auswerten.") return 0 empfehlung = next((v for v in VARIANTEN if v.kandidat and ergebnisse.get(v.key)), None) if empfehlung is None: print(" Der Handshake scheitert in JEDER Variante -- die Ursache liegt am Ziel") print(f" ({args.host}) oder dazwischen, nicht an den OpenSSL-Vorgaben des Jumphosts.") print(" Am Windows-Ziel pruefen: SCHANNEL-Registry (aktivierte TLS-Versionen und") print(" Cipher-Suites), Gruppenrichtlinie 'SSL-Verschluesselungsverfahren', und ob") print(" ueberhaupt ein RDP-Zertifikat hinterlegt ist.") return 1 print(" Der Handshake scheitert an den OpenSSL-Vorgaben DIESES Jumphosts,") print(" nicht am Ziel und nicht am Zertifikat.") print(f" Das Ziel spricht: {', '.join(gesprochen) if gesprochen else '(unklar)'}") print(f"\n MILDESTE ausreichende Lockerung: {empfehlung.label}") print("\n In ansible/inventory/group_vars eintragen:") print(" guacd_tls_legacy_compat: true") for key, value in empfehlung.ansible.items(): gezeigt = '""' if value == "" else (f'"{value}"' if isinstance(value, str) else value) print(f" {key}: {gezeigt}") print("\n Das wirkt NUR auf den guacd-Prozess (Environment=OPENSSL_CONF in dessen") print(" Unit), nicht systemweit. 'DEFAULT' schliesst auch bei Stufe 0 weiterhin") print(" NULL- und anonyme Cipher aus -- es wird also keine ungeschuetzte") print(" Verbindung moeglich, nur eine aeltere.") if not ergebnisse.get("sec1") and not ergebnisse.get("sec1_max12"): print("\n Dass erst Stufe 0 hilft (Stufe 1 UND 2 scheitern gleich), deutet auf die") print(" signature_algorithms-Erweiterung hin: OpenSSL 3.0 bietet ab Stufe 1 kein") print(" SHA-1 mehr an, und das Ziel kann den ServerKeyExchange offenbar nicht") print(" anders signieren. Am Windows-Ziel gegenpruefen (PowerShell, Administrator):") print(" Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\SecurityProviders" "\\SCHANNEL\\Hashes\\SHA256' -ErrorAction SilentlyContinue") print(" Steht dort Enabled = 0, ist das die Wurzel. SHA256/SHA384 wieder") print(" aktivieren, neu starten -- danach guacd_tls_legacy_compat auf false.") return 1 if __name__ == "__main__": sys.exit(main())