Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Remove direct access to the Docker daemon

By updated

Improve this page

An account in the docker group and a container that mounts docker.sock have the same power: they talk to the Docker daemon, and the daemon runs as root. This lesson teaches you to list these direct accesses, to understand why each one is worth root access on the host, then to remove them and check that the Docker path is really closed. That check has a limit: it does not tell you what an account was able to do while it was root through Docker, and the lesson says where it stops. The measurements come from a throwaway Debian 13 VM with Docker Engine 29.9.2: do not replay the removals on a shared host.

What you will learn

Each removal is checked against the real state, not against the announced configuration.

  • You take stock of the paths to the daemon: group, socket mode, ACL, TCP listener and sudo.
  • You show that belonging to the docker group is equivalent to being root.
  • You remove an account from the group, you close its processes that are still open and you remove an ACL that reopens access without the group.
  • You audit all containers, stopped ones included, that mount the socket, directly or through a parent directory (/run or /), and you prove it with the inode.
  • You see that a read-only mount does not prevent stopping a container.
  • You remove a mount by recreating the container without losing its configuration.
  • You know what the removal does not prove.

Prerequisites

The lesson Rotate Docker logs and reclaim disk space (C35b) comes before this one. You must know how to start a container with a -v mount, read an ls -l and use sudo. Unix file permissions are detailed in the page Fine-tune permissions with ACLs, which also explains getfacl and setfacl.

The demonstration uses the account stagiaire-ops and the containers c36-tableau, c36-racine, c36-renomme, c36-arrete and c36-sain. The two labs at the end of the lesson work on other names. All the containers start from the same image, pinned to its digest:

Terminal window
IMG='alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a'
docker version --format 'client={{.Client.Version}} serveur={{.Server.Version}}'
client=29.9.2 serveur=29.9.2

Who can talk to the daemon, and how do you find out?

By default, the Docker daemon listens on a Unix socket, a special file /run/docker.sock: anyone who can open it can send orders to the daemon. The inventory therefore consists of listing who can open this file, or reach the daemon some other way.

Terminal window
ls -l /run/docker.sock
readlink -f /var/run/docker.sock
getent group docker
srw-rw---- 1 root docker 0 Nov 4 18:46 /run/docker.sock
/run/docker.sock
docker:x:989:ansible

The mode srw-rw---- gives read and write to root and to the docker group, and nothing to anyone else. getent group docker lists the members of that group, here ansible, the VM's operations account.

readlink -f then shows that /var/run/docker.sock, the path the tools use, points to the same file as /run/docker.sock, because /var/run is a link to /run. This resolution will matter for the mounts.

A + at the end of the mode, as in srw-rw----+, signals an ACL, a list of extra permissions attached to the file, which ls -l does not detail. To read it you need getfacl, which is missing from the VM's minimal Debian 13: the acl package provides it.

Terminal window
sudo apt-get install -y acl
getfacl -p /run/docker.sock

Without an ACL, getfacl -p only returns the three entries of the classic mode (owner, group, others). The apt-get output is not reproduced:

/run/docker.sock
# owner: root
# group: docker
user::rw-
group::rw-
other::---

The daemon can also listen on TCP, which would hand its API to the whole network that can reach it. You read this in the service's start line and in the listening ports:

Terminal window
systemctl cat docker | grep -E '^ExecStart='
sudo ss -lntp | grep -E 'dockerd' || echo "no TCP listener for dockerd"
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
no TCP listener for dockerd

-H fd:// means the daemon receives its socket from systemd, with no tcp:// option. Look for dockerd in ss -lntp, not for the word "docker": the docker-proxy process legitimately listens on TCP for published ports, and it is not the API. With this ExecStart, a hosts key in /etc/docker/daemon.json does not open a port: on the VM, the daemon refuses to start, with the message the following directives are specified both as a flag and in the configuration file: hosts. A TCP listener therefore comes from a -H tcp:// in the service or in a systemd override, which systemctl cat docker displays in full. Do both checks together, because ss sees the real listener, whatever its origin.

