courses

Container Security

A convenience boundary, not a security boundary

Containers are not a security boundary — they are a convenience boundary. A container running on the same kernel as its host shares that kernel. If you run a container as root, a container escape can give an attacker root on the host. Container security is about understanding what isolation containers actually provide, and then applying controls at every layer where that isolation is incomplete.

⚠️ The kernel is shared. Namespaces and cgroups partition resources, but every container calls into the same host kernel. A single kernel exploit — or a --privileged flag, or a bind-mounted host path — collapses the isolation entirely. Treat root-in-container as one misconfiguration away from root-on-host.

This page assumes you already know the Docker fundamentals — namespaces, cgroups, images, and Compose — covered on the Containerization page.

The Container Threat Model

What containers isolate:

Isolation Mechanism Notes
Filesystem Overlay FS + chroot-like Host filesystem accessible if volume-mounted or --privileged
Process namespace PID namespace Container processes invisible to each other by default
Network namespace Network namespace Separate IP stack; bridge network by default
User namespace UID namespace UID 0 in container ≠ UID 0 on host only if userns-remap enabled

What containers do not isolate:

The practical threat model for the lab environment:

Kali (attacker)
  ↓ HTTP to port 80
nginx container  →  DVWA container  →  MariaDB container
                         ↓
                   container escape?
                         ↓
                   ubuntu-server host
                         ↓
                   Proxmox host  ← out of scope

A successful DVWA exploit that leads to RCE inside the container is one step from a container escape if the container is misconfigured. The controls in this page prevent that step.

Dockerfile Security

Run as a non-root user

The most impactful single change in a Dockerfile:

# Bad: process runs as root
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx

# Good: create a dedicated user and switch to it
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx \
    && useradd --system --no-create-home --shell /usr/sbin/nologin appuser
USER appuser

Verify after building:

docker run --rm myimage whoami    # must not print "root"

Minimal base images

Every package in the base image is a potential CVE. Prefer:

Base image Use case Approximate size
alpine General; musl libc ~7 MB
debian:bookworm-slim Needs glibc ~75 MB
ubuntu:24.04 Familiarity; lab use ~80 MB
scratch Static binaries only 0 MB

Avoid latest tags in production — pin a specific digest:

FROM debian:bookworm-slim@sha256:abc123...

No secrets in image layers

Every RUN, ENV, and ARG instruction creates a layer that persists in the image history even if you delete the file in a later layer:

# Bad: password baked into layer forever
ENV DB_PASSWORD=supersecret
RUN curl -u admin:supersecret https://internal-api/setup

# Good: secret provided at runtime via environment or mounted file
# (OpenBao agent, Docker secrets, or explicit env var at run time)
RUN curl -u admin:${DB_PASSWORD} https://internal-api/setup

Inspect your image layers before pushing:

docker history --no-trunc myimage | grep -i secret
docker inspect myimage | jq '.[0].Config.Env'

Runtime Hardening

Read-only root filesystem

A read-only filesystem prevents an attacker from writing tools, persistence mechanisms, or modified binaries inside the container:

# docker-compose.yml
services:
  dvwa:
    image: dvwa:hardened
    read_only: true
    tmpfs:
      - /tmp           # writable scratch space
      - /var/run       # PID files
      - /var/log       # logs (or use a named volume)
    volumes:
      - dvwa-data:/var/www/html/hackable/uploads  # the one writable path DVWA needs

Linux capabilities

Docker drops most capabilities by default but still grants more than most applications need. Drop all and add back only what the process requires:

services:
  nginx:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE   # bind to ports < 1024
      - CHOWN              # change file ownership at startup
      - SETUID             # drop to unprivileged user after startup
      - SETGID

Common capabilities and when they are needed:

Capability What it allows Usually needed?
NET_BIND_SERVICE Bind to ports < 1024 Only if serving HTTP/HTTPS directly
NET_ADMIN Network configuration Rarely; only network tools
SYS_PTRACE Attach debugger Never in production
SYS_ADMIN Catch-all admin Almost never; red flag
CHOWN Change file ownership Startup scripts that fix permissions
SETUID / SETGID Change UID/GID Startup scripts that drop privileges

