CoursesAdvanced container securityDaemon & socket hardening

Daemon & socket hardening

Protect docker.sock; lock down daemon.json.

Advanced12 min · lesson 16 of 25

Shodan, the search engine that indexes machines exposed to the internet, currently lists tens of thousands of Docker daemons answering on TCP port 2375 with nothing in front of them. No password. No certificate. Each one is a root shell for whoever sends the right HTTP (HyperText Transfer Protocol) request. That is the uncomfortable fact behind daemon hardening. The Docker API (application programming interface, the set of requests the daemon will accept) has no built-in authentication, the daemon itself runs as root, and every request it accepts runs with the daemon's privilege. Reach the API and you are root on the host. The API listens in two places: the Unix socket at /var/run/docker.sock, which exists on every Docker host, and a TCP port if somebody switches one on. People remember to firewall the port. They forget the socket.

terminal
# The 'dev' user is unprivileged. Their only power is membership in the docker group.
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),998(docker)
$ docker run -v /:/host -it alpine chroot /host sh
sh-5.2# id # inside the chroot we are root on the HOST filesystem
uid=0(root) gid=0(root) groups=0(root)
sh-5.2# head -1 /etc/shadow
root:$6$Xy9k...:19710:0:99999:7:::
sh-5.2# echo 'dev ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers # host-root persistence

The socket behaves like a keyhole in a door that has no lock. Docker never asks who you are. It checks only whether your hand fits through. Three things make it fit: sitting in the docker group (the socket file is owned by that group and writable by it), having the socket bind-mounted into a container with -v /var/run/docker.sock, or an open TCP listener. So treat docker-group membership as a passwordless root account rather than a developer convenience, and hand it out with the care you would give the root password. If you genuinely have to reach the daemon across a network, expose only the TLS (Transport Layer Security, the encryption behind https) port 2376 with mutual TLS (mTLS, where the client and the server each present a certificate proving who they are), and firewall it down to named hosts. Better still, do not expose it at all. Reach it over SSH (Secure Shell) or a Docker remote context instead.

Find every hand already on the socket

You cannot harden what you cannot see, so start by mapping who can reach the API right now. Two questions cover most of it. Which running containers have been handed the socket? And which users sit in the docker group? Each answer is one command, and both belong in whatever regular audit you already run.

