INC-2026-0827high金鑰輪替復盤

金鑰已經換了, 真的每一把都換到了嗎?

從一次漏掉 JSON 私鑰的經驗,重建以「身分」為中心的憑證清冊、24 小時輪替流程,以及讓應用程式根本不需要持有私鑰的代理架構。

事件期間 2026-08-27 → 09-15·整理於 2026-09-17·8 分鐘·4 可證明1 推論1 缺口
憑證管理事件復盤檢查清單Google Sheets
tl;dr
  1. 平台匯出的環境變數清單只能說明那個平台現在存了什麼。它不包含過去放進 Git 的檔案、其他排程使用的私鑰,也不涵蓋開發者本機。
  2. 同一個服務帳戶的金鑰散在 6 個平台變數、5 個 repo 的 key 檔與本機。輪替以「身分」為單位,才不會漏掉副本。
  3. 根本解法是讓應用程式不持有私鑰:一個無金鑰的 Sheets/Drive 代理,只發可撤銷的呼叫 token。
◉ 攻擊者視角他不需要知道你有幾份副本,只需要其中一份還活著。
◎ 防守者視角清冊記錄的不只是金鑰放哪裡,還要知道它代表誰、能開什麼、誰在用。
本篇目錄
env vars ✓repo json17 天 ✗本機?

為什麼照清單做,還是會漏?

8/27 收到部署平台的外洩通知後,我們依環境變數清單逐一輪替:AWS 金鑰、資料庫密碼,8/30 前完成。看起來很完整。

但那把 GCP 服務帳戶金鑰不在清單上。它以 JSON 檔的形式被 commit 進至少兩個 repo 的 application/chat_bot_log/,也可能被放進某個服務的環境變數。平台的清單是「位置清單」,不是「身分清單」。 於是它多活了 17 天。

這次外洩了什麼

前三列是這次真的外洩的;後面五列沒有證據顯示外洩,但它們是同一份清冊必須涵蓋的類別。清冊不完整的那一天,你不會知道自己漏的是哪一類。

leaked credentials外洩的三類,以及清冊必須涵蓋的其他類別
服務憑證類型用途存在形式放在哪權限範圍狀態
GCP:Service Account(IAM 服務帳戶)Google Service KEY JSON訊息機器人讀寫試算表、上傳照片到 DriveJSON 檔,內含 private_key 的 PEM 區塊;也可能整段 base64 塞進環境變數6 個部署平台環境變數 + 5 個 repo 內的 key 檔 + 開發者本機roles/owner(實際只需要把檔案分享給它)9/13 刪除;這就是漏掉 17 天的那一把
AWS:IAM 使用者access key ID + secret access key應用程式存取後端雲端服務明文環境變數部署平台環境變數(在平台清單上)未逐一比對8/29~8/30 已輪替
資料庫連線帳號密碼應用程式與排程連資料庫明文環境變數或整串連線字串部署平台環境變數(在平台清單上)該資料庫讀寫8/29~8/30 已輪替
Google:OAuth 2.0 用戶端client ID + client secret,以及換來的 refresh token代表使用者授權存取 Google 服務的網頁登入流程JSON 設定檔或明文環境變數;refresh token 常另存在資料庫或本機快取檔應用程式設定、token 快取檔使用者同意的 scope;refresh token 不會自己過期本案未證實外洩,但清冊必須列它:它是最容易被遺忘的長期憑證
GitHub個人存取權杖(PAT)CI 拉私有 repo、自動化推 commit明文字串CI secret、本機 .netrc 或 git 認證快取依 token scope本案未證實外洩;清冊必須列
Google Cloud:API 金鑰API key(AIza 開頭)呼叫地圖、AI 等計費 API明文字串,可能被打包進前端前端程式碼、環境變數依金鑰限制設定本案未證實外洩;清冊必須列,因為它會直接產生帳單
訊息平台與郵件服務channel access token、SMTP 帳密推播訊息、寄信明文環境變數部署平台環境變數、應用程式設定代表你的品牌對外發訊息本案未證實外洩;清冊必須列,外洩後傷的是信譽不是帳單
平台事件中其他被取走的環境變數不明不明推測為明文環境變數部署平台平台未提供事件細節;這正是「以身分為單位輪替」比「照清單輪替」重要的原因
憑證本身已遮蔽或失效;這裡只說明它是哪個服務、拿來做什麼、以什麼形式存放,讓你能對照自己的環境。

