跳转到帖子

CTF 密码学实战:从 JWT 弱密钥复现到授权边界与日志验证

精选回复

发布于

背景:签名校验通过,为什么仍然可能越权

CTF 的 Web 密码学题里,JWT 常被用来承载用户身份或角色。一个典型场景是:普通用户登录后得到 HS256 签名的令牌,服务端验证签名,再依据载荷中的 role 决定是否允许访问管理功能。

这类题不一定需要寻找 JWT 库漏洞。如果签名密钥来自题目附件中的开发配置、部署模板或极小的候选集合,问题往往只是 HMAC 密钥强度不足。攻击者一旦恢复密钥,就能够生成签名正确、载荷被修改的令牌。

下面用完全本地的实验复现这条链路:观察令牌结构、离线验证候选密钥、修改角色、确认签名信任边界失效。适用范围是 CTF、靶场,以及明确授权的测试环境,不包含真实站点扫描或在线口令尝试。

问题定位:先区分编码、签名和授权

  • JWT 的 Header 和 Payload 通常只是 Base64URL 编码,不是加密。能够解码出 role,不等于能够修改它。
  • HS256 是 HMAC-SHA256。签名输入是原始编码片段组成的 header.payload,而不是解码后重新排版的 JSON。
  • HMAC 使用共享密钥。持有验证密钥的一方,同时具有签发能力。
  • 签名有效只说明令牌在密码学上被接受,不自动说明业务授权正确。服务端还应检查算法、签发者、受众、有效期和权限状态。

排查时应先确认算法是否确实为 HS256,再检查测试环境的密钥来源。不要把 RS256 的公钥误当作可以直接测试的 HMAC 密钥,也不要为了复现而关闭服务端算法约束。

技术分析:为什么可以离线验证密钥

设令牌为 H.P.S,其中 H、P、S 分别是三个 Base64URL 片段。HS256 的计算关系为:

S = BASE64URL(HMAC_SHA256(secret, ASCII(H + "." + P)))

拿到一枚合法令牌后,签名输入和预期签名都已知。对每个候选密钥计算 HMAC,即可在本地判断候选是否匹配,不需要向认证接口发送请求。因此,登录限速和验证码无法阻止这一步。

这里有两个容易踩坑的细节:第一,计算签名时必须保留原始的 H、P 字符串,不能重新序列化;第二,Base64URL 与普通 Base64 的字符表和补齐规则不同,解码时需要正确处理末尾的等号。

操作步骤:构造一个可复现的 CTF 实验

将下面代码保存为 jwt_lab.py,使用 Python 3 执行即可,不需要安装第三方依赖。代码先模拟题目签发一枚普通用户令牌,再使用一个刻意缩小的候选集合恢复弱密钥,最后生成 role 为 admin 的令牌。

为了保证实验可重复,签发端和分析端放在同一个文件中。真实题目里,分析端通常只有合法令牌和题目提供的线索,不会提前知道签发密钥。

import base64
import hashlib
import hmac
import json
import time


