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

What Docker actually is

Docker is used by developers as a way to easily ship and configure applications. It does this by allowing the developer to define a repeatable, isolated environment with a simple configuration file. From the application’s point of view it is running on a Linux system with full network and filesystem access.

Sounds cool, how does it work? This lesson takes the container you ran in lesson 1 apart, from the inside and from the outside.

A container is a process

A container is an isolated environment, it is not a virtual machine. In many ways docker is really just a clever way to use several features included in the linux kernel.

Three kernel features do the heavy lifting:

  • Namespaces control what a process can see. A separate PID namespace means its processes are numbered from 1 and it cannot see yours. A mount namespace gives it a different root filesystem. Network, UTS (hostname), IPC and user namespaces do the same for their own slice of the world.
  • cgroups control what a process can use. CPU, memory, block IO, and how many processes it may spawn.
  • A union filesystem (overlayfs, usually) stacks read-only image layers with one thin writable layer on top. This minimizes the files that get copied around.

The Docker daemon assembles and manages those three into something you can drive from a command line.

Just one kernel

If a container really is a process on your kernel, then a container claiming to be Ubuntu 22.04 will report your kernel version, not Ubuntu’s. Lets look:

$ uname -a
Linux slopkiddie 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul  1 21:53:05 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux

$ docker run --rm ubuntu:22.04 uname -a
Linux 5b2238e84881 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul  1 21:53:05 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux

Same kernel, same build date, different hostname. Ubuntu 22.04 shipped with 5.15. The container is running 5.15, because nothing in that container is running a kernel at all. The image is a userland: libc, a package manager, /etc.

Two consequences fall straight out of that:

  • A kernel exploit that works in the container works on the host, because it is the same kernel. Keep that security boundary in mind if you are running hostile code.
  • You cannot run a Linux container on a Windows kernel or a Darwin kernel. On macOS and Windows, Docker Desktop is quietly running a Linux VM and your containers live in it. uname -a on a Mac will show a LinuxKit kernel that belongs to that VM.

What can we see from the OS?

First start something that stays alive, and then ask Docker what is running in it:

$ docker run -d --name probe ubuntu:22.04 tail -f /dev/null
f4dacd21217a328a25265187897dc022a71add02dcef26f25861e7c58fc82b7f

Above we started a container, named it probe and had it detached -d so it will run in the background. It printed the container’s full ID.

A container lives exactly as long as its main process, so that main process has to be something that does not finish. tail -f /dev/null blocks forever and costs nothing, which makes it the usual way to keep a container parked and waiting.

To look inside that container, we use the docker exec command which lets us execute a process inside a running container.

$ docker exec probe ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.5  0.0   2824  1544 ?        Ss   15:04   0:00 tail -f /dev/null
root           7  0.0  0.0   7064  3108 ?        Rs   15:04   0:00 ps aux

Remember a container is just a clever way to run processes in different namespaces. We can use docker top to see the running processes for the container. In this example it is PID 388733.

$ docker top probe
UID       PID      PPID     C   STIME   TTY   TIME       CMD
root      388733   388708   0   15:04   ?     00:00:00   tail -f /dev/null

Now leave Docker out of it entirely and ask your host machine:

$ ps -o pid,ppid,cmd -p 388733
    PID    PPID CMD
 388733  388708 tail -f /dev/null

There it is, in your process table. docker top and ps are looking at the same process from the different namespace. From the container PID 1, and nothing else on the machine is visible. That is the PID namespace doing its job.

Look around inside

Let’s get a shell and look at it. Here we use -it to run the container in interactive mode and allocates a TTY:

$ docker run --rm -it ubuntu:22.04 bash
root@5b2238e84881:/# ls /
bin   dev  home  lib32   libx32  mnt  proc  run   srv  tmp  var
boot  etc  lib   lib64   media   opt  root  sbin  sys  usr

A complete Linux filesystem, and none of it is yours. Your /home is not in there. This is Ubuntu 22.04’s userland, unpacked from the image.

Where does it come from?

root@5b2238e84881:/# mount | grep ' / '
overlay on / type overlay (rw,relatime,lowerdir=…/snapshots/283/fs:…/snapshots/68/fs,upperdir=…/snapshots/284/fs,workdir=…/snapshots/284/work)

root@5b2238e84881:/# df -h /
Filesystem      Size  Used Avail Use% Mounted on
overlay          62G   25G   34G  43% /

/ is an overlay: two read-only lower directories, which are the image layers, and one writable upper directory, which is this container. 62G is your disk, because that is where all of it lives.

Watch what the writable layer means in practice:

$ docker run --rm ubuntu:22.04 sh -c 'echo secret > /tmp/loot; ls /tmp'
loot

$ docker run --rm ubuntu:22.04 ls /tmp

Nothing the second time. Same image, new container, new empty upper layer. Every change you make lives in that one thin layer and dies with the container, which is exactly the property that makes containers good for breaking things and useless for keeping things. Lesson 4 is how you get work back out.

You can see the layer’s contents from the host side:

$ docker exec probe sh -c 'touch /tmp/loot; echo hi > /root/notes.txt'
$ docker diff probe
C /root
A /root/notes.txt
C /tmp
A /tmp/loot

A for added, C for changed, D for deleted. docker diff is a fast answer to “what did this container touch”, and it is worth remembering when the container is not yours.

What the kernel is actually limiting

Namespaces isolate things. cgroups limit them, and by default a container gets no limits at all:

$ docker run --rm ubuntu:22.04 cat /sys/fs/cgroup/memory.max
max