一份清冊至少回答這五個問題

清冊只記錄憑證識別資訊與管理方式,不放金鑰明文。敏感值由秘密管理系統保存。

  1. 這個身分負責什麼業務,由哪個角色維護?
  2. 有哪些有效憑證,建立與預計到期時間為何?(本案:2020-09-10 建立,無到期)
  3. 哪些服務使用它,部署在什麼環境?(平台變數、repo、排程、本機)
  4. 它可以操作哪些資源,是否超出業務需求?(本案:Owner,實際只需寫試算表)
  5. 撤銷後如何驗證,以及服務如何切換?
以「身分」為單位的意思

輪替的對象是「這個服務帳戶」,不是「這個環境變數」。動作是:列出這個身分的所有金鑰 ID → 建新金鑰 → 找出所有使用位置逐一切換 → 驗證健康 → 刪除所有舊金鑰 ID → 驗證舊金鑰回 invalid_grant。做完最後一步才算輪替完成。

輪替是一個有驗收條件的變更

建立新憑證只完成一半。需要讓合法服務改用新方式,確認健康狀態,再撤銷舊憑證並驗證不能繼續使用。若仍有主動濫用,可能需要先撤銷、接受短暫服務中斷。

本案 9/13 的處置順序:

  1. 9/13
    刪除專案內全部 7 把使用者金鑰
    包含 5 個服務帳戶的既有金鑰;立即讓所有副本失效。
  2. 9/13
    拿掉 5 個服務帳戶的 Owner/Editor
    Sheets Bot 不需要任何專案角色,檔案分享給帳戶就夠。
  3. 9/13 → 9/14
    建立無金鑰的 Sheets/Drive 代理
    應用程式改走代理,只拿可撤銷的呼叫 token;代理本身用 Workload Identity。
  4. 9/14 → 9/15
    盤點所有 repo 內的 key 檔並移除
    commit 只是把最新版本刪掉;外部已取得的副本不會因此失效,所以前一步的撤銷才是關鍵。
  5. 待辦
    另一個仍是 Owner 且金鑰仍有效的服務帳戶比照處理
    同一個坑不要跳兩次。

受害 VM 重建、原始碼移除秘密、憑證撤銷是三件不同的工作。把金鑰從最新版本刪除,不會讓外部已取得的副本失效。

根本解法:讓應用程式不持有私鑰

輪替做得再好,只要私鑰檔存在,它就會被複製、被 commit、被放進環境變數。最乾淨的做法是私鑰只存在一個地方,而且那個地方不是任何一台應用程式主機

  應用程式(Bot、排程、後台)
        │  帶一個可撤銷的呼叫 token
        ▼
  sheets-proxy(Cloud Run,Workload Identity,無金鑰檔)
        │  以服務帳戶身分呼叫
        ▼
  Google Sheets / Drive API
  • 應用程式只拿到一個對代理的 token,可以隨時個別撤銷,權限只有「透過代理讀寫被白名單的試算表」。
  • 代理本身用 Workload Identity 取得 Google 憑證,沒有 JSON 檔可以外洩。
  • 白名單限定哪些 token 能碰哪些檔案。就算 token 外洩,攻擊者也開不了 VM、叫不了 AI。

每次復盤,都讓下一次少一個盲點

  • 把輪替紀錄、例外權限與尚待查證項目集中管理。
  • 對高權限身分定期複核,優先減少必須長期保存的秘密。
  • 組織政策:禁止建立服務帳戶金鑰(iam.disableServiceAccountKeyCreation),例外要審核。
  • 不要刪除擁有大量 Drive 檔案的服務帳戶本身,刪除會連檔案一起消失。拿掉權限、刪金鑰就好。

