此漏洞利用仅通过服务器的公开可用 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 字节):