发布于10小时前10小时 背景:签名校验通过,为什么仍然可能越权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 密钥进行离线验证。完整分析应区分三个结论:候选密钥命中、修改令牌通过验签、业务接口发生越权。三者逐层验证,才能把一道密码学题转化为边界清晰、证据完整、修复可验收的授权测试案例。 网络请求、日志与边界流量分析示意 代码审计、调用链与关键函数定位示意
10小时前10小时 这个题里容易出现两个误判: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,而不是业务名称、环境名或可枚举短字符串。
创建帐户或登录后发表意见