no-new-privileges

Prevents processes inside the container from gaining new capabilities via SUID binaries or setuid syscalls:

services:
  dvwa:
    security_opt:
      - no-new-privileges:true

seccomp Profiles

seccomp (Secure Computing Mode) is a kernel feature that restricts which syscalls a process can make. Docker applies a default seccomp profile that blocks ~44 syscalls. You can tighten this further with a custom profile.

The default Docker profile blocks: keyctl, add_key, request_key, ptrace, mbind, migrate_pages, move_pages, set_mempolicy, and many others. Full list.

Custom profile — block ptrace and reboot

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "syscalls": [
    {
      "names": ["ptrace", "reboot", "kexec_load"],
      "action": "SCMP_ACT_ERRNO"
    }
  ]
}

A practical approach: start from the Docker default profile and add blocks:

# Download the Docker default profile
curl -sO https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json

# Apply it explicitly to a container
docker run --security-opt seccomp=default.json myimage

# Apply a custom profile
docker run --security-opt seccomp=custom-seccomp.json myimage

In Compose:

services:
  dvwa:
    security_opt:
      - no-new-privileges:true
      - seccomp:./custom-seccomp.json

Verifying seccomp is applied

docker inspect dvwa_container | jq '.[0].HostConfig.SecurityOpt'
# Should show something like:
# ["no-new-privileges:true","seccomp:{...}"]

AppArmor Profiles

AppArmor (Application Armor) is a Linux Security Module that restricts what a process can access based on a profile. Docker applies the docker-default AppArmor profile to all containers unless told otherwise.

Apply the default profile explicitly

services:
  nginx:
    security_opt:
      - apparmor:docker-default

Generate a custom profile with aa-genprof

# Install AppArmor utilities
apt install apparmor-utils

# Generate a profile for nginx (runs nginx, watches what it does)
aa-genprof nginx

# Refine the profile by watching log violations
aa-logprof

# Check profile status
aa-status

Profile modes

Mode Behavior
enforce Block violations; log them
complain Allow violations; log them
disable No AppArmor enforcement

Always develop profiles in complain mode first, then switch to enforce after testing:

aa-complain /etc/apparmor.d/docker-nginx
# ... test the application ...
aa-enforce /etc/apparmor.d/docker-nginx

Image Scanning with Trivy

Trivy scans container images, filesystems, and IaC for known CVEs, misconfigurations, and secrets.

Basic image scan

# Scan an image — show only HIGH and CRITICAL
trivy image --severity HIGH,CRITICAL dvwa:latest

# Exit non-zero if any CRITICAL CVEs found (useful in CI)
trivy image --severity CRITICAL --exit-code 1 dvwa:latest

# Save report as JSON
trivy image --format json --output trivy-report.json dvwa:latest

Interpreting output

dvwa:latest (debian 12.5)
=========================
Total: 12 (HIGH: 8, CRITICAL: 4)

┌──────────────┬────────────────┬──────────┬────────────────────────────────┐
│   Library    │ Vulnerability  │ Severity │         Fixed Version          │
├──────────────┼────────────────┼──────────┼────────────────────────────────┤
│ libssl3      │ CVE-2024-XXXX  │ CRITICAL │ 3.0.11-1~deb12u2               │
│ libcurl4     │ CVE-2024-YYYY  │ HIGH     │ 7.88.1-10+deb12u6              │
└──────────────┴────────────────┴──────────┴────────────────────────────────┘

Remediation: rebuild with an updated base image or upgrade the specific package in your Dockerfile.

Filesystem scan (SCA for application dependencies)

# Scan a Python project's requirements
trivy fs --scanners vuln .

# Scan with secret detection
trivy fs --scanners secret .

Generating an SBOM