The last path to check is sudo. The command sudo -l -U <account> tells you whether an account can become root another way: with a broad sudo, the docker group is superfluous, since the account is already root. A sudo rule limited to /usr/bin/docker also gives root: the account can run sudo docker run -v /:/hote … and get the same mounted root as the group.

Why is belonging to the docker group the same as being root?

The daemon executes the orders of any client that opens its socket, with its own rights, those of root. A member of the group can therefore ask it to start a container that mounts the host's files, then read or write there as root.

To show it, create a working account without sudo and add it to the group:

Terminal window
sudo useradd -m -s /bin/bash stagiaire-ops
sudo usermod -aG docker stagiaire-ops
id stagiaire-ops
uid=1002(stagiaire-ops) gid=1002(stagiaire-ops) groups=1002(stagiaire-ops),989(docker)

With this account, without any sudo, start two containers. The first mounts the host's /etc read-only and reads the first line of shadow, the file of password hashes. The second mounts the host's whole root and runs id in that root:

Terminal window
sudo -n -u stagiaire-ops docker run --rm -v /etc:/hote-etc:ro "$IMG" head -1 /hote-etc/shadow
sudo -n -u stagiaire-ops docker run --rm -v /:/hote "$IMG" chroot /hote id
root:*:20760:0:99999:7:::
uid=0(root) gid=0(root) groups=0(root),1(daemon),2(bin),3(sys),4(adm),6(disk),10(uucp),11,20(dialout),26(tape),27(sudo)

The first output is the first line of the host's /etc/shadow. The second shows that chroot /hote, which takes the host's root as its root, opens a uid=0 session in the real system. The same account, without Docker, gets nothing:

Terminal window
sudo -n -u stagiaire-ops head -1 /etc/shadow
head: cannot open '/etc/shadow' for reading: Permission denied

The docker group is therefore not a right "to manage containers": it is root access on the host, and it must be granted as such.

How do you remove an account from the docker group, and what about open sessions?

Before removing the account, you need a session of that account that is already open, to see what becomes of it. Open a second terminal and run the command below in it. It prints the account's groups, waits 30 seconds, prints them again, then runs docker ps. As soon as it is running, go back to the first terminal without waiting: you have 30 seconds to run the removal there. If that is too short, replace sleep 30 with sleep 60.

Terminal window
# second terminal, run this first
sudo -n -u stagiaire-ops bash -c 'id -nG; sleep 30; echo "--- after the removal, in the same session:"; id -nG; docker ps >/dev/null; echo "docker ps rc=$?"'

In the first terminal, remove the account with gpasswd -d. Then check the group's list, the account's groups and a new docker ps command, whose return code you note:

Terminal window
# first terminal, right after starting the command in the second one
sudo gpasswd -d stagiaire-ops docker
getent group docker
id stagiaire-ops
sudo -n -u stagiaire-ops docker ps; echo "rc=$?"
Removing user stagiaire-ops from group docker
docker:x:989:ansible
uid=1002(stagiaire-ops) gid=1002(stagiaire-ops) groups=1002(stagiaire-ops)
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
rc=1

The group no longer lists the account, and neither does id. The new command is refused, with the message permission denied while trying to connect to the docker API, and its return code is 1. The removal seems done.

Yet look at the second terminal: a session opened before the removal keeps its access. After its 30 seconds, it prints:

stagiaire-ops docker
--- after the removal, in the same session:
stagiaire-ops docker
docker ps rc=0

The first two lines give the groups before and after the removal: the session still sees docker, and its docker ps succeeds (rc=0). A process's groups are fixed when the session opens, and the removal does not change them: this session stays usable until it is closed.

