Files
ssh-jumphost/scripts/diagnose_rdp_tls.py
2026-09-03 10:34:01 +02:00

395 lines
18 KiB
Python

#!/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 <ziel>: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("<BBHI", 0x01, 0x00, 8, requested)
# X.224 CR: LI, CR-CDT(0xE0), DST-REF(2), SRC-REF(2), CLASS(1)
x224 = 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("<I", payload[4:8])
raise ValueError("Der Server LEHNT die gewuenschte Sicherheitsstufe ab: "
+ _FAILURE_CODES.get(code, f"unbekannter failureCode 0x{code:02x}"))
if kind != 0x02:
raise ValueError(f"Unerwartete Antwortart 0x{kind:02x} in der X.224-Antwort")
(selected,) = struct.unpack("<I", payload[4:8])
return selected
# Letztes erfolgreich abgeholtes Serverzertifikat (DER) -- fuer die Auswertung
# am Ende. Nicht schoen als Modulvariable, aber dieses Skript ist ein
# Diagnosewerkzeug mit genau einem Durchlauf, kein Baustein zum Wiederverwenden.
_ZERTIFIKAT: dict[str, bytes] = {}
def beschreibe_zertifikat(der: bytes) -> 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())