251 lines
11 KiB
Python
251 lines
11 KiB
Python
#!/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 <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).
|
|
|
|
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("<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 _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("<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
|
|
|
|
|
|
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."""
|
|
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())
|