
攻擊不一定從 Runtime 開始——供應鏈是容器安全最容易被忽略的攻擊面。
Day 2–21 我們從 Initial Access 打到 Impact,所有攻擊都發生在「映像部署之後」。但真實世界中,攻擊者越來越偏好在「映像還沒部署之前」就下手——這就是供應鏈攻擊。

供應鏈攻擊的可怕之處:攻擊者不需要入侵你的叢集。只要污染你的依賴(base image、npm package、CI/CD pipeline),你的 CI/CD 會自動把後門帶進生產環境。
所有指令都在 koad 專案目錄下執行。如果還沒 clone,請先參考 Day 1 的 Step 0。
開始前請確認以下環境就緒。
# 主機終端
docker version --format '{{.Server.Version}}'
本日場景主要使用 Docker CLI 操作映像分層,部分示範可在主機終端執行,不一定需要進入 Pod。
# 主機終端
# dive — Docker 映像分層檢視工具
which dive || echo "可選:go install github.com/wagoodman/dive@latest"
# trivy — 映像漏洞掃描
which trivy || echo "可選:請參考 Day 23 安裝 Trivy"
完成本日實作後,你將能夠:
| 攻擊面 | 手法 | 相關 ATT&CK | KOAD 場景 |
|---|---|---|---|
| 映像分層 | 在中間層藏入密碼/後門 | T1525 Implant Internal Image | S12 |
| Registry | 未授權存取私有 Registry | T1133, T1525 | S12 |
| CI/CD Pipeline | 投毒 base image / latest tag 劫持 | T1195 Supply Chain | — |
| 依賴 | 惡意 package / typosquatting | T1195 | — |
Docker 映像由多個唯讀層(layer)堆疊而成。每個 Dockerfile 指令產生一個新層。這是 Docker 的核心設計——層可以被快取和共享,大幅加速建構。
但這個設計有一個安全隱患:即使後面的指令刪除了檔案,之前的層仍然保留了完整內容。
Layer 3: COPY app /app/ ← 最終映像包含這三層
Layer 2: RUN rm /app/creds.json ← 檔案被「刪除」(只是 whiteout 檔)
Layer 1: COPY creds.json /app/ ← 密碼完整保留在這一層
Layer 0: FROM ubuntu:22.04
Docker 映像遵循 OCI Image Specification。每一層是一個 tar 檔,包含該層新增/修改/刪除的檔案。刪除操作不是真的刪除——而是在新的層中建立一個 .wh.filename(whiteout 檔),告訴容器 runtime「這個檔案不存在」。
但底層的 tar 檔仍然包含原始檔案——任何人 pull 這個映像都能解壓底層看到密碼。
FROM ubuntu:22.04
# Layer 1: 複製密碼檔
COPY credentials.json /app/credentials.json
# Layer 2: 使用密碼下載依賴
RUN curl -H "Authorization: Bearer $(cat /app/credentials.json)" \
https://api.example.com/deps > /app/deps.tar
# Layer 3: 刪除密碼——開發者以為這樣就安全了
RUN rm /app/credentials.json
# Layer 4: 應用程式
COPY app /app/
CMD ["/app/server"]
步驟 1:安裝 dive
snap install dive
預期結果:
dive 0.12.0 from Wagoodman installed
dive 是分析 Docker 映像分層結構的工具——攻擊者用它來找出映像中間層殘留的敏感檔案。

步驟 2:檢視映像每一層的內容
dive koad/vulnerable-webapp:latest
預期結果:

dive 的 TUI 介面會清楚顯示每個 layer 的內容變化,包括被「刪除」但仍然存在於歷史層中的檔案

步驟 3:檢視映像建構歷史
docker history koad/vulnerable-webapp:latest
預期結果:
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5 2 min ago COPY app /app/ 1.2MB
b2c3d4e5f6 2 min ago RUN rm /app/credentials.json 0B ← 0B = whiteout
c3d4e5f6g7 2 min ago COPY credentials.json /app/ 256B ← 密碼在這裡
d4e5f6g7h8 2 min ago FROM ubuntu:22.04 77MB
0B的RUN rm層代表 whiteout(刪除標記),檔案在這一層被「移除」——但上一層的256B COPY credentials.json仍完整保留在映像中,任何人都可以提取。

