加密擋不住的路:控制面與生命週期

2026-10-22 · 4,370 字 · 10 分鐘

保管別人的機密 — 第 4 篇:控制面與生命週期

前兩篇做完之後,平台 DB、備份、內部帳號這三個突破口都擋住了:偷到的只有密文,能請 KMS 解密的只有注入元件。但第 1 篇就說過,D 控制面跟其他三個不一樣。這篇專門處理它,順便處理備份另外要注意的事,以及所有金鑰和權杖的生命週期。

主圖的控制面與備份視角:亮起來的是平台 DB、備份、注入元件、容器、控制面五條 lane,以及第 8、9、15 步,並標出攻擊位置 B 與 D

控制面與備份的視角。亮起來的是第 8 步(備份)、第 9 步(控制面發身分)、第 15 步(明文進容器)。看完整主圖

能管理叢集的人,可以直接進容器拿明文

假設平台跑在 Kubernetes 上,攻擊者手上有一份 cluster-admin 權限的 kubeconfig。他不需要碰 KMS,下面任何一個指令都能拿到 Org A 的 DATABASE_URL:

# 1. 進到 Org A 的容器,印出環境變數
kubectl -n org-a exec deploy/web -- env | grep DATABASE_URL

# 2. 映像檔裡沒有 shell 也沒關係:掛一個 debug 容器,讀應用程式行程的環境變數
kubectl -n org-a debug -it pod/web-7d9f8 --image=busybox --target=web \
  -- sh -c 'tr "\0" "\n" < /proc/1/environ'

# 3. 機密如果存成 Kubernetes Secret,直接讀出來(base64 只是編碼,不是加密)
kubectl -n org-a get secret web-env -o jsonpath='{.data.DATABASE_URL}' | base64 -d

# 4. 自己起一個 Pod,用 Org A 的 service account 跑,讓注入元件替它把機密解開
kubectl -n org-a run probe --image=busybox \
  --overrides='{"spec":{"serviceAccountName":"web"}}' -- sleep 3600

前三個指令直接讀主圖第 15 步之後、已經在容器裡的明文。第 4 個指令走的是第 9 步:注入元件看到的是一個帶著 Org A 身分的 Pod,照規則替它解密,KMS 那邊的權限檢查也會通過,因為身分本身是真的。

這幾條路都不需要 KMS 的 Decrypt 權限,所以前兩篇的保護全部被繞過。Zeabur 這次就是這樣:攻擊者拿到共享叢集的管理權杖後,讀了所有服務的設定與機密,還在 865 個資料庫服務的容器裡執行指令。

上面這些指令打進去的地方,就是控制面(control plane);指令最後作用的地方,是資料面(data plane):

控制面(control plane) 資料面(data plane)
負責 決定容器在哪跑、設定是什麼、誰有什麼權限 實際跑服務、處理流量
Kubernetes kube-apiserver、scheduler、controller-manager、etcd 節點上的 kubelet、Pod,以及 Secrets Store CSI driver 這類注入機密的元件
Fly.io 排程 Machine,發臨時 token 給主機 主機上的 agent 用 token 解密、注入 Machine
主圖 控制面這條 lane 注入元件、容器

控制面不可能不存在,平台總得有人能管理叢集。所以這裡的目標不是「擋住」,而是讓管理權限:很難拿到、拿到了很快失效、從外面用不了、用了會被看見、能影響的範圍越小越好。

第一件事:管理權杖要短命

Zeabur 的共享叢集管理權杖 2024 年 1 月建立,沒有期限、從來沒有輪替,2026 年 1 月又被以明文寫進設定儲存庫,到 2026 年 10 月被攻擊者拿來用。換成時間軸來看,就知道「沒有期限」有多危險:

長期權杖 Zeabur 的共享叢集管理權杖 一直有效 2024/01 建立,無期限 2026/01 明文寫進設定儲存庫 2026/10 被濫用 短期權杖 用時才申請,每次一小時 只有綠色的那一小段有效。外洩時,攻擊者最多只能用到那一小時結束。

長期權杖外洩後能用多久?直到有人發現為止。短期權杖外洩後能用多久?最多到它過期。

