新創資安事件啓示錄:當環境變數變成整個平台的信任根
主打 AI Agent 整合的 PaaS 新創平台爆發資安事件:攻擊者從一把雲端管理憑證出發,一路打穿到主資料庫,把所有租戶存在環境變數裡的第三方金鑰整批查出來、匯出去。問題的根源不是「被駭」這件事本身,而是架構設計把太多信任集中在同一個地方——環境變數欄位是整個平台唯一的機密儲存層,一次控制平面層級的入侵,就足以讓所有租戶的金鑰在同一時間曝險。因此想寫這篇文章討論金鑰該怎麼存。
(一) 從攻擊鏈看金鑰為什麼能走這麼遠
該平台官方在事故發生後陸續更新的攻擊鏈,是這起事件最值得拆解的部分:攻擊者取得一把 AWS 管理層級的憑證,先進入東京的一個共用叢集,再從叢集摸到控制平面的 VPN,最後連上主資料庫,把使用者存在環境變數裡的金鑰查出來、匯出去。時間軸上,平台在 8 月 28 日凌晨建立事故頁,一開始只說是「內部服務憑證」被用來查詢環境變數紀錄;直到隔天晚間,才更新出「AWS 管理憑證 → 共用叢集 → 控制平面 VPN → 主資料庫」這條完整路徑。
如果那把 AWS 憑證的有效權限,本身就同時涵蓋叢集存取、VPN 素材、資料庫連線——那麼圖面上的四個箭頭,實際上可能從頭到尾只被同一個身分牽著走,中間沒有一關是「前一個身分拿不到、簽不出、也改不掉」的獨立授權。
要驗證一組雲端身分的真實影響範圍,不能只看掛了哪些 policy 名稱,而要直接查它的 effective permissions:
aws accessanalyzer list-findings \
--analyzer-arn <analyzer-arn> \
--filter '{"resource":{"contains":["<role-or-user-arn>"]}}'
這個指令會列出該身分實際可觸及的資源,而不是政策文件上「看起來」授權了什麼——這正是重建這類事故攻擊面時,第一件該做的事。
(二) 問題不在用環境變數,而在把它當成信任根
環境變數本身不是問題。幾乎所有 PaaS——Heroku、Render、Vercel 等——都用環境變數當作應用程式讀取設定的方式,這是產業慣例,沒有問題。真正的問題,是把環境變數當成金鑰的「長期儲存層」與「信任根」:如果一把 API Key 從建立到被讀取,自始至終只以明文或近乎明文的形式,存放在一個任何有資料庫查詢權限的人都能整批撈出來的資料表裡,那麼平台安不安全,最後就完全取決於資料庫這一層守不守得住——而資料庫這一層遲早會被打穿。
這也是為什麼「有沒有磁碟層加密」在這類事故裡幾乎沒有意義:磁碟加密守的是實體硬碟或備份被整批搬走的情境,對一個已經拿到正常查詢權限的身分來說,加密與否沒有差別——他讀到的永遠是解密後的結果。
你可以先檢查自己的 Kubernetes 環境,有多少 Secret 其實是用明文 Value 直接塞進 Pod 的 env,而不是走 secretKeyRef 間接引用:
kubectl get pods -n <namespace> -o json | \
jq -r '.items[].spec.containers[].env[]? | select(.value != null) | .name'
如果這個指令列出一長串看起來像金鑰或密碼的變數名稱(API_KEY、DB_PASSWORD、AWS_SECRET_ACCESS_KEY),就代表這些值目前活在 Pod spec 裡,任何有 kubectl describe pod 或叢集唯讀權限的人都看得到明文——這正是下一節要處理的問題。
(三) 替代機制之一:KMS 信封加密
信封加密(Envelope Encryption)解決的正是「資料庫被整批查詢」這個攻擊情境:金鑰不是直接明文存進資料表,而是先用一把租戶專屬的 Data Encryption Key(DEK)加密,DEK 本身再被 KMS 管理的 Key Encryption Key(KEK)包起來。即使攻擊者拿到資料庫的完整查詢權限,撈出來的也只是密文——要還原成可用的金鑰,還得再過一次 KMS 的解密授權,而這個解密動作本身可以獨立限流、稽核、按租戶隔離。
以 AWS KMS 為例,產生一組信封加密用的 DEK:
aws kms generate-data-key \
--key-id alias/secrets-kek \
--key-spec AES_256 \
--output json
回傳的 JSON 會包含 Plaintext(用來在應用端加密實際的金鑰內容,加密完立刻從記憶體清掉,不落地)與 CiphertextBlob(這是唯一要存進資料庫的東西)。日後要用這把金鑰,應用只需要把 CiphertextBlob 交還給 KMS:
aws kms decrypt \
--ciphertext-blob fileb://encrypted-dek.bin \
--output json
關鍵在於:kms:Decrypt 這個 action 可以只授權給特定的 IAM 角色,獨立於資料庫查詢權限之外設定,而且每一次呼叫都會留下 CloudTrail 紀錄。就算攻擊者拿到了資料庫的讀取權限,他還得再單獨拿到 KMS 的解密權限,才能把密文變回可用的金鑰——這正是這次事件裡明顯缺少的一道關卡。
(四) 替代機制之二:短效動態憑證與 Workload Identity
信封加密解決的是「靜態金鑰被讀到之後」的問題,但更根本的做法,是讓「長期有效的靜態金鑰」這種東西盡量不要存在。以 HashiCorp Vault 的 Dynamic Secrets(Database Secrets Engine)為例,資料庫帳密可以在讀取當下才現場生成,設定短 TTL,用完就過期:
vault write database/roles/app-readonly \
db_name=postgres-prod \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="4h"
應用程式啟動時去讀這個角色,拿到的是一組全新、只活 1 小時的帳密,而不是一組寫死在設定檔裡、可能已經存在好幾年的密碼:
vault read database/creds/app-readonly
雲端身分的部分,可以直接用 OIDC 聯邦身分取代長期 AWS Access Key。以 EKS 的 IAM Roles for Service Accounts(IRSA,需要叢集已啟用 OIDC provider)為例,先建立信任特定 Kubernetes Service Account 的 IAM Role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/oidc.eks.<region>.amazonaws.com/id/<oidc-id>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.<region>.amazonaws.com/id/<oidc-id>:sub": "system:serviceaccount:<namespace>:<service-account-name>"
}
}
}
]
}
再把這個角色掛到 Service Account 上:
kubectl annotate serviceaccount <service-account-name> \
-n <namespace> \
eks.amazonaws.com/role-arn=arn:aws:iam::<account-id>:role/<role-name>
掛好之後,Pod 裡的應用程式會透過短效的 STS token 取得 AWS 權限,系統裡從頭到尾不存在一把可以被偷走的長期 AWS Access Key——這點特別關鍵,因為這起事件的起點,正是一把管理層級的長期 AWS 憑證。
(五) SRE/SecDevOps 該補的維運層防線:稽核、限流、異常偵測
前面四節解決的是「金鑰怎麼存」,但就算儲存架構做對了,還是需要維運層的偵測機制,才抓得到異常的大量讀取。用 CloudWatch Logs Insights 查詢 Secrets Manager 的存取模式,是一個低成本的起點:
fields @timestamp, userIdentity.arn, eventName, requestParameters.secretId
| filter eventName = "GetSecretValue"
| stats count(*) as callCount by userIdentity.arn
| sort callCount desc
這段查詢統計每個身分呼叫 GetSecretValue 的次數,正常情況下,一個應用身分的呼叫模式應該是穩定、可預期的;如果短時間內某個身分的呼叫次數異常飆高,通常就是批量匯出的訊號。把這條查詢包成 CloudWatch Alarm,設定「5 分鐘內超過 50 次」這類門檻,搭配自動觸發的 Lambda 去暫時吊銷該身分的 Session,就是一道低成本但有效的偵測層。
稽核日誌本身也要放在攻擊者拿到的身分「動不了」的地方——如果攻擊者取得的那組憑證,同時也能停用或刪除 CloudTrail,那所有的「目前沒有發現異常」都失去了證明力。實務上建議用 organization trail,把日誌統一寫到一個獨立的 log archive 帳號,搭配 CloudTrail 的 log file integrity validation,確保就算主帳號被打穿,稽核紀錄依然完整、可驗證。
結論:怎麼確認自己已經補上這幾道防線
這幾道防線做完之後,可以用下面幾個檢查點,確認自己的環境是不是還在用「環境變數即信任根」的舊模式:
- 用第(二)節的
kubectl/jq指令抽查,是否還有明文金鑰直接寫在 Pod 的 env 裡,而不是透過secretKeyRef或外部 Secret Operator 注入 - 檢查有沒有一把身分,同時擁有「資料庫批量讀取」與「KMS 解密」這兩種權限——如果有,信封加密就形同虛設
- 確認高權限雲端身分是不是短效的(STS/OIDC),而不是一把可以無限期使用的 Access Key
- 對
GetSecretValue、vault read這類高敏感 API,是否已經有異常呼叫量的告警規則,而不是只在事故發生後才回頭翻 Log
這起事件最終會怎麼收尾,還要看官方公布的鑑識結果。但不管根因最後落在哪一段,對自己維護的系統來說,能現在就做的事情很明確:
別讓一個資料庫的讀取權限,等於整個平台所有租戶的金鑰。
延伸閱讀
- 《SRE 不是專案:價值、評估與治理》——Secrets 管理本質上也是一種治理問題,同樣適用「不是一次性專案,而是持續投入」的框架
留言
張貼留言