步驟 4:匯出映像為 tar
docker save koad/vulnerable-webapp:latest -o image.tar
預期結果:
(無輸出表示成功)
docker save將映像的所有 layer 匯出為 tar 檔案——這是手動分析映像內容的第一步,不需要任何特殊權限。
步驟 5:解壓並提取密碼
tar xf image.tar
tar tf sha256:c3d4e5f6g7.../layer.tar
預期結果:
app/credentials.json ← 密碼就在這裡
在含有
COPY credentials.json的 layer tar 中找到了目標檔案——即使後續層執行了rm,歷史層的內容不受影響。
tar xf sha256:c3d4e5f6g7.../layer.tar app/credentials.json
cat app/credentials.json
預期結果:
{"api_key": "sk-secret-1234567890", "db_password": "P@ssw0rd!"}
成功從映像歷史層中提取了 API 金鑰和資料庫密碼——這就是為什麼絕對不能在 Dockerfile 中 COPY 密碼再刪除,必須使用多階段建構或 BuildKit secrets。

步驟 6:用 Trivy 掃描映像中的 Secret
trivy image --scanners secret koad/vulnerable-webapp:latest
預期結果:
SECRET SCANNING
Total: 1 (HIGH: 1)
| Category | Severity | Match |
|---|---|---|
| AWS Access Key | HIGH | AKIAIOSFODNN7EXAMPLE |
Trivy 的 secret scanner 能自動掃描映像所有 layer 中的密碼模式(API key、AWS credential 等),即使檔案已被刪除也能偵測到。
多階段建構是解決映像分層藏密的根本方案——第二階段的映像完全不包含第一階段的 layer:
# Stage 1: 建構(含密碼/工具的環境)
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 最終映像(不含建構環境)
FROM gcr.io/distroless/nodejs20-debian12:nonroot
COPY --from=builder /app/dist /app
USER nonroot
CMD ["app/server.js"]

# 比較映像大小
$ docker images
REPOSITORY TAG SIZE
node 20 950MB
node 20-alpine 175MB
distroless/nodejs 20 40MB ← 最小攻擊面
# distroless 優勢:
# 沒有 shell → 無法 exec
# 沒有 package manager → 無法安裝工具
# 沒有 curl, wget → 無法下載惡意程式
KOAD S12 部署了一個未設認證的私有 Registry(NodePort 30500):
步驟 1:列出 Registry 中的映像
curl -s http://$(minikube ip):30500/v2/_catalog
預期結果:
{"repositories":["koad/backdoor-image","koad/vulnerable-webapp"]}
未經認證就能列出 Registry 中所有映像——攻擊者可以枚舉可用目標,並從名稱判斷哪些映像值得深入分析。

步驟 2:列出映像的所有 tag
curl -s http://$(minikube ip):30500/v2/koad/backdoor-image/tags/list
預期結果:
{"name":"koad/backdoor-image","tags":["latest","v1.0","v2.0-backdoor"]}
tag 列表中出現
v2.0-backdoor這種可疑名稱——攻擊者可能已經推入了後門映像,或者可以用docker push覆蓋latesttag 來劫持部署。

步驟 3:下載映像的 manifest
curl -s http://$(minikube ip):30500/v2/koad/backdoor-image/manifests/latest \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json"
預期結果:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": "sha256:..."},
"layers": [{"digest": "sha256:layer1..."}]
}
manifest 揭露了映像的完整結構,包括每個 layer 的 digest——攻擊者可以用這些 digest 直接下載個別 layer 進行離線分析。

步驟 4:直接下載 layer blob
curl -s http://$(minikube ip):30500/v2/koad/backdoor-image/blobs/sha256:layer1... \
-o layer1.tar.gz
預期結果:
(檔案 layer1.tar.gz 被下載,可直接解壓分析)
透過 Registry API 直接下載 layer blob,完全繞過
docker pull——攻擊者可在無 Docker 環境中提取映像內容,分析原始碼、設定檔和密碼。