漏掉一把金鑰,帳單只是零頭

外洩本身的帳單是 US$146.10,有帳單可查。但把這把金鑰追回來、換掉、再改成不需要私鑰的架構,花掉的是三倍以上的人力。這就是為什麼「清冊」不是文件工作,是省錢工作。

cost這次補救的帳:帳單一列,人力五列
項目用量單價期間US$NT$舉證
漏掉這把金鑰的直接帳單
帳單實付。詳細 SKU 拆解見〈一把 Owner 金鑰,被用了 15 天〉
15 天被盜用:3 台 VM、Gemini、流量帳單實付8/28 → 9/12146.104,675GCP 帳單報表(依服務/依 SKU 分組)截圖 2026-09-15
盤點:找出同一個身分的所有副本
估:副本數量可證,工時與日費為估算
6 個平台環境變數、5 個 repo 內 key 檔、開發者本機,約 4 小時顧問日費 NT$27,500(估)/8 小時一次性429.6913,750盤點結果(6+5+本機)可證;工時為估算
撤銷與切換
估:動作與數量有稽核記錄,工時為估算
刪 7 把使用者金鑰、拿掉 5 個服務帳戶的 Owner/Editor、逐一驗證健康,約 6 小時同上9/13644.5320,6259/13 稽核記錄可證動作與數量;工時為估算
改建無金鑰代理
估:這是唯一一次性、不會重複付的支出
設計、實作、白名單、切換,約 2 人日同上9/13 → 9/141718.7555,000代理上線時間可證;工時為估算
代理的執行成本(Cloud Run)
估:完全落在免費額度內(每月 200 萬次請求、180,000 vCPU·秒、360,000 GiB·秒)
估每月 30 萬次請求 × 200 ms × 512 MiBUS$0.40/百萬請求;US$0.000024/vCPU·秒每月0.000Cloud Run 官方牌價與免費額度 2026-09;用量為估計值
秘密保管(Secret Manager)
估:扣掉免費的 6 個版本與 1 萬次存取後的金額。把秘密管好,一個月不到 12 元台幣
估 10 個 active secret version、每月 5 萬次存取US$0.06/版本·月;US$0.03/萬次存取每月0.3612Secret Manager 官方牌價 2026-09;用量為估計值
攻擊者驗證這批憑證的成本
估:他不需要贏全部,只需要你漏一把
一次撈到上百組,自動化全部試一遍一支腳本,邊際成本趨近 0數小時0.0008/28 首次使用距平台通知僅 36 小時,符合批次自動驗證的節奏;他的實際支出無從得知
攻擊者拿走的是哪一份副本
時間相關性指向平台事件,但 repo 裡的檔案理論上也可能早被撈走
平台環境變數?repo 裡的 key 檔?本機?平台未提供事件細節;repo 與本機都沒有讀取記錄,無從判定
合計(僅可證明列)146.104,675
匯率假設 1 USD ≈ NT$32。單價為公開牌價或帳單實付,來源:GCP 帳單報表截圖 2026-09-15(外洩費用);Cloud Run 與 Secret Manager 官方牌價,查價 2026-09;工時為本案處置紀錄,日費為假設值。「舉證」欄標明每筆損失的證據來源與等級( 可證明 推論 缺口)。

帳單 US$146.10,人力 US$2,793。下一次的成本,取決於這次有沒有把清冊做出來。

他賭的是你的清冊不完整。
  • 平台事件一次撈到上百組憑證,他不會逐一驗證,會用自動化工具全部試一遍。哪一組還活著就用哪一組。
  • 你輪替 AWS 金鑰的那兩天,他正在用 GCP 金鑰測試 AI 模型。他不需要贏全部,只需要你漏一把。
  • JSON 金鑰檔沒有到期日。2020 年的金鑰在 2026 年一樣好用。