max means unlimited. This is okay for development, but always remember A container with a runaway process will happily take the whole machine down with it. We can limit this with:

$ docker run --rm --memory 256m --cpus 0.5 ubuntu:22.04 sh -c 'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/cpu.max'
268435456
50000 100000

268435456 bytes is 256MB. 50000 100000 is the CPU quota: 50ms of runtime per 100ms period, which is the half a core we asked for. Those two files are the cgroup, and everything --memory and --cpus do is write to them.

Here is the part that catches people out:

$ docker run --rm --memory 256m ubuntu:22.04 free -h
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi        1.4Gi       196Mi      1.0Mi       6.2Gi        6.0Gi

$ docker run --rm --memory 256m ubuntu:22.04 nproc
2

The container is limited to 256MB, and free reports 7.8GB, because free reads /proc/meminfo and /proc is not namespaced for this. The process sees the host’s memory and the host’s CPU count right up until it is killed for exceeding a limit it could not see.

For us that cuts both ways. Tools that size themselves from nproc will misbehave in a constrained container. And when you land in a container on an engagement, /proc/meminfo and /proc/cpuinfo are telling you about the host, which is information the person who built that container did not mean to give you.

The network, from both sides

From inside, a container looks like a small machine on a private LAN:

$ docker run --rm builder sh -c 'ip -br a; ip route'
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0@if180       UP             172.17.0.3/16
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.3

By default a container starts with one interface. Here we have an address on 172.17.0.0/16, and a default route to 172.17.0.1. That gateway is your host. Every container you start can reach whatever your machine is listening on, which is worth remembering before you run something you do not trust.

Name resolution is Docker’s too:

$ docker exec probe cat /etc/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.

nameserver 192.168.6.11
search .

# Based on host file: '/run/systemd/resolve/resolv.conf' (legacy)
# Overrides: []

The daemon writes that file into each container at start, based on the host’s own resolver config. It is a copy, not a link, so a container started before you changed networks keeps pointing at the old nameserver.

From outside, the same facts come from docker rather than from a shell:

$ docker inspect probe --format 'name={{.Name}} ip={{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} pid={{.State.Pid}}'
name=/probe ip=172.17.0.2 pid=388733

$ docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
probe 172.17.0.2/16

docker inspect is the one to remember. It prints everything the daemon knows about a container: its address, its mounts, its environment variables, its capabilities. On a machine that is not yours, that is a very good first command.

Clean up

tail -f /dev/null never finishes, so probe is still running. Stop it:

$ docker kill probe
probe

That leaves a stopped container, which docker ps will not show you and docker ps -a will:

$ docker ps -a --filter name=probe
CONTAINER ID   IMAGE          COMMAND               CREATED         STATUS                                PORTS     NAMES
f4dacd21217a   ubuntu:22.04   "tail -f /dev/null"   2 seconds ago   Exited (137) Less than a second ago             probe

Exited (137) is the exit code, 128 plus signal 9, which is what docker kill sends. The writable layer is still on disk and the name is still taken, so starting another probe fails until this one is gone:

$ docker rm probe
probe

docker rm -f probe does both steps at once. That is the one to reach for when you do not care why it is running.

The daemon does the work

You met the socket in lesson 1. Now that you have seen namespaces and cgroups, the division of labour makes sense: docker on your command line is a thin client that sends HTTP requests over /var/run/docker.sock, and the daemon on the other end, running as root, does the namespace, cgroup and overlay work.

When it is not running, the client says so plainly:

$ docker ps
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

Every docker command in this course is a request to that daemon. Hold on to that idea, because the last lesson is about what happens when the wrong process can send those requests.

Four nouns

The rest of the tooling is a management interface over four kinds of object. Learn these four and the command surface stops being intimidating.

Images are the read-only templates, built in layers and identified by a repository, a tag, and a digest.

$ docker images
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:latest   28bd5fe8b56d         13MB         3.93MB
python:3.11     7bd2bbe21121       1.61GB          427MB
ubuntu:22.04    941f1899488c        228MB         61.3MB

DISK USAGE is what the image costs you unpacked, CONTENT SIZE is what came down the wire compressed. Alpine at 13MB and Python at 1.6GB in the same list is a fair summary of how much variation there is between base images.

Containers are running (or stopped) instances of an image, each with its own writable layer.

$ docker ps
CONTAINER ID   IMAGE          COMMAND               CREATED        STATUS        PORTS     NAMES
f4dacd21217a   ubuntu:22.04   "tail -f /dev/null"   1 second ago   Up 1 second             probe

docker ps shows running containers only. docker ps -a includes the dead ones, which pile up fast and are worth knowing about before your disk fills.

Networks are virtual switches. You get three out of the box and can make as many more as you like:

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
f5fb625502a3   bridge    bridge    local
2ec9652013e4   host      host      local
c3e7261750b3   none      null      local

bridge is the default and the one we looked at above. The other two, and the ones you build yourself, are lesson 6.

Volumes are storage that outlives a container. We will mostly use the simpler cousin, bind mounts, in lesson 4.

The flags you will type the most

Almost every command in this course is docker run with some combination of five flags:

Flag What it does
-i Keep stdin open, so you can type at it
-t Allocate a TTY, so the shell looks like a shell
--rm Delete the container when it exits
-v host:container Bind a directory from your machine into the container
-w /path Start in that directory inside the container

-it together is what you want for an interactive shell. --rm will keep your machine clean: without it, every experiment leaves a stopped container behind for you to cleanup manually.

Next we start using it, beginning with never installing a tool again.