Image Attack Surface and Minimal Base Images
Image Attack Surface and Minimal Base Images
A container image is a packaged application with everything it needs to run: the runtime, libraries, dependencies, and the application itself. Every package in that image is a potential vulnerability. The larger the image, the larger the attack surface — and the more CVEs you inherit from packages your application never uses.
Why image size matters for security
A typical ubuntu:22.04 base image contains over 80 packages, including system utilities, package managers, and libraries that your application never invokes. Each of those packages:
- May have known CVEs that require patching
- Increases the time to pull and deploy the image
- Provides tools an attacker can use if they compromise the container
# Large attack surface — full Ubuntu base
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip
COPY app.py .
CMD ["python3", "app.py"]
# Image size: ~250 MB
# Packages: 80+ (including curl, wget, gcc, etc.)
# CVE surface: large
Minimal base images
The solution is to use minimal base images that contain only what your application needs:
Distroless images (Google) contain only your application and its runtime dependencies — no shell, no package manager, no system utilities:
# Distroless — minimal attack surface
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /root/.local /home/nonroot/.local
COPY app.py .
USER nonroot
CMD ["app.py"]
# Image size: ~50 MB
# Packages: only Python runtime + app dependencies
# CVE surface: minimal
Alpine Linux is another popular minimal base (5 MB), though it uses musl libc which can cause compatibility issues with some applications:
FROM alpine:3.19
RUN apk add --no-cache python3 py3-pip
COPY app.py .
CMD ["python3", "app.py"]
# Image size: ~60 MB
Multi-stage builds
Multi-stage builds separate the build environment from the runtime environment. The final image contains only the compiled application, not the build tools:
# Build stage — includes compilers, headers, build tools
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server
# Runtime stage — only the compiled binary
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot
ENTRYPOINT ["/server"]
# Final image: ~15 MB, no shell, no compiler, no package manager
The multi-stage pattern is the most effective way to minimize image size and attack surface. The build stage can be large — it's discarded. Only the runtime stage matters for security.
Scanning images for vulnerabilities
Even minimal images need scanning. Vulnerability scanners match image layers against CVE databases:
# Trivy — scan a local image
trivy image myapp:latest
# Trivy — scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
# Trivy — scan a remote registry image
trivy image registry.company.com/myapp:v1.2.3
# Grype — alternative scanner
grype myapp:latest
Scan output includes:
- CVE identifier (e.g., CVE-2024-1234)
- Severity (CRITICAL, HIGH, MEDIUM, LOW)
- Package name and version
- Fixed version (if available)
Integrating scanning into CI/CD
Scanning must happen at build time, not after deployment:
# GitHub Actions example
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1 # Fail the build if CRITICAL or HIGH CVEs found
- name: Push image
if: success()
run: docker push registry.company.com/myapp:${{ github.sha }}
The exit-code: 1 ensures the build fails if critical vulnerabilities are found, preventing vulnerable images from reaching the registry.
Non-Root Users and Runtime Configuration
Non-Root Users and Runtime Configuration
Running a container as root is the default in most base images. If an attacker exploits a vulnerability in the application, they gain root privileges inside the container — which can be escalated to host access through container escapes.
The non-root principle
Every container should run as a non-root user. This limits the impact of a compromise:
# Create a non-root user and switch to it
FROM python:3.11-slim
RUN groupadd --system appgroup && \
useradd --system --gid appgroup --no-create-home appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["python3", "app.py"]
The USER directive in the Dockerfile ensures the container process runs as the specified user. Without it, the process runs as root.
Verifying the runtime user
# Check what user a container runs as
docker inspect --format='{{.Config.User}}' myapp:latest
# Check at runtime
docker run --rm myapp:latest id
# uid=999(appuser) gid=999(appgroup) groups=999(appgroup)
Dropping capabilities
Linux capabilities grant specific privileges to processes. Containers don't need most of them:
# Kubernetes pod spec — drop all capabilities, add only needed ones
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
containers:
- name: app
image: myapp:latest
securityContext:
runAsNonRoot: true
runAsUser: 999
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # Only if binding to ports < 1024
The key settings:
runAsNonRoot: true— Kubernetes rejects pods that would run as rootallowPrivilegeEscalation: false— prevents the process from gaining more privileges than its parentreadOnlyRootFilesystem: true— prevents writing to the container filesystemcapabilities.drop: [ALL]— removes all Linux capabilities
Read-only root filesystems
A read-only root filesystem prevents attackers from:
- Writing malicious binaries to disk
- Modifying configuration files
- Installing persistence mechanisms
- Tampering with logs
# Docker — read-only root filesystem
docker run --read-only --tmpfs /tmp myapp:latest
# The /tmp tmpfs provides a writable area for temporary files
# Everything else is read-only
Applications that need to write files should use mounted volumes or tmpfs for specific directories.
Image provenance and SBOM
Knowing what's in your image is as important as minimizing it. A Software Bill of Materials (SBOM) lists every package and dependency:
# Generate an SBOM with Syft
syft myapp:latest -o spdx-json > sbom.json
# Scan the SBom for vulnerabilities
grype sbom.json
SBOMs are essential for:
- Responding to new CVEs (know which images are affected)
- Compliance requirements (know what software you're running)
- Supply chain security (verify the provenance of dependencies)
Practice Problems
Rewrite the following Dockerfile to minimize attack surface, run as non-root, and use a multi-stage build: ```dockerfile FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 python3-pip COPY requirements.txt . RUN pip install -r requirements.txt COPY app.py . CMD ["python3", "app.py"] ```
Solution
```dockerfile
# Build stage
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
# Runtime stage
FROM python:3.11-slim
RUN groupadd --system appgroup && \
useradd --system --gid appgroup --no-create-home appuser
WORKDIR /app
COPY --from=builder /root/.local /home/nonroot/.local
COPY --chown=appuser:appgroup app.py .
USER appuser
ENV PATH=/home/nonroot/.local/bin:$PATH
CMD ["python3", "app.py"]
```
Changes: multi-stage build (discards pip cache and build tools), non-root user, slim base image (fewer packages than ubuntu:22.04), --chown for correct file ownership. Write a Kubernetes pod spec that runs a container as non-root, drops all capabilities, uses a read-only root filesystem, and prevents privilege escalation.
Solution
```yaml
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
```
The emptyDir volume provides a writable /tmp directory while the root filesystem remains read-only. All capabilities are dropped, privilege escalation is prevented, and the process runs as UID 999. Quiz
1. Why are distroless images more secure than full OS base images like ubuntu:22.04?
2. A container runs as root by default. An attacker exploits a vulnerability in the application and gains code execution inside the container. What is the most effective way to limit the impact of this compromise?
3. Why should vulnerability scanning be integrated into the CI/CD pipeline rather than run manually after deployment?
Flashcards
Question
What is a distroless image and why is it more secure?
Click to reveal answer
Answer
A distroless image contains only the application and its runtime dependencies — no shell, package manager, or system utilities. This minimizes the attack surface and provides fewer tools for an attacker to use.
Question
What is a multi-stage build and why does it improve security?
Click to reveal answer
Answer
A multi-stage build separates the build environment from the runtime environment. The final image contains only the compiled application, not build tools, compilers, or package managers — reducing the attack surface.
Question
What Kubernetes security context settings limit container privileges?
Click to reveal answer
Answer
runAsNonRoot: true, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, and capabilities.drop: [ALL]. Together these prevent root access, privilege escalation, filesystem writes, and unnecessary capabilities.
Question
Why integrate vulnerability scanning into CI/CD?
Click to reveal answer
Answer
It fails the build when critical CVEs are found, preventing vulnerable images from being pushed to the registry. This stops vulnerabilities at the source rather than detecting them after deployment.
Revision Notes
Key Takeaways
- 1. Minimal base images (distroless, alpine) reduce CVE surface by including only what the application needs
- 2. Multi-stage builds discard build tools from the final image
- 3. Running as non-root with dropped capabilities and read-only filesystem limits compromise impact
- 4. Vulnerability scanning in CI/CD prevents vulnerable images from reaching the registry
Interview Tips
- • Use a concrete example: 'We reduced our image from 250MB to 15MB using distroless and multi-stage builds, which eliminated 90% of our CVE findings.'
- • Explain the runtime security: 'Even if an attacker exploits our app, they can't write files, escalate privileges, or use system tools because we run as non-root with a read-only filesystem.'
- • Discuss the pipeline integration: 'Our CI pipeline fails the build on any CRITICAL CVE, so vulnerable images never reach the registry.'
Cheat Sheet
Container Image Security Cheat Sheet
Phase: Container and Kubernetes Security | Level: intermediate
Minimizing attack surface:
- Use distroless or alpine base images instead of full OS images
- Multi-stage builds: discard build tools, ship only the runtime
- Every package is a potential CVE — fewer packages = smaller attack surface
Non-root runtime:
- Dockerfile:
USER appuserdirective - Kubernetes:
runAsNonRoot: true,runAsUser: 999 - Prevents root access if the application is compromised
Kubernetes security context:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
Vulnerability scanning:
- Scan at build time in CI/CD, not after deployment
- Fail builds on CRITICAL/HIGH CVEs
- Tools: Trivy, Grype
- Generate SBOMs for provenance tracking
Key principle: The image is the attack surface. Minimize it, scan it, and run it with least privilege.