| 攻擊 | 說明 | 嚴重程度 |
|---|---|---|
| 映像竊取 | 拉取映像,分析原始碼和密碼 | HIGH |
| 映像植入 | 推入後門映像,覆蓋合法 tag | CRITICAL |
| latest 劫持 | 推入惡意版本到 :latest |
CRITICAL |
| Registry 掃描 | 枚舉所有映像和 tag | MEDIUM |
# 1. 啟用 HTTP Basic Auth
$ htpasswd -Bbn admin StrongP@ss123 > /auth/htpasswd
$ docker run -d -p 5000:5000 \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
-v /auth:/auth \
registry:2
# 2. 啟用 TLS
$ docker run -d -p 5000:5000 \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-v /certs:/certs \
registry:2

# 危險:使用 :latest
containers:
- image: myapp:latest # 可變指標,任何人都能改
# 安全:使用 digest
containers:
- image: myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
# 不可變,永遠指向同一個映像
| 事件 | 年份 | 手法 | 影響 |
|---|---|---|---|
| SolarWinds SUNBURST | 2020 | CI/CD build server 注入後門 | 18,000+ 組織受影響 |
| Codecov | 2021 | 修改 bash uploader 竊取 env vars | 大量 CI/CD secret 外洩 |
| ua-parser-js | 2021 | npm 帳號被盜,植入挖礦程式 | 800 萬/週下載量 |
| event-stream | 2018 | 維護者轉移,新維護者植入竊密碼 | 影響 BTC 錢包 |
| PyTorch nightly | 2022 | Dependency confusion | 竊取 CI/CD 環境變數 |
SolarWinds 是史上最大的供應鏈攻擊。攻擊者在 SolarWinds 的 CI/CD 伺服器上植入後門。每次軟體建構時,後門自動被編譯進去。18,000 個組織安裝了含後門的更新,被攻擊了 9 個月才被 FireEye 發現。
2019-09 攻擊者入侵 CI/CD 環境
2019-10 植入 SUNBURST 後門
2020-03 含後門的更新推送給客戶
2020-12 FireEye 發現並揭露
# 正確
FROM nginx:1.25-alpine
# typosquatting
FROM ngnix:1.25-alpine # 'ng' → 'gn'
FROM nginx-official:latest # 看似官方但不是
FROM nginnx:latest # 多一個 n
# 你的 package.json 依賴內部套件 "internal-utils"
# 攻擊者在 npmjs.com 發布同名套件 v99.0.0
# npm 可能優先安裝公開的 v99(版本號更高)
# 使用 syft 產生 SBOM
$ syft koad/vulnerable-webapp:latest -o spdx-json > sbom.json
# 列出所有元件
$ cat sbom.json | jq '.packages[] | {name, version, type}' | head -20
{
"name": "openssl",
"version": "3.0.13-1~deb12u1",
"type": "deb"
}
# 用 grype 掃描 SBOM 中的漏洞
$ grype sbom:sbom.json
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
openssl 3.0.13 3.0.14 deb CVE-2024-XXX HIGH
| 措施 | 對抗的攻擊 | 實作 |
|---|---|---|
| 多階段建構 | 映像分層藏密 | Dockerfile multi-stage |
| Cosign 簽章 | 映像竄改 | CI/CD 自動簽章 |
| Trivy 掃描 | 已知漏洞 | CI/CD gate |
| 映像白名單 | 非法映像 | Kyverno require-trusted-registry |
| Digest 固定 | latest 劫持 | 使用 @sha256: 引用 |
| SBOM | 未知依賴 | syft 產生 + grype 掃描 |
| Registry 認證 | Registry 滲透 | auth + TLS |
除了多階段建構,還有多個 Dockerfile 寫法可以降低供應鏈風險:
# 危險::latest 或 :1.25 都是可變 tag
FROM nginx:1.25
# 安全:固定 digest
FROM nginx@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node # ← 不以 root 運行
CMD ["node", "server.js"]
# 安裝後清理 cache
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Alpine 用 --no-cache
RUN apk add --no-cache curl
# .dockerignore — 防止敏感檔案被 COPY 進映像
.env
.git
*.key
*.pem
credentials.*
docker-compose*.yml
# 使用 hadolint 檢查 Dockerfile 最佳實踐
$ hadolint Dockerfile
Dockerfile:3 DL3007 warning: Using latest is prone to errors
Dockerfile:7 DL3015 info: Avoid additional packages by specifying --no-install-recommends
Dockerfile:12 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:15 DL4006 warning: Set the SHELL option -o pipefail before RUN with a pipe in
# CI/CD 整合
- name: Hadolint
uses: hadolint/hadolint-action@v3
with:
dockerfile: Dockerfile
failure-threshold: warning
SLSA(Supply-chain Levels for Software Artifacts,讀作「salsa」)是 Google 提出的供應鏈安全框架,定義了四個成熟度等級:
| SLSA Level | 要求 | KOAD 對應 |
|---|---|---|
| Level 1 | 建構過程有文件記錄 | Dockerfile + CI/CD 配置 |
| Level 2 | 使用版控的建構服務 | GitHub Actions + 自動建構 |
| Level 3 | 建構平台有安全強化 | 隔離建構環境 + 建構 provenance |
| Level 4 | 雙人審核 + 密封建構 | 完全密封(hermetic)建構 |
Provenance 是建構來源證明——記錄「這個映像是由誰、在哪裡、用什麼原始碼建構的」:
# 使用 slsa-verifier 驗證 provenance
$ slsa-verifier verify-image myregistry.io/myapp:v1.0 \
--source-uri github.com/org/repo \
--source-tag v1.0
# 驗證結果
Verified signature against tlog entry index 12345678
Verified build using builder https://github.com/slsa-framework/slsa-github-generator
PASSED: Verified SLSA provenance