做法上有幾個層次,越往下越好:

  1. 至少要有期限。任何管理權杖都設到期日,到期前輪換。
  2. 不要存在任何檔案裡。設定儲存庫、wiki、CI 變數都不行。需要時從機密管理服務取,用完不留。
  3. 用時才申請(just-in-time access)。平常沒有人持有管理權限。要操作時提出申請,寫明原因,核准後發一張一小時的權杖,時間到自動失效。每次申請都留紀錄。
  4. 跟人綁在一起。權杖透過公司的 SSO 加上 MFA 取得,而不是一串大家共用的字串。誰在什麼時候拿了權限,一目了然。

另外留一個緊急用的 break-glass 帳號,給 SSO 故障之類的狀況使用。它的憑證要封存保管,一旦被使用就立刻告警。

檢查自己的環境,可以從這幾個指令開始:

# kubeconfig 裡有沒有寫死的 token?有 token: 欄位,就是一份沒人管期限的憑證
grep -n "token:" ~/.kube/config

# 叢集裡有沒有長期有效的 service account token(舊版 Kubernetes 會自動建立這種 Secret)
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token

# 真的需要 token 時,發一張有期限的
kubectl -n ops create token deployer --duration=1h

第二件事:管理入口不對公網

Zeabur 的共享叢集管理介面當時對公網開放,攻擊者拿到權杖就能從世界任何地方連進來。事件發生後,他們第一批處理的事情之一,就是限制只有 Zeabur 自己的系統能連線。

管理入口放在私有網路裡,只能透過 VPN、跳板機或零信任代理進入。這樣攻擊者需要同時拿到兩樣東西:權杖,加上進入內網的管道,只偷到權杖還用不了。

確認方法很直接,從公司網路外面試連 API server:

curl -sk --max-time 5 https://<api-server>:6443/version

有回應就代表管理入口對外開放,權杖一外洩就能直接從外面使用。

第三件事:內部和使用者的東西分開

報告裡有一句很關鍵:內部服務和使用者服務部署在同一個叢集,攻擊者讀取使用者設定時,也一併拿到了內部服務的憑證。一份權限能讀的範圍,同時涵蓋了使用者和平台自己。

分開的方式有很多層:

分開的目的是縮小「一份憑證外洩時,能影響多少服務」。資安領域把這叫爆炸半徑(blast radius)。要知道一個帳號的範圍有多大,直接列出它能做的事:

kubectl auth can-i --list --as=system:serviceaccount:platform:deployer

如果同一個帳號既能讀 org-a 這類使用者 namespace,又能讀平台內部的 namespace,範圍就太大了。

第四件事:進容器這個動作要被看見

在容器裡執行指令(kubectl exec 這類)是讀取明文最直接的方法。平台工程師偶爾需要它來除錯,但它應該是:

在 Kubernetes 裡,exec 是獨立的子資源 pods/exec,可以單獨授權、單獨記錄:

# 某個人能不能對 org-a 的 Pod 執行 exec?
kubectl auth can-i create pods/exec -n org-a [email protected]
# kube-apiserver 的 audit policy:exec、attach、port-forward 一律記錄
# 執行的指令會出現在紀錄的 requestURI 裡
- level: Metadata
  resources:
  - group: ""
    resources: ["pods/exec", "pods/attach", "pods/portforward"]

Zeabur 的攻擊者在資料庫容器裡活動了三個多小時,沒有觸發任何告警。他們事後列出的新告警項目裡,「在容器內執行指令」就是其中一項。第 5 篇會把這些告警整理成清單。

備份:另一條繞路

主圖第 8 步,平台 DB 每天備份一次。第 2 篇說過,如果資料庫裡只有密文,備份裡也只有密文,被偷了一樣解不開。但 Zeabur 的備份出了三個問題,每一個都值得記下來。

第一,叢集備份裡有所有機密的明文。注意這裡說的是叢集備份,不是平台 DB 的備份。報告沒有交代備份的格式,但這在 Kubernetes 上是很常見的坑:叢集的 Secret 物件預設存在 etcd 裡,只做了 base64 編碼,沒有加密。備份 etcd,就等於備份了所有 Secret 的明文。Kubernetes 本身提供 KMS provider,可以用跟第 2 篇一樣的信封加密來加密 etcd 裡的資料,這個要記得打開。打開之後,可以直接從 etcd 讀一筆 Secret 確認:

# 省略 etcdctl 的連線參數
etcdctl get /registry/secrets/org-a/web-env | hexdump -C | head -3

看得到 k8s:enc:kms:v2: 開頭,代表有加密;看得到 DATABASE_URL 這類欄位名稱跟值,代表是明文。

