我們寫了一道關卡防自己,第二次部署它就亮了紅燈
沒有攻擊、沒有外洩、沒有帳單,這是一份設定稽核紀錄:一支只該讀寫試算表的 GCP:Service Account,在專案層是 Owner。拿掉只要一行,拿掉前要先查什麼?
- sheets-proxy 以一個 GCP:Service Account 的身分在 Cloud Run 上跑,靠自我模擬換出只有 Drive/Sheets 範圍的短期 token;它需要的權限全部來自「檔案有沒有分享給它」,不需要任何專案層角色。
- 部署腳本第 6 步會列出這個帳戶在專案層的角色,要求必須為空。今天印出 roles/owner:帳戶能開 VM、改防火牆、叫 AI、建金鑰。同一個破口形狀,5 天前才付過帳單。
- 拿掉是一行指令,proxy 不受影響。拿掉前要做的只有一件事:查稽核記錄,確認過去 30 天沒有別的程式用這個帳戶呼叫 Drive/Sheets 以外的 API。
一支只該讀寫試算表的帳戶,在專案層是 Owner
把 GCP 專案想成一棟辦公大樓。檔案分享是「哪幾間會議室的門卡給了你」;專案層 IAM 角色則是「你在整棟大樓的身分」。roles/owner 是大樓管理員:每一扇門都開得了,還能發門卡給別人、裝新的門。
我們的 sheets-proxy 是一支跑在 Cloud Run 上的小程式,替各個應用讀寫 Google 試算表與 Drive,讓應用端不必持有任何 Google 私鑰。它用一個 GCP:Service Account 的身分執行,需要的只有幾間會議室的門卡——白名單裡的試算表和資料夾分享給它就夠了。它不需要在大樓裡有任何身分。
昨天晚上重新部署時,它被自己的部署腳本抓到:這個帳戶在專案層綁著 roles/owner。先講清楚這篇的性質,沒有攻擊、沒有外洩、沒有帳單,這是一份寫在處置完成之前的設定稽核紀錄。距離上一次因為同樣形狀真的付過錢,才過了五天。
- 2026-09-12Owner 金鑰案:一支只寫試算表的 Bot 帳戶是 Owner,金鑰外洩 15 天,付了帳單也被停權 3 天本站防暴筆記 INC-2026-0827。
- 2026-09-13 → 14建 sheets-proxy,應用端不再持有 Google 私鑰;部署腳本加入「專案 IAM 必須為空」檢查SOP repo 的 deploy.sh 與部署提示。
- 2026-09-16這支 proxy 首次部署,供活動網站上傳圖片變更紀錄。
- 2026-09-17 21:4x為了新的共用雲端硬碟重新部署,第 6 步印出 roles/owner;gcloud 確認部署輸出與 gcloud projects get-iam-policy。
- 不明這個 Owner 角色是什麼時候、為了什麼被綁上去的尚未查稽核記錄。
這次沒有東西外洩,但把這個帳戶手上的憑證攤開來,能看出一件反直覺的事。
| 服務 | 憑證類型 | 用途 | 存在形式 | 放在哪 | 權限範圍 | 狀態 |
|---|---|---|---|---|---|---|
| GCP IAM 服務帳戶(sheets-proxy 的執行身分) | 沒有金鑰檔;只有 Cloud Run 附加身分換出的短期 access token | 以自我模擬取得 Drive/Sheets 範圍 token,代替各應用讀寫白名單內的試算表與資料夾 | 短期 OAuth access token(記憶體內,約 1 小時,不落地);本案沒有 JSON 金鑰 | 只存在 Cloud Run 執行個體記憶體 | 應該只有檔案層分享;實際上還有專案層 roles/owner | 未外洩。Owner 角色待移除 |
注意「存在形式」那一欄:這個帳戶本來就沒有金鑰檔,攻擊者撿不到 JSON。這是好消息,卻也正是它更不該有 Owner 的理由——唯一能冒用它的方式是攻破 proxy 本身或 Cloud Run,而一旦攻破,Owner 讓損害從「幾張試算表」變成「整個專案」。
抓到它的不是人,是部署腳本寫死的一行檢查
做完 Owner 金鑰案的復盤之後,我們把一件事寫進了部署腳本:第 6 步查這個帳戶在專案層有什麼角色,必須為空。昨晚為了把新的共用雲端硬碟加進白名單而重新部署,第 6 步印出了兩個字。
==> 6. 專案 IAM 是否仍乾淨(必須為空) roles/owner
腳本亮紅燈的第一反應不該是相信它,而是再查一次。用 gcloud projects get-iam-policy 直接確認,兩個來源一致。接下來的判斷都建立在四條線索上:三條能證明,一條只能推論,還有一條是動手前必須先補的缺口。
三條能證明的說的是同一件事:這個角色存在,而且沒有存在的理由。推論那條解釋它為什麼會在;缺口那條決定我們能多快把它拿掉。
這次沒有攻擊者,但路線圖五天前就跑過一次
下面這條鏈,本案一個階段都沒有發生,所以每一格都標問號。它不是憑空推測的:除了第一步的入口不同,其餘每一步 INC-2026-0827 都實際跑過一遍。
把兩件事並排,差別只有一個條件。
| Owner 金鑰案(INC-2026-0827) | 今天 | |
|---|---|---|
| 帳戶的工作 | LINE Bot 讀寫試算表 | proxy 讀寫試算表與 Drive |
| 實際權限 | roles/owner | roles/owner |
| 憑證形式 | Google Service KEY JSON,6 年沒換,散在 repo 與部署平台 | 沒有金鑰檔,只有短期 token |
| 被冒用的途徑 | 金鑰外洩 | 要攻破 proxy 或 Cloud Run |
| 結果 | 15 天,付了帳單也被停權 | 還沒發生 |
Owner 金鑰案教的是第一道防線「不發金鑰」;今天這件事教的是第二道,外洩了也要沒權限。第一道做好了,不代表第二道可以省。
我們怎麼處理:先查稽核記錄,再拿掉那一行綁定
偵測在昨晚 21 點多就結束了:腳本第 6 步印出 roles/owner,gcloud projects get-iam-policy 複查一致,確認不是腳本誤判。
接著是隔離,而這一步意外地輕鬆——因為根本沒有東西需要隔離。帳戶沒有金鑰檔可以撤銷,憑證只有 Cloud Run 執行個體記憶體裡約一小時的短期 token,也沒有任何外洩跡象。這份輕鬆不是運氣,是第一道防線當初做對了的紅利。
真正的清除動作還沒做,因為它有一個前提:先查 30 天稽核記錄,確認沒有別的程式用這個帳戶呼叫 Drive/Sheets 以外的 API。不知道誰在用就直接拔,換來的會是另一場事故。確認之後才移除專案層的 roles/owner 綁定。
最後的驗收條件也已經寫好:重跑部署腳本第 6 步,輸出必須回到空的;再打一次 proxy 的 whoami,確認它本來就不需要那個角色。這兩件事沒做完,這條待辦不算結案。
這次做對了三件,漏了四件
做對的第一件,是把復盤結論寫進部署腳本而不是待辦清單——待辦清單會過期,腳本不會。第二件是腳本亮紅燈後沒有直接相信它,而是用 gcloud 再查一次。第三件從更早就開始了:proxy 從設計就不發金鑰檔,靠自我模擬換短期 token,應用端不持有任何 Google 私鑰。還有一個比較不像「做對」的做對:發現之後沒有為了乾淨直接拔掉角色,而是先問「有沒有別人在用」。
漏掉的四件比較刺眼。9/16 第一次部署時,第 6 步的輸出印在畫面上,卻沒有人看——一個只會提醒、不會中止的檢查,跟沒有檢查的差別只在心理安慰。這個角色是誰、什麼時候、為什麼綁上去的,到現在還沒查 SetIamPolicy 稽核記錄。沒有對 SetIamPolicy 設記錄型警示,所以下次有人順手給 Owner,一樣要等下一次部署才會知道。而同樣的檢查目前只存在這一支部署腳本裡,其他服務都沒有。
四件漏掉的事裡有三件是同一個病根:我們把「檢查」做成了輸出,沒做成關卡。
如何避免
當天就能做完的有三件。先列出專案裡所有掛 roles/owner 或 roles/editor 的服務帳戶,每一個都問同一句:它的工作需要專案角色嗎。再查 30 天稽核記錄,確認這個帳戶沒有呼叫過 Drive/Sheets 以外的 API。確認之後才移除綁定,然後重跑部署腳本第 6 步,輸出必須是空的。
這幾週要補的是把檢查變成關卡:把「專案 IAM 必須為空」放進每一支部署腳本,而且不為空就中止部署,不是印出來就算;對 SetIamPolicy 設記錄型警示(我們把它取名 alert-setiampolicy-owner-s2,之後翻警示清單一眼就對得上這篇),讓下次有人順手給 Owner 當天就知道;順便查出這個 Owner 是哪一天、被誰綁上去的,把原因記進變更紀錄。
這一季之後的方向是讓預設值站在自己這邊:新建服務帳戶一律不給專案層角色,需要什麼再逐項加;把四道防線的第二道寫進 SOP,也就是假設憑證會外洩、外洩了也要沒權限;並定期盤點所有專案的 IAM 綁定,讓「誰在專案層有角色」變成一份可追蹤的清單,而不是每次重新部署才發現一次。
這篇提到的項目要花多少錢
這次沒有損失,所以這張表真正在比的是兩個數字:拿掉一個角色要花多少,以及沒拿掉的那一次花了多少。
| 項目 | 用量 | 單價 | 期間 | US$ | NT$ | 舉證 |
|---|---|---|---|---|---|---|
| 移除 roles/owner 綁定 | 1 條 gcloud 指令 | 免費 | 一次 | 0.00 | 0 | IAM 變更不計費 |
| sheets-proxy Cloud Run 估;目前流量落在免費額度附近 | 1 支,1Gi、最多 5 個執行個體 | 依請求與 CPU 秒計費 | 每月 | 5.00 | 160 | Cloud Run 牌價;帳單未單獨拆出 |
| 同一形狀上次的直接損失 | 3 台 VM、AI 呼叫 | — | 15 天 | 146.10 | 4,675 | 本站防暴筆記 INC-2026-0827 帳單報表 |
| 同一形狀若在這個專案發生 | 不明:這個專案還掛著其他服務 | — | — | — | — | 無法在事前量化 |
| 合計(僅可證明列) | 146.10 | 4,675 | ||||
第一列免費、第三列有收據、第四列答不出來——這三列放在一起,就是「今天做」與「事後做」的價差。
你可以學到什麼
- 01讀寫試算表的帳戶不需要任何專案角色。Drive 與 Sheets 的授權來自檔案分享,不查專案 IAM。
- 02沒有金鑰檔不等於安全。Owner 讓一小時的短期 token 足夠開完 VM、改防火牆,再幫自己建一把永久金鑰。
- 03檢查要能中止流程才算檢查。印在畫面上沒有人看,等於沒有檢查。
- 04同一個破口形狀,5 天前才付過 US$146.10 與停權 3 天。形狀比事件本身更值得記。
- 05拿掉權限之前先查稽核記錄。不知道誰在用就拔,換來的是另一場事故。
- 06第一道防線(不發金鑰)做好了,不代表第二道(外洩了也沒權限)可以省。
- 01roles/owner基本角色三個之最,什麼都能做。服務帳戶掛著它,帳戶被冒用等於專案被拿走。
- 02檔案分享 vs 專案 IAMDrive/Sheets 的授權來自檔案分享,不查專案角色。讀寫試算表的帳戶不需要任何專案角色。
- 03自我模擬(impersonation)proxy 靠它換出只有 drive/spreadsheets 範圍的短期 token,沒有金鑰檔可外洩。
- 04短期 token 的存在形式只在記憶體、約一小時;比 KEY JSON 安全,但 Owner 讓一小時足夠開完 VM。
- 05deploy.sh 第 6 步把「必須為空」寫進腳本,才會在重部署時被自己抓到。人不會每次記得看主控台。
- 06gcloud projects get-iam-policy一行就列出誰在專案層有什麼角色;用 --filter 只看 serviceAccount。
- 07remove-iam-policy-binding拿掉角色的那一行指令;先確認再跑。
- 08SetIamPolicy 稽核記錄誰、何時、為什麼給了 Owner,只有這裡查得到;也是該設警示的方法名。
- 09四道防線的第二道外洩了也沒權限。第一道(不發金鑰)做好了,不代表第二道可以省。
- 10先查再拿拿掉角色前查 30 天稽核記錄,確認沒有別的程式靠這個帳戶做 Drive 以外的事。
這篇的結論其實只有一句:你不必等到出事,才知道自己有沒有同一個形狀。下面四步全部唯讀,十五分鐘就能問完自己。
全部唯讀,不改任何設定;做完你會知道自己有沒有同樣的破口。
- 列出專案裡掛 Owner 或 Editor 的服務帳戶
gcloud projects get-iam-policy PROJECT_ID --flatten='bindings[].members' \ --filter='bindings.members:serviceAccount AND (bindings.role:roles/owner OR bindings.role:roles/editor)' \ --format='table(bindings.role,bindings.members)'看到什麼代表什麼:每一筆都問「它的工作需要專案角色嗎」。讀寫試算表、上傳 Drive、寄信、跑排程,答案都是不需要。 - 看這個帳戶 30 天內用了哪些 API(Drive/Sheets 不會出現在這裡,因為它們不寫專案稽核記錄)
gcloud logging read 'protoPayload.authenticationInfo.principalEmail="SA_EMAIL"' \ --project=PROJECT_ID --freshness=30d --format='value(protoPayload.serviceName,protoPayload.methodName)' | sort | uniq -c看到什麼代表什麼:空的代表它只做 Drive/Sheets;有 compute、iam、serviceusage 之類的,就要先弄清楚是誰在用再拿角色。 - 查 Owner 是誰、何時給的
gcloud logging read 'protoPayload.methodName="SetIamPolicy"' --project=PROJECT_ID --freshness=400d \ --format='table(timestamp,protoPayload.authenticationInfo.principalEmail)'看到什麼代表什麼:找到那一天,就知道是「先讓它能跑」還是別的原因。 - 若負責人已經移除綁定,用這個指令確認 proxy 還活著(移除本身是前一節的當天工作,不在這裡做)
curl -sS https://PROXY_URL/v1/whoami -H 'Authorization: Bearer TOKEN'看到什麼代表什麼:還能回 allowed_folders,代表它本來就不需要那個角色;再跑一次第 1 步,那個帳戶應該從清單裡消失。
來源
每篇新文章一封:三點摘要、關鍵字定義、今天能做的一件事。
延伸閱讀
列為「風險最高」的那把金鑰,我們決定晚點再撤
上一次鑑識就點名它,但它擁有一萬多個檔案、七個以上的線上服務靠它活著,所以排在「先搬消費者再停用」。一個月後,它在五個小時內替別人跑了 6,350 萬 input token,整個專案被 Google 停權。
教學版:四道防線只要做到一道,這 15 天就不會發生
同一起事件的教學版:15 天逐分鐘攤開,每一步標出為什麼會成功、哪一道控制擋得下來。不發金鑰、不給 Owner、關掉沒用的 API、預算警示,四道都沒有。
排程回報「找不到帳戶」,被停權的是整個專案
一支只負責把資料寫進試算表的小程式,用的帳號卻是整個雲端專案權限最大的角色。金鑰六年沒換、外流後被用了 15 天:帳單 US$146,但「資料有沒有被看過」我們永遠答不出來。