</>Học Dev
Bài học

Tuần 4 - Ngày 20: Container Security

Tuần 4 – Ngày 20

Mục tiêu học tập

  • Hiểu container threat model: shared kernel khiến container "yếu" hơn VM
  • Chọn base image an toàn: distroless, Alpine, Debian slim — trade-off
  • Không chạy container dưới root: USER directive, non-root UID, file permission
  • Xử lý secret đúng cách trong build và runtime (BuildKit secrets, env injection)
  • Scan image với Trivy trong CI và ECR scan-on-push
  • Nắm các CVE container escape kinh điển: CVE-2019-5736 (runc), CVE-2022-0492 (cgroup)
  • Cấu hình runtime hardening: read-only rootfs, seccomp profile, AppArmor/SELinux

1. Threat model: Container vs VM

VMstackContainerstackVMAVMBContAContBGuestOS(per-VM)SharedKernel(host)escape=ownhostHypervisorescape=ownhostHostOSHostOS/HWContaineryếuhơnVM:escapekhinamespace/cgrouplàđngthngtrênkernelhost.VMphithoátquahypervisorsurfacenh,auditk,khóhơnnhiu.

Điểm yếu của container: nếu attacker thoát khỏi namespace/cgroup (container escape), họ đứng trực tiếp trên kernel host — chiếm toàn bộ host. VM phải thoát qua hypervisor (khó hơn nhiều).

Lớp tấn côngContainerVM
Application bugTương đươngTương đương
Image supply chainCao (pull public image)Thấp hơn (AMI ít được pull random)
Kernel vulnerabilityTất cả container đều bịChỉ 1 VM bị (nếu kernel guest khác host)
Runtime escaperunc, containerd bugsHypervisor bugs (rất hiếm)
Cross-tenantCần PSS Restricted + gVisor/KataMặc định mạnh

Nguyên tắc: với multi-tenant workload, dùng Firecracker (Lambda, Fargate dùng), Kata Containers, hoặc gVisor để có VM-grade isolation trên trải nghiệm container.


2. Image security: chọn base image

Phổ base image, từ "fat" tới "minimal"

BaseSizeCó shell?Có package manager?Use case
ubuntu:22.04~80 MBCó (bash)aptDebug, full env
debian:12-slim~30 MBCó (bash)aptProduction khi cần libc + shell
alpine:3.19~7 MBCó (ash)apkProduction minimal (musl libc)
gcr.io/distroless/static~2 MBKhôngKhôngGo binary, static link
gcr.io/distroless/nodejs20~80 MBKhôngKhôngNode app, runtime only
scratch0 MBKhôngKhôngGo static binary tuyệt đối

Distroless — "không có gì để hack"

# Multi-stage build với distroless
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

# Runtime: chỉ có binary + minimal libs, không shell, không apt
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Distroless không có /bin/sh, apt, curl, wget — attacker shell vào cũng không có công cụ để pivot. Trade-off: không debug được bằng docker exec; phải dùng kubectl debug ephemeral container.

Alpine vs Debian slim

Alpine dùng musl libc (không phải glibc). Hậu quả:

  • Một số binary glibc-only (vd: precompiled wheel Python, libvips) không chạy được
  • DNS resolver khác nhau (Alpine có bug DNS với search domain dài)
  • Image cực nhỏ (~7 MB)

Debian slim dùng glibc — tương thích rộng, image vẫn nhỏ (~30 MB). Mặc định an toàn hơn cho team không muốn debug musl quirks.

Minimize layers và .dockerignore

# ❌ BAD: nhiều layer rác
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y python3
RUN rm -rf /var/lib/apt/lists/*

# ✅ GOOD: 1 layer, clean trong cùng layer
FROM ubuntu:22.04
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl python3 && \
    rm -rf /var/lib/apt/lists/*
# .dockerignore — tránh COPY rác (secrets, node_modules host)
.git
.env
.env.*
node_modules
*.log
**/Dockerfile
**/docker-compose*.yml