重點學習

keywords重點學習:最需要學習的 10 個關鍵字10
  1. 01
    以「身分」為單位輪替
    整篇的核心。平台給你的是位置清單,外洩的卻是身分;只換位置,就一定會漏。
  2. 02
    invalid_grant: account not found
    輪替的驗收條件。沒有親眼看到舊憑證回這句話,那次輪替就還沒做完。
  3. 03
    keys list --managed-by=user
    同一個服務帳戶可以有很多把金鑰。只刪你知道的那一把,等於沒刪。
  4. 04
    Git 歷史裡的秘密
    commit 刪掉只是刪掉最新版本。外部已取得的副本不會因為你刪檔而失效,撤銷才會。
  5. 05
    Workload Identity
    讓應用程式根本沒有私鑰檔可以被 commit、被複製、被放進環境變數。比任何輪替流程都根本。
  6. 06
    短期可撤銷 token
    把外洩的爆炸半徑從「整個專案」縮成「白名單裡那幾張試算表」。
  7. 07
    iam.disableServiceAccountKeyCreation
    組織政策層級把「發長期私鑰」這件事關掉,例外要走審核。這是唯一擋得住人性的做法。
  8. 08
    trufflehog / gitleaks
    盤點 repo 與部署目錄的標準動作。攻擊者用它撿,你用它先撿一次自己的。
  9. 09
    秘密管理系統(Secret Manager)
    清冊只記識別資訊與管理方式,敏感值放這裡。一個月不到 12 元台幣,比人力便宜太多。
  10. 10
    憑證清冊的五個問題
    誰用、有哪些憑證、部署在哪、能開什麼、怎麼撤銷。出事時能不能在 24 小時內回答,就看這五題。

今天就可以來試試

try today今天就可以來試試15 分鐘

全部唯讀,不改任何設定;做完你會知道自己有沒有同樣的破口。

  1. 挑一個你認為「已經處理好」的服務帳戶,列出它所有使用者建立的金鑰。
    gcloud iam service-accounts keys list \
      --iam-account=SA_EMAIL --managed-by=user \
      --format='table(name.basename(), validAfterTime)'
    看到什麼代表什麼:只要回傳超過一把,或有一把是你不認得的,就代表你的清冊記的是位置、不是身分。
  2. 把你紀錄中「已撤銷」的金鑰 ID,回到上一步的輸出裡對一次。
    看到什麼代表什麼:還找得到,代表那次輪替只換了環境變數、沒有真的撤銷。這是本案最貴的一課。
  3. 在所有 repo 裡找 JSON 私鑰檔,連歷史一起找。
    grep -rIl 'private_key_id' --include='*.json' .
    git log --all --name-only -S 'BEGIN.*PRIVATE KEY' --pretty=format:'%h %ad' | head -40
    看到什麼代表什麼:歷史裡命中就是已外洩。把它 commit 刪掉不算處理,撤銷該身分的所有金鑰才算。
  4. 在本機與部署目錄找散落的金鑰檔。
    find ~ /srv /opt /var/www -maxdepth 4 -type f \( -name '*service*account*.json' -o -name '*credential*.json' -o -name '.env' \) -ls 2>/dev/null
    看到什麼代表什麼:同一把金鑰出現在三個以上的位置,就是本案的形狀:你不會記得全部,所以必須用身分去反查。
  5. 數一數同一個身分被幾個服務在用。
    grep -rl 'GOOGLE_APPLICATION_CREDENTIALS\|GOOGLE_SA_JSON' /path/to/projects 2>/dev/null | wc -l
    看到什麼代表什麼:這個數字就是你輪替時必須同步切換的服務數量。事前不知道它,事中就會停機。
newsletter
下一篇拆解直接寄到信箱

每篇新文章一封:三點摘要、攻擊者/防守者視角、今天能做的一件事。或先 加入會員 記錄你完成的任務。