金鑰已經換了, 真的每一把都換到了嗎?
從一次漏掉 JSON 私鑰的經驗,重建以「身分」為中心的憑證清冊、24 小時輪替流程,以及讓應用程式根本不需要持有私鑰的代理架構。
- 平台匯出的環境變數清單只能說明那個平台現在存了什麼。它不包含過去放進 Git 的檔案、其他排程使用的私鑰,也不涵蓋開發者本機。
- 同一個服務帳戶的金鑰散在 6 個平台變數、5 個 repo 的 key 檔與本機。輪替以「身分」為單位,才不會漏掉副本。
- 根本解法是讓應用程式不持有私鑰:一個無金鑰的 Sheets/Drive 代理,只發可撤銷的呼叫 token。
本篇目錄
為什麼照清單做,還是會漏?
8/27 收到部署平台的外洩通知後,我們依環境變數清單逐一輪替:AWS 金鑰、資料庫密碼,8/30 前完成。看起來很完整。
但那把 GCP 服務帳戶金鑰不在清單上。它以 JSON 檔的形式被 commit 進至少兩個 repo 的 application/chat_bot_log/,也可能被放進某個服務的環境變數。平台的清單是「位置清單」,不是「身分清單」。 於是它多活了 17 天。
這次外洩了什麼
前三列是這次真的外洩的;後面五列沒有證據顯示外洩,但它們是同一份清冊必須涵蓋的類別。清冊不完整的那一天,你不會知道自己漏的是哪一類。
| 服務 | 憑證類型 | 用途 | 存在形式 | 放在哪 | 權限範圍 | 狀態 |
|---|---|---|---|---|---|---|
| GCP:Service Account(IAM 服務帳戶) | Google Service KEY JSON | 訊息機器人讀寫試算表、上傳照片到 Drive | JSON 檔,內含 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 帳密 | 推播訊息、寄信 | 明文環境變數 | 部署平台環境變數、應用程式設定 | 代表你的品牌對外發訊息 | 本案未證實外洩;清冊必須列,外洩後傷的是信譽不是帳單 |
| 平台事件中其他被取走的環境變數 | 不明 | 不明 | 推測為明文環境變數 | 部署平台 | — | 平台未提供事件細節;這正是「以身分為單位輪替」比「照清單輪替」重要的原因 |
一份清冊至少回答這五個問題
清冊只記錄憑證識別資訊與管理方式,不放金鑰明文。敏感值由秘密管理系統保存。
- 這個身分負責什麼業務,由哪個角色維護?
- 有哪些有效憑證,建立與預計到期時間為何?(本案:2020-09-10 建立,無到期)
- 哪些服務使用它,部署在什麼環境?(平台變數、repo、排程、本機)
- 它可以操作哪些資源,是否超出業務需求?(本案:Owner,實際只需寫試算表)
- 撤銷後如何驗證,以及服務如何切換?
輪替的對象是「這個服務帳戶」,不是「這個環境變數」。動作是:列出這個身分的所有金鑰 ID → 建新金鑰 → 找出所有使用位置逐一切換 → 驗證健康 → 刪除所有舊金鑰 ID → 驗證舊金鑰回 invalid_grant。做完最後一步才算輪替完成。
輪替是一個有驗收條件的變更
建立新憑證只完成一半。需要讓合法服務改用新方式,確認健康狀態,再撤銷舊憑證並驗證不能繼續使用。若仍有主動濫用,可能需要先撤銷、接受短暫服務中斷。
本案 9/13 的處置順序:
- 9/13刪除專案內全部 7 把使用者金鑰包含 5 個服務帳戶的既有金鑰;立即讓所有副本失效。
- 9/13拿掉 5 個服務帳戶的 Owner/EditorSheets Bot 不需要任何專案角色,檔案分享給帳戶就夠。
- 9/13 → 9/14建立無金鑰的 Sheets/Drive 代理應用程式改走代理,只拿可撤銷的呼叫 token;代理本身用 Workload Identity。
- 9/14 → 9/15盤點所有 repo 內的 key 檔並移除commit 只是把最新版本刪掉;外部已取得的副本不會因此失效,所以前一步的撤銷才是關鍵。
- 待辦另一個仍是 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,有帳單可查。但把這把金鑰追回來、換掉、再改成不需要私鑰的架構,花掉的是三倍以上的人力。這就是為什麼「清冊」不是文件工作,是省錢工作。
| 項目 | 用量 | 單價 | 期間 | US$ | NT$ | 舉證 |
|---|---|---|---|---|---|---|
| 漏掉這把金鑰的直接帳單 帳單實付。詳細 SKU 拆解見〈一把 Owner 金鑰,被用了 15 天〉 | 15 天被盜用:3 台 VM、Gemini、流量 | 帳單實付 | 8/28 → 9/12 | 146.10 | 4,675 | GCP 帳單報表(依服務/依 SKU 分組)截圖 2026-09-15 |
| 盤點:找出同一個身分的所有副本 估:副本數量可證,工時與日費為估算 | 6 個平台環境變數、5 個 repo 內 key 檔、開發者本機,約 4 小時 | 顧問日費 NT$27,500(估)/8 小時 | 一次性 | 429.69 | 13,750 | 盤點結果(6+5+本機)可證;工時為估算 |
| 撤銷與切換 估:動作與數量有稽核記錄,工時為估算 | 刪 7 把使用者金鑰、拿掉 5 個服務帳戶的 Owner/Editor、逐一驗證健康,約 6 小時 | 同上 | 9/13 | 644.53 | 20,625 | 9/13 稽核記錄可證動作與數量;工時為估算 |
| 改建無金鑰代理 估:這是唯一一次性、不會重複付的支出 | 設計、實作、白名單、切換,約 2 人日 | 同上 | 9/13 → 9/14 | 1718.75 | 55,000 | 代理上線時間可證;工時為估算 |
| 代理的執行成本(Cloud Run) 估:完全落在免費額度內(每月 200 萬次請求、180,000 vCPU·秒、360,000 GiB·秒) | 估每月 30 萬次請求 × 200 ms × 512 MiB | US$0.40/百萬請求;US$0.000024/vCPU·秒 | 每月 | 0.00 | 0 | Cloud Run 官方牌價與免費額度 2026-09;用量為估計值 |
| 秘密保管(Secret Manager) 估:扣掉免費的 6 個版本與 1 萬次存取後的金額。把秘密管好,一個月不到 12 元台幣 | 估 10 個 active secret version、每月 5 萬次存取 | US$0.06/版本·月;US$0.03/萬次存取 | 每月 | 0.36 | 12 | Secret Manager 官方牌價 2026-09;用量為估計值 |
| 攻擊者驗證這批憑證的成本 估:他不需要贏全部,只需要你漏一把 | 一次撈到上百組,自動化全部試一遍 | 一支腳本,邊際成本趨近 0 | 數小時 | 0.00 | 0 | 8/28 首次使用距平台通知僅 36 小時,符合批次自動驗證的節奏;他的實際支出無從得知 |
| 攻擊者拿走的是哪一份副本 時間相關性指向平台事件,但 repo 裡的檔案理論上也可能早被撈走 | 平台環境變數?repo 裡的 key 檔?本機? | — | — | — | — | 平台未提供事件細節;repo 與本機都沒有讀取記錄,無從判定 |
| 合計(僅可證明列) | 146.10 | 4,675 | ||||
帳單 US$146.10,人力 US$2,793。下一次的成本,取決於這次有沒有把清冊做出來。
- 平台事件一次撈到上百組憑證,他不會逐一驗證,會用自動化工具全部試一遍。哪一組還活著就用哪一組。
- 你輪替 AWS 金鑰的那兩天,他正在用 GCP 金鑰測試 AI 模型。他不需要贏全部,只需要你漏一把。
- JSON 金鑰檔沒有到期日。2020 年的金鑰在 2026 年一樣好用。
重點學習
- 01以「身分」為單位輪替整篇的核心。平台給你的是位置清單,外洩的卻是身分;只換位置,就一定會漏。
- 02invalid_grant: account not found輪替的驗收條件。沒有親眼看到舊憑證回這句話,那次輪替就還沒做完。
- 03keys list --managed-by=user同一個服務帳戶可以有很多把金鑰。只刪你知道的那一把,等於沒刪。
- 04Git 歷史裡的秘密commit 刪掉只是刪掉最新版本。外部已取得的副本不會因為你刪檔而失效,撤銷才會。
- 05Workload Identity讓應用程式根本沒有私鑰檔可以被 commit、被複製、被放進環境變數。比任何輪替流程都根本。
- 06短期可撤銷 token把外洩的爆炸半徑從「整個專案」縮成「白名單裡那幾張試算表」。
- 07iam.disableServiceAccountKeyCreation組織政策層級把「發長期私鑰」這件事關掉,例外要走審核。這是唯一擋得住人性的做法。
- 08trufflehog / gitleaks盤點 repo 與部署目錄的標準動作。攻擊者用它撿,你用它先撿一次自己的。
- 09秘密管理系統(Secret Manager)清冊只記識別資訊與管理方式,敏感值放這裡。一個月不到 12 元台幣,比人力便宜太多。
- 10憑證清冊的五個問題誰用、有哪些憑證、部署在哪、能開什麼、怎麼撤銷。出事時能不能在 24 小時內回答,就看這五題。
今天就可以來試試
全部唯讀,不改任何設定;做完你會知道自己有沒有同樣的破口。
- 挑一個你認為「已經處理好」的服務帳戶,列出它所有使用者建立的金鑰。
gcloud iam service-accounts keys list \ --iam-account=SA_EMAIL --managed-by=user \ --format='table(name.basename(), validAfterTime)'看到什麼代表什麼:只要回傳超過一把,或有一把是你不認得的,就代表你的清冊記的是位置、不是身分。 - 把你紀錄中「已撤銷」的金鑰 ID,回到上一步的輸出裡對一次。看到什麼代表什麼:還找得到,代表那次輪替只換了環境變數、沒有真的撤銷。這是本案最貴的一課。
- 在所有 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 刪掉不算處理,撤銷該身分的所有金鑰才算。 - 在本機與部署目錄找散落的金鑰檔。
find ~ /srv /opt /var/www -maxdepth 4 -type f \( -name '*service*account*.json' -o -name '*credential*.json' -o -name '.env' \) -ls 2>/dev/null看到什麼代表什麼:同一把金鑰出現在三個以上的位置,就是本案的形狀:你不會記得全部,所以必須用身分去反查。 - 數一數同一個身分被幾個服務在用。
grep -rl 'GOOGLE_APPLICATION_CREDENTIALS\|GOOGLE_SA_JSON' /path/to/projects 2>/dev/null | wc -l看到什麼代表什麼:這個數字就是你輪替時必須同步切換的服務數量。事前不知道它,事中就會停機。
每篇新文章一封:三點摘要、攻擊者/防守者視角、今天能做的一件事。或先 加入會員 記錄你完成的任務。