3. Đừng chạy container dưới root

Vấn đề khi chạy root

# ❌ Mặc định Dockerfile chạy với UID=0
FROM node:20
COPY . /app
WORKDIR /app
CMD ["node", "server.js"]

Nếu attacker RCE vào container và escape, họ đã là root trên host. Ngay cả không escape, root trong container có thể:

  • Đọc/ghi mọi file trong filesystem container
  • Bind port < 1024
  • Modify volumes mount từ host nếu không read-only

Pattern non-root đúng

FROM node:20-alpine
WORKDIR /app

# 1. Copy source (vẫn cần root vì set ownership)
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev

COPY --chown=node:node . .

# 2. Drop privilege trước CMD
USER node            # image node có sẵn user `node` UID=1000
EXPOSE 3000          # >= 1024 để non-root bind được
CMD ["node", "server.js"]

Tạo user mới nếu image base không có

FROM debian:12-slim

# Tạo user non-root với UID/GID cố định
RUN groupadd --gid 10001 app && \
    useradd --uid 10001 --gid 10001 --shell /sbin/nologin --no-create-home app

WORKDIR /app
COPY --chown=10001:10001 ./build /app

USER 10001:10001
ENTRYPOINT ["/app/server"]

Tại sao UID cố định? Khi Kubernetes mount PVC, file ownership theo UID số. Nếu image rebuild đổi UID, app mất quyền đọc dữ liệu cũ. UID >= 10000 tránh đụng UID system.

File permission gotcha

# ❌ Quên --chown → file thuộc root, non-root user không ghi được
COPY app.py /app/app.py
USER app
CMD ["python", "/app/app.py"]   # nếu app cần ghi /app/cache → fail

# ✅ Set ownership ngay khi copy
COPY --chown=app:app app.py /app/app.py

4. Xử lý secret trong image

Pattern sai (đừng làm)

# ❌ Secret nằm trong layer, mọi người pull image đều thấy
COPY .env /app/.env
ENV DATABASE_PASSWORD=hunter2
# Attacker chỉ cần
docker history --no-trunc your-image:latest   # thấy ENV value
docker save your-image | tar xO | grep -ai password   # thấy .env trong layer

Pattern đúng

Option 1 — BuildKit secrets (cho build-time secret như npm token)

# syntax=docker/dockerfile:1.6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
# Build với secret không lưu vào layer
docker build --secret id=npmrc,src=$HOME/.npmrc -t app .

Secret chỉ mount trong build step, không nằm trong image final.

Option 2 — Runtime injection (cho runtime secret)

# Docker Compose
docker run -e DATABASE_URL=$(aws secretsmanager get-secret-value ...) app

# Kubernetes — dùng Secret object hoặc External Secrets Operator (Day 21)
# AWS ECS — dùng `secrets` trong Task Definition (pull từ Secrets Manager/SSM)

Option 3 — Sidecar / init container fetch secret

Init container gọi Vault/Secrets Manager, ghi secret vào shared volume (emptyDir tmpfs), main container đọc từ volume. Secret không bao giờ vào image hay env var (env var dễ leak qua /proc/<pid>/environ).


5. Image scanning trong CI

Trivy — scan trong CI

# .github/workflows/build.yml
name: Build & Scan
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t app:${{ github.sha }} .

      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1          # fail build nếu có HIGH/CRITICAL

      - name: Upload to GitHub Security
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

Trivy phát hiện:

  • OS package vulnerabilities (Debian/Alpine/RHEL CVE database)
  • Language deps vulnerabilities (npm, pip, Go modules)
  • Misconfiguration (Dockerfile best practice)
  • Secret strings trong image (private key, token)

ECR scan-on-push

# Bật enhanced scanning (powered by Inspector)
aws ecr put-registry-scanning-configuration \
  --scan-type ENHANCED \
  --rules '[{
    "scanFrequency": "CONTINUOUS_SCAN",
    "repositoryFilters": [{"filter": "*", "filterType": "WILDCARD"}]
  }]'