# minikube 用 docker driver 時,映像在 minikube VM 中
# dive 看不到 minikube 內的映像
# 解法:先 save 到本地
$ minikube image save koad/vulnerable-webapp:latest /tmp/webapp.tar
$ dive --source docker-archive /tmp/webapp.tar
# distroless 沒有 bash/sh,kubectl exec 進不去
$ kubectl exec -it myapp -- /bin/sh
error: exec: "/bin/sh": no such file or directory
# 解法:使用 debug container(K8s 1.25+)
$ kubectl debug -it myapp --image=busybox --target=app
# Cosign keyless 需要連線到 Sigstore 的 Rekor
# 離線環境使用傳統金鑰對模式
$ cosign generate-key-pair
$ cosign sign --key cosign.key myimage:v1
$ cosign verify --key cosign.pub myimage:v1
| 考點 | 本日內容 | 權重 | 考法 |
|---|---|---|---|
| 映像安全 | 分層分析、多階段建構 | Supply Chain 20% | 分析 Dockerfile 漏洞 |
| Registry 安全 | 認證 + TLS | Supply Chain 20% | 配置 Registry 認證 |
| 映像掃描 | Trivy / KubeSec | Supply Chain 20% | 用 Trivy 掃描映像 |
| 不可變映像 | Digest 引用 | Supply Chain 20% | 將 :latest 改為 @sha256: |
以下用 KOAD 靶場內的工具,從建構含漏洞映像到掃描偵測,走完一輪完整流程。
# 主機終端
# 建立測試目錄
mkdir -p /tmp/supply-chain-demo && cd /tmp/supply-chain-demo
# 建立假的密碼檔
cat > credentials.json <<'CRED'
{"api_key": "sk-demo-secret-key-12345", "db_password": "DemoP@ss!"}
CRED
# 建立有漏洞的 Dockerfile
cat > Dockerfile <<'DOCKER'
FROM alpine:3.20
COPY credentials.json /app/credentials.json
RUN cat /app/credentials.json > /dev/null
RUN rm /app/credentials.json
CMD ["echo", "app running"]
DOCKER
預期結果:
(三個檔案建立完成,無輸出)
docker build -t secret-leak-demo:latest .
預期結果:
=> [1/4] FROM alpine:3.20
=> [2/4] COPY credentials.json /app/credentials.json
=> [3/4] RUN cat /app/credentials.json > /dev/null
=> [4/4] RUN rm /app/credentials.json
=> exporting to image
Successfully tagged secret-leak-demo:latest
docker history secret-leak-demo:latest
預期結果:
IMAGE CREATED CREATED BY SIZE
<missing> 10 seconds ago CMD ["echo" "app running"] 0B
<missing> 10 seconds ago RUN /bin/sh -c rm /app/credentials.json 0B ← 0B = 只有 whiteout
<missing> 10 seconds ago RUN /bin/sh -c cat /app/credentials.json ... 0B
<missing> 10 seconds ago COPY credentials.json /app/credentials.json 65B ← 密碼還在這層
COPY credentials.json那層有 65B,代表密碼完整保留。rm只產生 0B 的 whiteout 標記。
# 匯出映像
docker save secret-leak-demo:latest -o demo.tar
mkdir -p layers && tar xf demo.tar -C layers
# 找出含有 credentials.json 的 layer
find layers -name "layer.tar" -exec sh -c \
'tar tf "$1" 2>/dev/null | grep -q credentials && echo "Found in: $1"' _ {} \;
預期結果:
Found in: layers/abc123.../layer.tar
# 提取密碼
tar xf layers/abc123.../layer.tar app/credentials.json 2>/dev/null
cat app/credentials.json
預期結果:
{"api_key": "sk-demo-secret-key-12345", "db_password": "DemoP@ss!"}
成功從映像歷史層提取了密碼。即使 Dockerfile 最後執行了
rm,密碼仍完整保留在中間層。
trivy image --scanners secret secret-leak-demo:latest
預期結果:
secret-leak-demo:latest (alpine 3.20)
======================================
Total: 1 (HIGH: 1)
SECRET
app/credentials.json
Category: AsymmetricPrivateKey or GenericAPIKey
Severity: HIGH
Match: sk-demo-secret-key-12345
Trivy 的 secret scanner 自動掃描所有 layer,即使檔案已被刪除也能偵測到。
docker rmi secret-leak-demo:latest 2>/dev/null
rm -rf /tmp/supply-chain-demo
| 問題 | 現象 | 解法 |
|---|---|---|
| dive 安裝失敗 | snap install dive 報權限錯誤 |
改用 go install github.com/wagoodman/dive@latest 或從 GitHub Releases 下載 binary |
| docker save 檔案很大 | 映像匯出後 tar 檔超過 1GB | 這是正常的(所有 layer 未壓縮),分析完後刪除 tar 檔 |
| Trivy 掃不到 secret | Total: 0 但確實有密碼 |
確認使用 --scanners secret 參數;Trivy 只偵測已知模式(API key 格式),自訂密碼格式可能不會被偵測 |
| minikube 內映像 dive 看不到 | dive myimage:latest 報找不到映像 |
minikube 用自己的 Docker daemon,先 minikube image save myimage:latest /tmp/img.tar 再 dive --source docker-archive /tmp/img.tar |
| Registry API 連不到 | curl: (7) Failed to connect |
確認 S12 場景的 Registry Pod 正在執行:kubectl get pods -n koad-registry;確認 NodePort:kubectl get svc -n koad-registry |
# 主機終端 — 清除測試用的 Docker 映像
docker rmi secret-leak-demo:latest 2>/dev/null
docker rmi backdoor-nginx:latest 2>/dev/null
供應鏈場景主要在主機 Docker 操作,不影響 KOAD 靶場 Pod 環境。
| 問題 | 原因 | 解法 |
|---|---|---|
docker build 失敗:Dockerfile not found |
不在含有 Dockerfile 的目錄中 | cd 到包含 Dockerfile 的目錄,或用 -f 指定路徑:docker build -f path/to/Dockerfile . |
dive 指令找不到 |
未安裝 dive 工具 | brew install dive(macOS)或 wget 從 GitHub Releases 下載二進位檔 |
docker history 看不到機密內容 |
映像使用了 --squash 或 BuildKit 建構 |
改用 dive 逐層檢視;或 docker save <image> | tar xf - 解開每一層的 layer.tar |
docker push 到私有 Registry 被拒絕 |
Registry 需要認證或不允許匿名推送 | docker login <registry-url> 先登入;若 KOAD Registry 未啟動,kubectl get pod -n koad-registry 確認狀態 |
trivy image 掃描超時 |
映像太大或下載漏洞資料庫失敗 | 加 --timeout 10m;離線模式下先 trivy image --download-db-only 預下載資料庫 |
docker history 和分層結構的安全隱患明天 Day 23 防禦篇:Cosign 映像簽章、Trivy 漏洞掃描、Kyverno 驗簽策略、ImagePolicyWebhook。