terminal
# Which running containers have the socket bind-mounted in?
$ docker ps -q | xargs docker inspect \
--format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock
/ci-runner /var/run/docker.sock # a CI runner holding full host root: investigate
# Who is effectively root through the group?
$ getent group docker
docker:x:998:dev,jenkins
# Log every open of the socket at the kernel level
$ sudo auditctl -w /var/run/docker.sock -p rwxa -k docker-sock
$ sudo ausearch -k docker-sock -i | tail -1
type=PROCTITLE proctitle=curl --unix-socket /var/run/docker.sock http://./containers/json
The :ro flag does not make a mounted socket read-only
Plenty of compose files bind-mount the socket as -v /var/run/docker.sock:/var/run/docker.sock:ro and call the job done. It isn't. The :ro flag marks the socket's inode (the filesystem's record of that file) read-only, but talking to a socket is not a filesystem write. Read-only is a sticker on a telephone saying do not edit this phone. It says nothing about lifting the handset and placing a call, and a call is exactly what an API request is. You connect, you send requests, and :ro never touches that path. A container holding the socket :ro can still POST /containers/create and launch a privileged container that mounts the host's disk. If a tool truly needs the API, do not give it the real socket at all. Put a filtering proxy in front.

Set safe defaults in daemon.json

/etc/docker/daemon.json is the daemon's own config file, the house rules that every container inherits. Get it right and a rushed docker run with no security flags still lands somewhere sane. Three switches carry most of the weight. no-new-privileges: true is a ratchet that only turns one way. It sets the kernel's no_new_privs bit on every container, so once a process has started it can never end up with more privilege than it began with. Setuid binaries (programs that run as their owner rather than as the person who launched them) stop paying out, and su and sudo die as escalation paths. userns-remap: default is witness protection for the container's root account. It turns on user-namespace remapping (userns, the feature that hands each container its own private range of user ID numbers) and maps container UID 0 (user ID zero, root inside the container) onto an unprivileged host UID such as 100000. Inside the box you look like root. To the host you are user 100000, a stranger whose badge opens nothing. live-restore: true keeps containers running while the daemon restarts. That sounds like an uptime feature. It is really a security one, because it removes the last excuse anybody has for not restarting dockerd to apply a patch. Add icc: false to stop containers on the default bridge network chatting to each other, and cap the log files so one noisy container cannot fill the disk.

/etc/docker/daemon.json
{
"no-new-privileges": true,
"userns-remap": "default",
"live-restore": true,
"icc": false,
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}

Restart the daemon, then prove the settings actually took hold. A config file is a claim. The running daemon is the evidence.

terminal
$ sudo systemctl restart docker # live-restore keeps running containers up
$ docker info --format '{{.SecurityOptions}}'
[name=apparmor name=seccomp,profile=builtin name=cgroupns name=userns]
$ docker run --rm alpine grep NoNewPrivs /proc/self/status
NoNewPrivs: 1 # set for every container; docker info won't show it
$ docker run --rm alpine cat /proc/self/uid_map
0 100000 65536 # container root (0) maps to host UID 100000
$ docker inspect --format '{{.State.Running}} since {{.State.StartedAt}}' web
true since 2026-07-16T08:31:07Z # survived the restart: live-restore held it up

Settings drift. Someone loosens one during a 3am incident and never puts it back. docker-bench-security runs the CIS (Center for Internet Security) Docker Benchmark, a published checklist of host and daemon settings, against your machine and flags anything that has slipped.

terminal
$ docker run --rm --net host --pid host --userns host --cap-add audit_control \
-v /var/run/docker.sock:/var/run/docker.sock:ro -v /etc:/etc:ro \
docker/docker-bench-security
[INFO] 2 - Docker daemon configuration
[PASS] 2.2 - Ensure network traffic is restricted between containers on the default bridge
[PASS] 2.9 - Enable user namespace support
[WARN] 2.12 - Ensure that authorization for Docker client commands is enabled
[PASS] 2.15 - Ensure live restore is enabled

Some containers really do need the API. A CI (continuous integration, the system that builds and tests your code on every commit) runner builds images. Traefik reads container labels so it knows where to route traffic. Watchtower polls for newer image versions. Give each of them a filtered view instead of the raw socket. A proxy such as tecnativa/docker-socket-proxy sits in front of the daemon and forwards only the endpoints you allow. Let Traefik read GET /containers and nothing else, and a compromised Traefik can list your containers but cannot create one that mounts the host. A receptionist who takes messages is a very different thing from a key to the whole building.

How a workload reaches the Docker API, ranked by risk
A workload needs to talk to the Docker API
every path below grants some level of host root
raw socket mount
-v /var/run/docker.sock
full host root; :ro does not help. Avoid.
docker group
user added to group docker
passwordless root. Audit membership.
TCP 2375
unauthenticated port
never; scanned and mined within hours
TCP 2376 + mTLS
client cert required
ok if firewalled to named hosts
socket proxy
filtered, read-only API
best for Traefik, CI, Watchtower
rootless / SSH
no root daemon exposed
the socket is no longer a path to host root

Anyone who can talk to the Docker API can usually become root on the host: mount /, chroot into it, done. Membership in the docker group is the same power under a friendlier name. An unauthenticated TCP 2375 is a public root shell, and scanners find those within hours of them appearing.

Harden daemon.json with the same instinct. Turn off experimental features you do not need, be fussy about which authorization plugins you load, prefer a TLS-authenticated TCP port if a remote API is genuinely mandatory, and keep the file permissions on the Unix socket tight.

CI runners that hand docker.sock to build jobs inherit this entire problem, and build jobs run code you did not write. A carefully configured Docker-in-Docker (DinD, a build container that runs its own daemon), rootless mode, or a remote builder behind strong authentication all beat mounting the raw socket into an untrusted build.

In production this becomes a habit rather than a one-off. After every change window, rerun the same checks you ran when you first switched the controls on, paste the command and its output into the ticket, and refuse to close the change if the reading moved. A control nobody re-reads is a control nobody has.

Pick the tightest scope that still lets the workload do its job. One extra endpoint allowed on a socket proxy. One extra name in the docker group. One host where somebody opened the port for an afternoon of debugging and never closed it. Each looks small on its own, and they compound across every host and every pipeline until the tightest thing you have is the audit report.

Prevention slips, so leave a tripwire behind it. The auditd rule above (auditd is the Linux kernel's audit subsystem, which records who touched what) turns every open of the socket into a log line with the process name attached. Docker's own client shows up there constantly, and that is fine. What you alert on is the surprise: curl, python, a shell script, or any process name that has no business holding the daemon's phone.

Keep a written list of everything that legitimately talks to the daemon, with an owner's name and a reason next to each entry. Socket access tends to arrive as a favour for one urgent build and then live forever, because nobody remembers why it was granted or who would notice if it went away. A list with owners on it turns that guesswork into a two-minute review.

Turn userns-remap on in staging before you turn it on anywhere that matters. Remapping shifts the user IDs the host sees, so bind-mounted files can suddenly look owned by the wrong account, storage drivers that do not understand the mapping misbehave, and containers that share the host's process or network namespaces stop working under it. All of that is fixable. None of it is fun to meet for the first time at go-live.

When a colleague asks for the docker group, they are usually asking for something smaller: the ability to build an image, or restart their own service, without pestering you. Say yes to that. Rootless Docker gives them a daemon of their own with no host root behind it. A narrow sudo rule, a self-service pipeline, or a filtered socket proxy each cover the common cases too. Giving someone the whole daemon because the request was phrased that way is how these lists grow.

If you ever find a daemon that was listening on plain TCP, or a socket mounted into a container running an image you cannot vouch for, treat the host as compromised rather than merely misconfigured. Exposed daemons get found by automated crypto-mining crews first, and their usual souvenirs are a new line in /etc/sudoers, an extra SSH key, and a cron job. Rebuild the host, rotate every credential that lived on it, and only then close the ticket.

None of this stops at Docker. Kubernetes nodes run containerd or CRI-O, and those daemons have sockets of their own with exactly the same property: whoever can send them requests can start a container with whatever privilege they like. A pod that mounts the node's runtime socket owns the node. The name on the socket changes. The reasoning does not.

Try this

Prove the point to yourself on a lab machine you are happy to destroy. Log in as an unprivileged account that belongs to the docker group, start a container that mounts the host's root filesystem, and write a file that account has no business writing. Then look at the permissions on the socket and work out, in your own words, why the group is root-equivalent. Lab only.

terminal
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),998(docker)
$ docker run --rm -v /:/host alpine sh -c 'echo pwned-from-container > /host/tmp/docker-sock-proof; cat /host/tmp/docker-sock-proof'
pwned-from-container
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker ... /var/run/docker.sock
$ # cleanup on lab: rm /tmp/docker-sock-proof

Takeaway

Treat docker.sock and the docker group as root accounts, because that is what they are. Keep the API off plain TCP, keep the socket out of containers you do not trust, and keep daemon.json down to the features your platform actually uses.

Quick check
01A teammate mounts the Docker socket into a monitoring container with -v /var/run/docker.sock:/var/run/docker.sock:ro and tells you the :ro makes it safe to run an untrusted image in there. Where does that reasoning break?
Incorrect — The :ro flag only marks the socket's inode read-only. An API call is not a filesystem write, so the container can still POST a request that creates a privileged container.
Correct — Talking to the socket is not a file write, so :ro changes nothing about API access and the container keeps effective host root. Hand tools a filtering socket proxy instead.
Incorrect — Docker does not quietly flip the flag. The point is that read-only versus read-write on the inode has no bearing on sending API requests over the socket.
Incorrect — Dropping the caller's capabilities changes nothing. The privilege comes from the daemon running the request as root, not from the container that asked.
02You set "no-new-privileges": true in /etc/docker/daemon.json. A container image still ships a setuid-root copy of sudo. What does the setting do to it?
Incorrect — No. The flag sets the kernel's no_new_privs bit on every container the daemon launches, not on the daemon process.
Correct — With no_new_privs set, a process can never end up with more privilege than it started with, so setuid binaries stop paying out.
Incorrect — No. That describes user-namespace remapping, which is a separate switch. no-new-privileges blocks the escalation outright.
Incorrect — No. Containers start normally. The setuid binaries stay on disk and confer no extra privilege.
03An audit runs getent group docker and gets back docker:x:998:dev,jenkins. A colleague asks you to add the new intern so they can run containers without typing sudo. What are you actually handing over?
Correct — Docker-group membership is root-equivalent and needs no password, so treat the request the way you would treat a request for the root account.
Incorrect — No. The daemon runs every request as root, so membership grants effective host root however isolated the container looks.
Incorrect — No. A group member can create a privileged container that mounts the host filesystem, which is a long way past reading logs.
Incorrect — No. The socket is group-writable, so no password comes into it. Membership on its own is enough.

Related