A Software Bill of Materials lists every component in an image — required by many compliance frameworks and useful for tracking which images are affected when a new CVE drops:

# Generate CycloneDX SBOM
trivy sbom --format cyclonedx --output sbom.json dvwa:hardened

# Generate SPDX SBOM
trivy sbom --format spdx-json --output sbom.spdx.json dvwa:hardened

Network Segmentation

By default, all containers in a Compose file share one bridge network and can reach each other freely. Segment the network to enforce the principle of least privilege at the network layer:

networks:
  frontend:   # nginx only
    driver: bridge
  backend:    # dvwa + mariadb only
    driver: bridge

services:
  nginx:
    networks:
      - frontend
      - backend   # nginx bridges both to proxy to dvwa

  dvwa:
    networks:
      - backend   # dvwa cannot be reached from outside backend

  mariadb:
    networks:
      - backend   # mariadb only accessible from backend

Verify isolation:

# This should fail — nginx should NOT reach mariadb directly
docker exec nginx_container mariadb -h mariadb -u dvwa

Secrets in Containers

Never put secrets in environment variables visible to docker inspect, or in image layers.

Option 1: Docker secrets (Compose)

secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  dvwa:
    secrets:
      - db_password
    # Secret available at /run/secrets/db_password inside container

Option 2: OpenBao Agent sidecar

Run an OpenBao Agent container that authenticates to OpenBao and writes a secret file to a shared volume:

services:
  openbao-agent:
    image: openbao/openbao:latest
    command: bao agent -config=/openbao/config/agent.hcl
    volumes:
      - bao-secrets:/openbao/secrets

  dvwa:
    volumes:
      - bao-secrets:/run/secrets:ro
    # DVWA reads /run/secrets/db_password at startup

volumes:
  bao-secrets:

Container Security Checklist

Before a container goes into a lab or production environment:

scripts/container-check.sh
#!/usr/bin/env bash
# scripts/container-check.sh
set -euo pipefail

CONTAINER="$1"
FAIL=0

check() {
    local label="$1" result="$2"
    if [[ "$result" == "pass" ]]; then
        echo "  PASS  $label"
    else
        echo "  FAIL  $label"
        ((FAIL++))
    fi
}

# Not running as root
user=$(docker exec "$CONTAINER" whoami 2>/dev/null)
[[ "$user" != "root" ]] && r=pass || r=fail
check "Non-root user (got: $user)" "$r"

# no-new-privileges set
val=$(docker inspect "$CONTAINER" | jq -r '.[0].HostConfig.SecurityOpt[]?' | grep -c no-new-privileges || true)
[[ "$val" -ge 1 ]] && r=pass || r=fail
check "no-new-privileges" "$r"

# Not privileged
priv=$(docker inspect "$CONTAINER" | jq -r '.[0].HostConfig.Privileged')
[[ "$priv" == "false" ]] && r=pass || r=fail
check "Not --privileged" "$r"

# No secrets in env
envvars=$(docker inspect "$CONTAINER" | jq -r '.[0].Config.Env[]?' | grep -iE 'password|secret|key|token' || true)
[[ -z "$envvars" ]] && r=pass || r=fail
check "No secrets in environment variables" "$r"

echo ""
[[ "$FAIL" -eq 0 ]] && echo "All checks passed." || echo "$FAIL check(s) failed."
exit "$FAIL"

Key takeaways

References


Related course pages: Containerization · CI/CD & DevOps · Secrets management with OpenBao

🛠️ Maintenance note: Trivy’s CLI moved scanner selection to --scanners vuln,secret,misconfig (the older --security-checks flag is removed); re-check flag names against the installed Trivy version. The Docker default seccomp profile and capability set shift between Engine releases — pull profiles/seccomp/default.json from the matching moby/moby tag rather than master. this course uses OpenBao (the OpenBao Project / Linux Foundation fork of HashiCorp Vault, after Vault’s 2023 BSL relicensing); the Agent sidecar above uses the openbao/openbao image and bao CLI, and the API stays Vault-compatible.