# Push image, scan tự động chạy
docker tag app:latest 111111111111.dkr.ecr.us-east-1.amazonaws.com/app:latest
docker push 111111111111.dkr.ecr.us-east-1.amazonaws.com/app:latest

# Xem finding
aws ecr describe-image-scan-findings \
  --repository-name app \
  --image-id imageTag=latest

Enhanced scan (Inspector v2) continuous re-scan khi có CVE mới — không chỉ lúc push. Basic scan chỉ chạy 1 lần lúc push.


6. Container Escape CVE kinh điển

CVE-2019-5736 — runc escape

runc (runtime mà Docker, containerd dùng) cho phép malicious container ghi đè binary runc trên host. Lần sau host gọi runc exec → chạy code attacker với quyền root.

ContainerHostattackerexecruncbinary(/usr/bin/runc)overwritevimaliciousbinaryNextexecexecattackercodeasroot

Fix: nâng runc >= 1.0.0-rc6 (2019). Bài học: runtime CVE = mọi container trên host bị ảnh hưởng. Patch host kernel + runtime kịp thời.

CVE-2022-0492 — cgroup release_agent

Container có CAP_SYS_ADMIN (mặc định không có, nhưng nhiều CI/CD platform bật) có thể ghi vào /sys/fs/cgroup/release_agent để host chạy script khi cgroup empty → escape.

Fix: Linux kernel patch + không cấp CAP_SYS_ADMIN cho container (mặc định Kubernetes không cấp). Đừng dùng docker run --privileged cho workload production.

CVE-2024-21626 — runc leaky file descriptor ("Leaky Vessels")

runc <= 1.1.11 leak file descriptor /proc/self/fd/<n> trỏ về host filesystem → container có thể cd ra ngoài bằng symlink. Fix: nâng runc >= 1.1.12.

Bài học chung

Defense in depth:
  1. Patch kernel + runc/containerd thường xuyên
  2. Drop CAP_SYS_ADMIN, CAP_NET_ADMIN nếu app không cần
  3. Read-only rootfs để attacker không ghi được binary
  4. Seccomp profile mặc định block syscall nguy hiểm
  5. Non-root user — escape vẫn không có root trên host

7. Runtime hardening

Read-only root filesystem

# Docker
docker run --read-only --tmpfs /tmp:size=64M app

# Kubernetes
securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]

App ghi log → mount volume / emptyDir riêng cho thư mục cần ghi. Mặc định mọi nơi khác read-only → attacker không drop được binary.

Seccomp profile — block dangerous syscall

Seccomp (secure computing mode) filter syscall mà container được phép gọi. Docker và containerd có default profile block ~44 syscall ít dùng (vd: mount, reboot, kexec_load).

# Docker dùng default
docker run --security-opt seccomp=default.json app

# Custom profile cho app chỉ làm HTTP
docker run --security-opt seccomp=./minimal.json app

Tool oci-seccomp-bpf-hook (Red Hat) record syscall thực tế app dùng, generate seccomp profile minimal.

AppArmor / SELinux

  • AppArmor (Ubuntu, Debian): path-based MAC. Profile khai báo "binary X được đọc /var/log/, không được mở socket". Docker có default profile docker-default.
  • SELinux (RHEL, Fedora, Amazon Linux): label-based MAC. Mọi process, file có security label; policy quyết định label nào tương tác được với label nào.

Cả hai cung cấp lớp phòng thủ thứ N — kể cả attacker thoát namespace, MAC ngăn họ truy cập file ngoài policy. Đừng --security-opt apparmor=unconfined cho production.