def b64u(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def unb64u(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def encode_json(obj):
    raw = json.dumps(obj, separators=(",", ":")).encode("utf-8")
    return b64u(raw)


def sign(header, payload, key):
    message = header + "." + payload
    mac = hmac.new(key, message.encode("ascii"), hashlib.sha256).digest()
    return message + "." + b64u(mac)


def signature_matches(token, key):
    # 仅验证 HS256 签名,不是完整的 JWT 验证器。
    header, payload, signature = token.split(".")
    metadata = json.loads(unb64u(header))
    if metadata.get("alg") != "HS256":
        return False
    expected = hmac.new(
        key, (header + "." + payload).encode("ascii"), hashlib.sha256
    ).digest()
    return hmac.compare_digest(expected, unb64u(signature))


# 模拟 CTF 题目的签发端:故意使用低强度密钥。
lab_key = b"ctf-dev-secret"
header = encode_json({"alg": "HS256", "typ": "JWT"})
payload = encode_json({
    "sub": "1001", "role": "user", "iss": "ctf-lab",
    "aud": "ctf-api", "exp": int(time.time()) + 600
})
token = sign(header, payload, lab_key)

# 分析端:只使用令牌和实验候选集。
candidates = [b"secret", b"development", b"ctf-dev-secret", b"test-only"]
recovered = next(
    (key for key in candidates if signature_matches(token, key)), None
)
if recovered is None:
    raise SystemExit("候选集未命中,不能据此认定密钥安全")

original_header, original_payload, _ = token.split(".")
claims = json.loads(unb64u(original_payload))
claims["role"] = "admin"
forged = sign(original_header, encode_json(claims), recovered)

print("candidate matched:", recovered is not None)
print("modified claims:", json.loads(unb64u(forged.split(".")[1])))
print("modified signature accepted:", signature_matches(forged, lab_key))

运行命令:

python3 jwt_lab.py

预期结果是 candidate matched 和 modified signature accepted 均为 True,解码后的 role 为 admin。这里证明的是:弱密钥使攻击者能够生成密码学上有效的修改令牌。脚本没有实现业务接口,因此不能仅凭这个输出宣称已经取得管理权限。

授权测试中如何确认影响,而不是夸大结论

在允许令牌替换测试的环境中,建议准备两个专用账号,分别对应普通权限和管理权限,并选择没有写入副作用的权限检查接口。验证顺序如下:

  • 使用普通账号的原始令牌请求管理权限检查接口,记录预期拒绝结果。
  • 仅修改 role,不重新计算签名,确认接口拒绝该令牌。若被接受,应优先排查是否根本没有校验签名。
  • 使用恢复出的实验密钥重新签名,再请求同一接口,观察是否越过原有权限边界。
  • 对比服务端解析出的主体、角色来源和最终授权判定,避免把页面展示变化误认为实际越权。

如果重签名令牌仍然被拒绝,可能是服务端从数据库读取权限、执行了令牌状态检查,或者存在其他声明约束。应继续定位拒绝原因,而不是直接认定密钥恢复失败。相反,即使角色修改没有生效,密钥泄露仍是独立的签发信任风险。

日志留痕:离线恢复过程通常不会出现在服务端

离线候选验证没有请求流量,服务端日志通常只能看到合法令牌的获取,以及后续修改令牌的使用。因此,“没有连续认证失败”不能证明没有发生密钥恢复。

  • 网关记录请求时间、路径、状态码和请求关联 ID,不记录完整 Authorization 头。
  • 认证组件记录校验结果及分类原因,例如签名不匹配、令牌过期、签发者不符、受众不符。
  • 授权组件记录主体 ID、目标资源、所需权限、实际权限来源和最终决策。
  • 关联令牌时优先使用受控的令牌指纹;不要把原始令牌写入应用日志、工单或截图。

特别需要注意:用正确密钥伪造的令牌不会产生“签名错误”。如果系统接受令牌内自带角色,日志甚至可能显示一次正常的管理员访问。检测应关注权限状态是否与服务端账号记录一致,而不是只统计验签失败次数。

风险与修复建议

  • 使用随机密钥:HS256 密钥至少使用 32 字节的密码学安全随机值,不要使用项目名、环境名或可读口令拼接。可用下面的命令生成编码后的随机密钥。
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
  • 统一编码约定:明确配置值是直接作为字符串字节使用,还是先进行 Base64URL 解码。签发端和验证端必须一致,不能各自猜测。
  • 限制密钥分发:共享 HMAC 密钥的验证服务也具备签发能力。如果多个服务只需要验证令牌,可以考虑非对称签名,将私钥限制在签发端。
  • 显式约束验证:生产环境使用成熟 JWT 库,固定允许的算法,并强制要求和校验 iss、aud、exp 等必要声明。上面的实验函数不能替代生产验证器。
  • 明确权限来源:敏感操作应校验当前服务端权限状态,或者配合短有效期和权限版本机制,避免长期信任令牌中的角色快照。
  • 处置已经暴露的密钥:不仅要替换配置,还要让旧密钥退出验证集合,并评估现存令牌的失效范围。保留已泄露的旧密钥作为轮换兼容项,仍然允许继续伪造。
  • 补充回归测试:旧密钥签发的令牌应被拒绝;修改载荷但未重签名的令牌应被拒绝;错误算法、过期令牌和错误受众也应被拒绝。

总结

这类 CTF 密码学题的核心不是“JWT 可以解码”,而是已知消息与签名允许对低强度 HMAC 密钥进行离线验证。完整分析应区分三个结论:候选密钥命中、修改令牌通过验签、业务接口发生越权。三者逐层验证,才能把一道密码学题转化为边界清晰、证据完整、修复可验收的授权测试案例。

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

这个题里容易出现两个误判:Base64URL 解出 payload 不等于签名通过;拿到 HS256 密钥也不等于已经证明越权。建议把证据拆成“签名、授权、审计”三层。

1. 弱密钥复现要验证完整签名链

保存原始 compact JWT,不要重新编码 header 或 payload。题目环境里可以直接用 Hashcat 16500 模式:

printf '%s\n' "$JWT" > token.txt
hashcat -m 16500 -a 0 token.txt wordlist.txt --potfile-path jwt.pot
hashcat -m 16500 token.txt --show --potfile-path jwt.pot

跑出候选密钥后,建议再独立计算一次 HMAC,避免把“字典命中”当作最终结论:

import os
import json
import base64
import hmac
import hashlib

token = os.environ["JWT"].strip()
secret = os.environ["JWT_SECRET"].encode()

h, p, s = token.split(".")

def b64url_decode(v):
    return base64.urlsafe_b64decode(v + "=" * (-len(v) % 4))

header = json.loads(b64url_decode(h))
assert header.get("alg") == "HS256", header

expected = hmac.new(
    secret,
    f"{h}.{p}".encode(),
    hashlib.sha256
).digest()

print("signature_ok =", hmac.compare_digest(expected, b64url_decode(s)))

2. 授权边界要按矩阵测试,不要只改一个 role

  • 原始用户访问自己的资源:作为 200 基线。
  • 原始用户访问其他用户资源:应为 403,或统一返回 404 防止资源枚举。
  • 有效签名但缺少权限的用户访问管理接口:应为 403。
  • 正确签名但已过期、错误 issuer 或 audience:应为 401。
  • 修改 role 后成功,只能说明服务信任该 claim;还应继续测试修改 sub、对象 ID 和 scope,确认权限究竟来自 JWT、数据库角色,还是资源所有权判断。
curl -sS -i \
  -H "Authorization: Bearer $TOKEN_A" \
  "$BASE/api/orders/$ORDER_B"

这里最好明确区分:401 是认证失败,403/404 是身份有效但授权拒绝。如果所有失败都变成 500,通常是中间件异常处理或 claim 类型校验没做好,不能算有效的边界验证。

3. 日志要记录决策依据,而不是记录完整 Token

仅有访问日志中的状态码不够,应用授权点应写入结构化事件,并用同一个 request_id 串联网关、认证中间件和业务授权:

{
  "request_id": "req-8f21",
  "authn": "ok",
  "authz": "deny",
  "reason": "object_owner_mismatch",
  "sub": "u-17",
  "route": "GET /api/orders/:id",
  "object_id": "o-99",
  "alg": "HS256",
  "kid": "lab-1",
  "status": 403
}
RID=req-8f21 jq -c '
  select(.request_id == env.RID) |
  {ts, authn, authz, reason, sub, route, object_id, status}
' app.jsonl

不要把完整 JWT、签名密钥或 Authorization 头写进日志。需要关联同一 Token 时,可以记录 HMAC(log_key, token) 的截断指纹;拒绝原因则保留为内部字段,例如 signature_invalid、expired、aud_mismatch、policy_denied。

修复侧还应固定算法白名单,强制校验 exp、iss、aud,并把 kid 限制在本地可信密钥表中。HS256 密钥至少使用 32 字节随机值,例如 openssl rand -hex 32,而不是业务名称、环境名或可枚举短字符串。

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

最近浏览 0

  • 没有会员查看此页面。