A removal is therefore only worth something once the open sessions are closed, otherwise the access removed from /etc/group stays open in practice. Check with a real call, docker ps run from a new login of the account, rather than with the line in /etc/group.

How do you close the account's remaining processes?

To close these sessions, first list the account's processes with pgrep -u. A process started by sudo -u, which here plays the role of a forgotten session, shows up in the list:

Terminal window
sudo -u stagiaire-ops bash -c 'sleep 600' &
pgrep -u stagiaire-ops -a
38840 sleep 600

loginctl terminate-user closes an account's login sessions (SSH, console), but it does not see this process:

Terminal window
sudo loginctl terminate-user stagiaire-ops; echo "rc=$?"
Could not terminate user: User ID 1002 is not logged in or lingering
rc=1

The message says loginctl knows no session for this account: the process exists, but it is not attached to any login. pkill -KILL -u ends all the account's processes, whatever their origin, and pgrep confirms that none is left:

Terminal window
sudo pkill -KILL -u stagiaire-ops; echo "pkill rc=$?"
pgrep -u stagiaire-ops -a || echo "no process left after pkill"
pkill rc=0
no process left after pkill

A terminal multiplexer, a cron job or a systemd --user service left behind by the account are also processes of that account, so they are covered by pgrep -u and pkill -u. These cases were not measured here: run pgrep again afterwards to check that they have not come back to life.

Which other paths reopen access without the group?

The group is only one way to open the socket. An ACL can give the same right to a specific account, without going through the group. The account is refused today; after setfacl, it gets through:

Terminal window
sudo setfacl -m u:stagiaire-ops:rw /run/docker.sock
getfacl -p /run/docker.sock
ls -l /run/docker.sock
sudo -n -u stagiaire-ops docker ps --format '{{.Names}}' >/dev/null; echo "rc=$?"
/run/docker.sock
# owner: root
# group: docker
user::rw-
user:stagiaire-ops:rw-
group::rw-
mask::rw-
other::---
srw-rw----+ 1 root docker 0 Nov 4 18:46 /run/docker.sock
rc=0

The line user:stagiaire-ops:rw- is the ACL, and the + in ls -l signals it: the account calls the daemon although it is not in the group. An inventory that only reads getent group docker misses it. Read ls -l and getfacl too.

To remove this ACL without restarting the daemon, delete the account's entry, then read it back with getfacl and call the daemon from the account:

Terminal window
sudo setfacl -x u:stagiaire-ops /run/docker.sock
getfacl -p /run/docker.sock
ls -l /run/docker.sock
sudo -n -u stagiaire-ops docker ps; echo "rc=$?"
/run/docker.sock
# owner: root
# group: docker
user::rw-
group::rw-
mask::rw-
other::---
srw-rw----+ 1 root docker 0 Nov 4 18:46 /run/docker.sock
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
rc=1

The line user:stagiaire-ops:rw- has disappeared from getfacl and the call is refused (rc=1). Note that ls -l still shows the +: it stays after a setfacl -x, because the mask remains. The + alone therefore does not let you conclude, getfacl is what settles it.

Restarting the socket (systemctl restart docker.socket docker) also erases the ACL, but it restarts the daemon: without live-restore, the containers are restarted. The lesson Upgrade Docker Engine and back up its configuration details this behavior; on a host in service, prefer setfacl -x.

This finding only holds for an ACL set by hand. An ACL set by a systemd unit or by a udev rule would be applied again each time the socket is created; this case was not measured, so look for it in the host's units and rules.