8. Câu hỏi ôn tập

  1. Tại sao container có cùng kernel với host khiến security boundary yếu hơn VM?

    Xem đáp án

    Container dùng namespace + cgroup để cô lập process, nhưng tất cả share cùng kernel với host. Một kernel vulnerability hoặc bug trong runc/containerd cho phép attacker "thoát" namespace, đứng trực tiếp trên kernel host → toàn quyền host và mọi container khác.

    VM có hypervisor tạo virtualized hardware riêng, kernel guest tách biệt kernel host. Để escape, attacker phải khai thác hypervisor vulnerability — surface nhỏ và được audit kỹ. Vì vậy multi-tenant nhạy cảm thường dùng Firecracker/Kata (microVM) thay container thuần.

  2. Distroless image khác Alpine ở điểm gì và khi nào nên chọn distroless?

    Xem đáp án

    Alpine vẫn có shell (ash), package manager (apk), busybox utilities. Distroless loại bỏ tất cả — chỉ có binary app + minimal libs (libc, ssl certs, timezone). Attacker shell vào distroless không có sh, curl, wget để pivot.

    Chọn distroless khi: (1) app static (Go binary), (2) Node/Python/Java runtime đơn giản, (3) không cần debug trong container. Trade-off: không docker exec sh được — phải dùng kubectl debug ephemeral container hoặc image debug riêng cho dev.

  3. COPY .env /app/.env trong Dockerfile sai ở đâu, dù bạn delete file sau đó?

    Xem đáp án

    Mỗi COPY tạo một layer immutable. Dù step sau có RUN rm /app/.env, file vẫn nằm trong layer cũ — chỉ bị "hide" ở layer mới. Ai pull image đều xem được bằng docker history --no-trunc hoặc docker save | tar xO.

    Fix: dùng BuildKit secrets (--mount=type=secret) cho build-time secret, hoặc inject runtime qua env var/Secrets Manager.

  4. Tại sao chạy container với --privileged nguy hiểm?

    Xem đáp án

    --privileged cấp tất cả Linux capabilities, disable seccomp, expose host device. Container có thể: (1) mount host filesystem (/dev/sda), (2) load kernel module, (3) escape qua cgroup release_agent (CVE-2022-0492), (4) ghi /proc/sys/* để chỉnh host. RCE vào container = root trên host.

    Chỉ dùng --privileged cho công cụ hệ thống (Docker-in-Docker đặc biệt, kernel module loader) — không bao giờ cho app workload. Trong K8s, set allowPrivilegeEscalation: false, privileged: false qua Pod Security Standard.

  5. Tại sao non-root user UID nên cố định và >= 10000?

    Xem đáp án

    Cố định: K8s mount PV/PVC theo UID số (không phải tên). Nếu rebuild image đổi UID, app mất quyền đọc dữ liệu cũ trên volume — gây outage. Set UID cố định (vd 10001) trong Dockerfile để consistent qua các build.

    >= 10000: UID < 1000 thường dành cho user/system service trên host (root=0, daemon=1, sshd=74...). Trùng UID có thể gây nhầm lẫn quyền giữa container và host (đặc biệt khi mount hostPath). UID >= 10000 tránh đụng và rõ ràng là "container app user".

Bài tập thực hành

# 1. Build image với distroless và non-root
mkdir container-lab && cd container-lab

cat > app.go <<'EOF'
package main
import "net/http"
func main() {
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello\n"))
  })
  http.ListenAndServe(":8080", nil)
}
EOF

cat > go.mod <<'EOF'
module app
go 1.22
EOF

cat > Dockerfile <<'EOF'
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
EOF

docker build -t app:distroless .
docker run -d -p 8080:8080 --name app --read-only --cap-drop ALL app:distroless
curl localhost:8080

# 2. Scan bằng Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image --severity HIGH,CRITICAL app:distroless

# 3. Thử shell vào (không có shell)
docker exec -it app /bin/sh   # → exec failed: not found

# 4. Build với BuildKit secret
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmtoken,src=$HOME/.npmrc \
  -t app:secret-build .

# 5. Cleanup
docker rm -f app

Tài liệu tham khảo chính thức


Bài tiếp theo →