第二,備份的存取金鑰跟其他機密放在一起,而且權限太大。報告寫到,一把只需要讀一個儲存空間的 AWS 金鑰,實際上能讀整個 AWS 帳號的儲存空間。備份金鑰應該另外保管,權限只開到它負責的那個 bucket。在備份主機上用這把金鑰試一下就知道:

aws s3 ls s3://backup-tpe1/      # 應該成功
aws s3 ls s3://some-other-bucket/ # 應該是 AccessDenied

第三,也是最容易被忽略的一點:輪換過的新金鑰,被備份收錄了。

14:00 21:00 15:04 攻擊者讀到備份存取金鑰 16:00 每日備份收錄剛輪替的新憑證 18:51 刪除備份存取金鑰 20:30 攻擊者用上新憑證

2026 年 10 月 15 日(UTC),依 Zeabur 報告整理。新憑證被收進備份,而攻擊者手上已經有備份的存取金鑰。

Zeabur 在事件中輪換了一批憑證,這是正確的動作。但下午 4 點的例行備份照常執行,把剛換好的新憑證收了進去,而攻擊者這時已經握有備份的存取金鑰。晚上 8 點半,攻擊者用上了新憑證。

從這裡學到兩件事:

Zeabur 的改善措施裡也有這一條:叢集備份不再保存明文機密,備份金鑰與其他機密分開保存。

生命週期:每一份憑證都要能換

前面講的是控制面的權杖。把範圍放大,平台上所有的金鑰、權杖、憑證,都該有完整的生命週期:建立、使用、輪換、到期、撤銷。這分成兩群。

使用者的機密

Org A 的 DATABASE_URL 也會需要更換,例如資料庫密碼外洩了。平台要讓這件事很容易:

平台自己的憑證

Zeabur 的報告裡有一段更正很值得記住:八月事件後,他們說已經停用或輪換所有內部管理金鑰,實際上只換了 GitHub App 私鑰,設定儲存庫裡的叢集管理權杖和 AWS 金鑰都沒換。

會發生這種事,通常不是因為懶,而是因為根本不知道自己有哪些憑證。所以第一步是盤點,為每一把憑證建一筆紀錄:

憑證 負責人 存在哪裡 能做什麼 有效期限 上次輪換 怎麼輪換
叢集管理權杖(tpe1) 平台組 不存放,JIT 申請 管理 tpe1 叢集 1 小時 每次申請 自動
備份 bucket 金鑰 平台組 KMS 封存 只能寫 backup-tpe1 90 天 2026-09-01 runbook #12
sealed box 私鑰 安全組 KMS 解開送入的機密 180 天 2026-07-15 runbook #7

表上的每一欄都是一個問題,答不出來的那一格就是風險。有了這張表,「輪換所有內部金鑰」才是一件能被驗證的事,而不是一句口號。

最後一個常被忘記的情況:服務停用時,它的憑證要跟著撤銷。Zeabur 有一把已停用服務的部署金鑰,停用四個月後仍然有效,事件中被拿來讀設定儲存庫。

對照主圖

  1. 備份:備份裡只有密文;叢集層的 etcd 也要加密;備份金鑰另外保管、權限只到一個 bucket。
  2. 控制面發臨時身分:控制面本身要用短期、跟人綁定、不對公網的權限來操作,而且操作會被記錄。
  3. 明文進容器:這裡的明文無法避免,所以進容器的動作(exec)要被限制、被記錄、被告警。

這一層擋住了什麼

位置 有了這一層之後
A 平台 DB 擋住(第 2 篇)
B 備份 擋住:備份裡沒有明文,備份金鑰權限縮小、另外保管
C 內部帳號 擋住(第 3 篇)
D 控制面 沒有辦法完全擋住,但能把風險壓到很小:權杖很難拿到、拿到很快過期、從外面用不了、能影響的範圍變小

D 永遠不會變成「擋住」,這是誠實的答案。平台總得有人能管理叢集,能管理就能接觸到明文。剩下的最後一道防線,是在它被濫用的時候馬上發現。這是最後一篇的主題。

控制面與生命週期檢查清單

下一篇

到這裡,事前的設計都講完了,四個攻擊位置也都處理過。但系統上線之後,每一層都會慢慢走樣,也可能直接出錯:權限越開越寬、清單漏了一把、某個人被釣魚。最後一篇講營運中怎麼定期稽核、出錯時怎麼發現、發現後怎麼補救,再用攻擊情境把前四篇逐一驗收。

下一篇:營運與事後,稽核、偵測、應變 →