That leaves sudo. On the VM, sudo -l -U stagiaire-ops returns User stagiaire-ops is not allowed to run sudo on c45-debian. (c45-debian is the VM's name), so there is no path that way. An account allowed to do anything with sudo would be root without going through Docker.

Which containers mount the daemon's socket?

A container that mounts docker.sock gives its process the power of any account in the docker group. The diagram sums up the paths seen so far: the thick arrows are direct accesses to the socket, and the dotted arrows go through a directory that contains it, /run or the root /. All of them end at the same socket, so at the same root daemon.

Five paths lead to the daemon's socket: an account in the docker group, an account with an ACL, a container that mounts the socket, a container that mounts a parent directory containing the socket, and a container that mounts the host's root. The socket leads to dockerd, which runs as root and executes the orders on the host.contains the socketcontains the socketruns as rootdockergroupACLon the socketContainer,socket mountedContainer,parent directory mountedContainer,root / mountedDaemon socket/run/docker.sockdockerdruns as rootHostfiles, processes, accounts
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Five demonstration containers cover these forms, and one absence. The first mounts the socket by its path and a named volume, with the labels, the environment variable and the restart policy of a small dashboard. The next three mount /, /run under another name, and /run in a stopped container created with --restart always. The last one mounts nothing:

Terminal window
docker run -d --name c36-tableau --label equipe=supervision --restart unless-stopped -e MODE=lecture \
-v /var/run/docker.sock:/var/run/docker.sock:ro -v c36-cache:/cache "$IMG" sleep 3600 >/dev/null
docker run -d --name c36-racine -v /:/hote:ro "$IMG" sleep 3600 >/dev/null
docker run -d --name c36-renomme -v /run:/data:ro "$IMG" sleep 3600 >/dev/null
docker create --name c36-arrete --restart always -v /run:/r "$IMG" sleep 3600 >/dev/null
docker run -d --name c36-sain "$IMG" sleep 3600 >/dev/null

To audit them, a script reads each container's mounts with docker inspect, resolves each source with readlink -f, then compares it to the resolved socket. A source that is the socket, or a directory that contains it, exposes it. Comparing character strings would let /var/run/docker.sock through, which is /run/docker.sock under another name, and /run.

Terminal window
SOCK=$(readlink -f /var/run/docker.sock)
for c in $(docker ps -a --format '{{.Names}}'); do
docker inspect -f '{{range .Mounts}}{{.Source}} {{.Destination}}{{"\n"}}{{end}}' "$c" |
while read -r src dst; do
[ -z "$src" ] && continue
r=$(sudo readlink -f "$src")
if [ "$SOCK" = "$r" ] || [ "${SOCK#"${r%/}/"}" != "$SOCK" ]; then
echo "$c : $src -> $dst EXPOSE"
else
echo "$c : $src -> $dst"
fi
done
done
c36-arrete : /run -> /r EXPOSE
c36-renomme : /run -> /data EXPOSE
c36-racine : / -> /hote EXPOSE
c36-tableau : /var/lib/docker/volumes/c36-cache/_data -> /cache
c36-tableau : /var/run/docker.sock -> /var/run/docker.sock EXPOSE

Each line gives the mount's source and its destination; EXPOSE marks a source that is the socket or that contains it. Four containers are flagged, including c36-racine (the root contains /run/docker.sock) and c36-arrete, which is stopped but which --restart always will start again. The named volume c36-cache is not flagged, and c36-sain does not appear for lack of a mount. Mounting /run or / is therefore no safer than mounting the socket.

Two details of the script avoid easy traps. The first concerns the root. For r=/, a simple pattern like case "$SOCK" in "$r"/*) becomes //*, which matches no absolute path: a container that mounts / would go unnoticed. The script's test strips the source's trailing slash, ${r%/}, then looks for that prefix followed by / at the start of the socket; for the root, it strips the initial / from /run/docker.sock and flags it.

The second trap concerns readlink -f. Without sudo, the command fails on the paths under /var/lib/docker, which only root can traverse: the variable r stays empty, and a badly written test would then declare a simple named volume exposed. With sudo readlink -f, the volume c36-cache is resolved and correctly not flagged. Finally, the loop runs over docker ps -a, not docker ps, so as not to forget a stopped container.

This script trusts the containers' configuration. For proof seen from the inside, compare the inode, the number that identifies a file for the kernel, together with the device number. A path is what you wrote, the inode is what the kernel designates: two names that lead to the same pair are the same file. The next loop looks for all the sockets visible inside each running container, with no path written in advance, and keeps only those whose pair is that of the host's socket:

Terminal window
H=$(stat -c '%d:%i' "$SOCK"); echo "host: $H"
for c in $(docker ps --format '{{.Names}}'); do
docker exec "$c" find / -path /proc -prune -o -type s -print 2>/dev/null |
while read -r s; do
[ "$(docker exec "$c" stat -c '%d:%i' "$s")" = "$H" ] && echo "$c : $s"
done
done
host: 25:18226
c36-renomme : /data/docker.sock
c36-racine : /hote/run/docker.sock
c36-tableau : /run/docker.sock

The three exposed containers show the same pair 25:18226 as the host, under three different names: the name proves nothing, the pair does. c36-sain prints nothing. The filter nevertheless hides a serious fact: a container that mounts / or /run also sees dozens of the host's other sockets, those of containerd, dbus and systemd, and a user's SSH agent (/run/user/1001/openssh_agent). Removing access to docker.sock is therefore not enough to make these mounts harmless: it is the mount itself that must be removed.

Why does a read-only mount not protect anything?

The first two containers carry :ro, and that :ro protects nothing: it applies to the socket's file, not to the requests that travel through it. From c36-tableau, a stop POST aimed at c36-sain succeeds. The command first installs curl in the container from Alpine's repositories: the VM must therefore reach the Internet, otherwise it does not return 204.

Terminal window
docker exec c36-tableau sh -c 'apk add -q --no-cache curl >/dev/null 2>&1; curl -s -o /dev/null -w "%{http_code}\n" --unix-socket /var/run/docker.sock -X POST http://x/containers/c36-sain/stop'
docker ps -a --filter name=c36-sain --format '{{.Names}} {{.Status}}'
204
c36-sain Exited (137) Less than a second ago

The code 204 says the daemon carried out the stop, and c36-sain exited with code 137. If a tool really needs the API, for example a dashboard that lists the containers, the solution is neither the :ro nor the raw socket: it is a proxy that filters the requests. The lesson Put a proxy in front of the Docker API (C36b) teaches this filtering and its limits.

How do you remove a socket mount?

A mount is decided when the container is created: you cannot remove it from a running container. You must recreate the container without the socket mount. Recreating means deleting the old container and starting a new one: everything that is not repeated in the new command, labels, environment variables, restart policy, other mounts, is lost. So note the configuration before deleting:

Terminal window
docker inspect -f 'labels={{json .Config.Labels}} env={{json .Config.Env}} restart={{.HostConfig.RestartPolicy.Name}} mounts={{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}' c36-tableau
labels={"equipe":"supervision"} env=["MODE=lecture","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"] restart=unless-stopped mounts=/var/lib/docker/volumes/c36-cache/_data:/cache /var/run/docker.sock:/var/run/docker.sock

The container has one label, a MODE variable, the unless-stopped policy and two mounts: the volume c36-cache and the socket (the PATH variable comes from the image). Recreate it with the same options, except the socket, then read the configuration back and look for the socket:

Terminal window
docker rm -f c36-tableau >/dev/null
docker run -d --name c36-tableau --label equipe=supervision --restart unless-stopped -e MODE=lecture \
-v c36-cache:/cache "$IMG" sleep 3600 >/dev/null
docker inspect -f 'labels={{json .Config.Labels}} env={{json .Config.Env}} restart={{.HostConfig.RestartPolicy.Name}} mounts={{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}' c36-tableau
docker exec c36-tableau ls -l /var/run/docker.sock; echo "rc=$?"
labels={"equipe":"supervision"} env=["MODE=lecture","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"] restart=unless-stopped mounts=/var/lib/docker/volumes/c36-cache/_data:/cache
ls: /var/run/docker.sock: No such file or directory
rc=1

The labels, the variable and the restart policy are identical, the named volume survived (it lives outside the container) and only the socket mount has gone. The error message says the recreated container no longer sees the socket: that is more reliable proof than rereading the launch command. The recreation does interrupt the service for a few seconds, since it is a new container: warn the users beforehand.

When the container is described in a Compose file, prefer that: delete the line of volumes that mounts the socket, then run docker compose up -d. Compose detects the change, keeps the rest of the configuration from the file and prints Recreated for the container; ls -l /var/run/docker.sock then returns No such file or directory there, as above.

Now clean up the demonstration and the working account:

Terminal window
docker rm -f c36-tableau c36-racine c36-renomme c36-arrete c36-sain >/dev/null
docker volume rm c36-cache >/dev/null
sudo userdel -r stagiaire-ops
docker ps -a --filter name=c36- --format '{{.Names}}'; id stagiaire-ops

userdel reports that the account had no mail spool, which is harmless. id confirms the account no longer exists, and the container list stays empty:

userdel: stagiaire-ops mail spool (/var/mail/stagiaire-ops) not found
id: ‘stagiaire-ops’: no such user

What does a refused docker ps prove, and what is left to check?

A refused docker ps proves one thing only: the account's Docker path is closed. It does not prove the account has lost all access to the host. An account that was root through Docker may have left a container started with --restart that mounts /, an SSH key in /root/.ssh/authorized_keys or a setuid binary: none of these accesses goes through the socket any more, and removing the group changes nothing about them.

What belongs to this lesson is the audit of the containers: docker ps -a, those created during the period when the account had access (docker inspect -f '{{.Created}}' <name> returns a date such as 2026-11-04T18:47:34.250972599Z, in UTC) and the mounts of /, of /run or of the socket, using the audit script of the section "Which containers mount the daemon's socket?". Looking for an added SSH key or a setuid binary belongs to an incident review, outside this lesson.

Put it into practice

The first lab asks you to remove the Docker access of a temporary audit account, without deleting it and without touching the operator's access. Two checks measure the real state of the host: the account no longer belongs to the docker group, and a real call to the daemon from this account fails. Keeping the account and the operator's access are prohibitions of the scenario, checked before the measurements: they earn no points.

The second lab starts from an application container that mounts /var/run/docker.sock read-only. You first see that a stop POST goes through, which is not graded. You then remove the ability to reach the socket by any path, leaving the container running and its label in place. Two checks measure it. The first resolves each mount source with readlink -f and rejects the one that is the socket or that contains it. The second reads, from inside the container, the device-inode pair of each visible socket and rejects the daemon's.

Key points

Direct access to the daemon is root access on the host, and the inventory must look for all its forms.

  • You take stock of the docker group, the socket mode, the + of an ACL in ls -l with getfacl, a TCP listener of dockerd and the sudo rights.
  • You know that a member of the docker group is root on the host: they read /etc/shadow and get uid=0 through chroot into a mounted root.
  • You remove an account with gpasswd -d, then you close its open sessions: they keep their access.
  • You check the removal with a real call, not with /etc/group, because an ACL set by hand reopens access without the group; setfacl -x removes it and getfacl settles it, not the + of ls -l.
  • You close the account's processes with pgrep -u and pkill -KILL -u.
  • You resolve each mount source with sudo readlink -f and compare it with the socket or its parent directories, /run and / included, on all containers, stopped ones included.
  • You prove the exposure with the inode, seen from the container, and the removal by the absence of the socket in the container recreated with the same configuration.
  • You know that a refused docker ps only closes the Docker path: the container audit is part of it, the rest belongs to an incident review.
  • You know that :ro does not prevent a POST (204): a tool that needs the API goes through a proxy.

Knowledge check

Check that the essentials are in place: what the docker group gives, what a removal leaves open and how to prove a socket mount.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn