umbau 1.3.12

This commit is contained in:
2026-09-03 08:42:04 +02:00
parent 2741910a72
commit b2c72de069
4 changed files with 201 additions and 110 deletions

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Stellt genau den TLS-Verbindungsaufbau nach, an dem guacd/FreeRDP scheitert.
"""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.):
@ -14,27 +15,28 @@ 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.
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.
Genau das prueft dieses Skript. Es macht, was FreeRDP macht:
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 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).
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
@ -46,6 +48,12 @@ import socket
import ssl
import struct
import sys
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
@ -72,6 +80,85 @@ _FAILURE_CODES = {
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."""
@ -81,14 +168,6 @@ def _x224_connection_request(requested: int) -> bytes:
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:
@ -99,11 +178,18 @@ def _recv_exact(sock: socket.socket, count: int) -> bytes:
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:
"""Fuehrt die X.224-Aushandlung durch, gibt das gewaehlte Protokoll zurueck."""
"""X.224-Aushandlung; gibt das vom Server gewaehlte Protokoll zurueck."""
sock.sendall(_x224_connection_request(requested))
response = _read_tpkt(sock)
body = response[4:]
body = _read_tpkt(sock)[4:]
if len(body) < 7:
raise ValueError("X.224-Antwort zu kurz")
payload = body[7:]
@ -113,56 +199,33 @@ def negotiate(sock: socket.socket, requested: int) -> int:
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}")
)
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
def _context(variant: str) -> 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."""
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' -- er bietet auf dieser Stufe gar kein TLS an")
ctx = _context(variant)
with ctx.wrap_socket(sock, server_hostname=host) as tls:
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)
info = f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte"
return (True, info)
return (True, f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte")
except ssl.SSLError as exc:
return (False, f"{type(exc).__name__}: {exc.reason if getattr(exc, 'reason', None) else 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}")
@ -174,20 +237,16 @@ def main() -> int:
parser.add_argument("--timeout", type=float, default=10.0)
args = parser.parse_args()
import ssl as _ssl
print(f"Jumphost: {_ssl.OPENSSL_VERSION}")
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"))
]
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")
# 1) Welche Sicherheitsstufe waehlt der Server, wenn man ihm alles anbietet?
print("== Schritt 1: X.224-Aushandlung ==")
requested = PROTOCOL_SSL | PROTOCOL_HYBRID
try:
@ -196,8 +255,8 @@ def main() -> int:
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.")
print(f" FEHLGESCHLAGEN: {exc}\n")
print(" Damit eruebrigt sich der TLS-Test -- die RDP-Vorstufe scheitert bereits.")
return 1
if selected == PROTOCOL_RDP:
@ -206,43 +265,52 @@ def main() -> int:
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"),
]
print("\n== Schritt 2: TLS-Handshake, aufsteigend gelockert ==")
print(" (Zertifikatspruefung ueberall AUS -- wie bei ignore-cert)\n")
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}")
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}")
# 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.")
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
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).")
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 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.")
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. Die saubere Loesung bleibt, das Windows-Ziel")
print(" passend zu konfigurieren -- dies ist die Ueberbrueckung bis dahin.")
return 1