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
Đ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ông | Container | VM |
|---|---|---|
| Application bug | Tương đương | Tương đương |
| Image supply chain | Cao (pull public image) | Thấp hơn (AMI ít được pull random) |
| Kernel vulnerability | Tất cả container đều bị | Chỉ 1 VM bị (nếu kernel guest khác host) |
| Runtime escape | runc, containerd bugs | Hypervisor bugs (rất hiếm) |
| Cross-tenant | Cần PSS Restricted + gVisor/Kata | Mặ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"
| Base | Size | Có shell? | Có package manager? | Use case |
|---|---|---|---|---|
ubuntu:22.04 | ~80 MB | Có (bash) | apt | Debug, full env |
debian:12-slim | ~30 MB | Có (bash) | apt | Production khi cần libc + shell |
alpine:3.19 | ~7 MB | Có (ash) | apk | Production minimal (musl libc) |
gcr.io/distroless/static | ~2 MB | Không | Không | Go binary, static link |
gcr.io/distroless/nodejs20 | ~80 MB | Không | Không | Node app, runtime only |
scratch | 0 MB | Không | Không | Go 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.
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
-
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.
-
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ùngkubectl debugephemeral container hoặc image debug riêng cho dev. -
COPY .env /app/.envtrong Dockerfile sai ở đâu, dù bạn delete file sau đó?Xem đáp án
Mỗi
COPYtạ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ằngdocker history --no-trunchoặcdocker save | tar xO.Fix: dùng BuildKit secrets (
--mount=type=secret) cho build-time secret, hoặc inject runtime qua env var/Secrets Manager. -
Tại sao chạy container với
--privilegednguy hiểm?Xem đáp án
--privilegedcấ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
--privilegedcho công cụ hệ thống (Docker-in-Docker đặc biệt, kernel module loader) — không bao giờ cho app workload. Trong K8s, setallowPrivilegeEscalation: false,privileged: falsequa Pod Security Standard. -
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
- Docker security overview
- Distroless images
- Trivy documentation
- BuildKit secrets
- Amazon ECR image scanning
- OWASP Docker Top 10