What this check reads
It gathers three independent signals and concatenates them into one string. ss -tln supplies any listener whose local address ends in :2375 or :2376, keeping the first two matches; ps -eo args supplies the first -H tcp://... on the running dockerd command line; and /etc/docker/daemon.json supplies the first tcp:// entry inside its "hosts" array. An empty combined string means the daemon is on the local unix socket only and the check PASSes. If it is not empty the port number alone decides severity: 2376 appearing anywhere in it is a WARN, on the assumption that TLS is in use, and anything else is a FAIL.
When it applies
Runs whenever the docker binary exists - it does not need daemon access, which is why it works unprivileged. It combines three independent signals: TCP listeners on 2375/2376 (via ss, if installed), -H tcp://… on the running dockerd command line, and a tcp:// entry in the "hosts" array of /etc/docker/daemon.json (readable without root on a default install). Because any one of them triggers the finding, a daemon configured but not currently listening is still reported. The port number alone decides severity: 2376 is assumed to mean TLS and produces WARN, anything else FAIL - the check never verifies that --tlsverify and client certificates are actually enforced.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | Docker not installed | Nothing was checked. With no docker binary present, none of the three signals was collected. |
| WARN | TCP on 2376 (verify `--tlsverify`) | The daemon is on TCP, and the port number is the only reason this is not a failure. Nothing here confirmed that tlsverify and client certificates are enforced, so confirm it yourself. |
| FAIL | TCP on 2375 or any non-2376 TCP host | The Docker API is reachable over TCP on a port that implies no TLS. Anyone who can route to it is root on this host. |
| PASS | Unix socket only | No TCP listener, no -H tcp:// flag and no tcp:// host entry was found, so access runs through the local unix socket and its file permissions. |
Why it matters
The Docker API is root-equivalent: anyone who can reach it can run docker run -v /:/host --privileged and own the machine. Unauthenticated TCP (2375) exposed to the internet is mass-scanned and exploited within minutes for cryptominers. Docker docs: "Protect the Docker daemon socket"; CIS Docker 2.7 "Ensure TLS authentication for Docker daemon is configured"; OWASP Docker Cheat Sheet Rule #1 "Do not expose the Docker daemon socket (even to the containers)".
Why it fails, and when it is wrong
- Port 2376 with
--tlsbut without--tlsverifyencrypts but does not authenticate clients; the check cannot tell and reports WARN. Checkps -o args -C dockerdfor--tlsverifyand--tlscacert. - TCP bound to
127.0.0.1:2375(some CI tools) still FAILs; it is reachable by any local user and via SSRF, and the daemon's local-only bind is a weak control. Prefer the Unix socket. ssreports the port regardless of which process owns it; a different service on 2375/2376 would be a false positive. Verify withss -tlnp.- systemd socket activation (
docker.socketwithListenStream=0.0.0.0:2375) is detected viassbut not via the daemon.json grep. - The severity test is whether
2376appears anywhere in the combined string, so a daemon listening on both 2375 and 2376 reports WARN, not FAIL. Read the listener list in the message and treat any 2375 in it as the FAIL it is. - The
daemon.jsonsignal only matches a"hosts"array written on one line; a pretty-printed array spread over several lines is not read. The command-line signal matches-H tcp://but not the long form--host=tcp://. Thesssignal still catches a listener on 2375 or 2376 in both cases, but a non-standard port configured that way is missed entirely.
How to fix it
Find where TCP is configured
Check all three places the audit reads before you change anything. A systemd drop-in is the usual culprit, not daemon.json.
systemctl cat docker docker.socket | grep -nE 'ExecStart|ListenStream'
sudo grep -n -A3 '"hosts"' /etc/docker/daemon.json
sudo ss -tlnp | grep -E ':(2375|2376)\b'Remove the TCP listener
Delete the tcp:// entry from the hosts array in daemon.json, or the -H tcp:// flag from the drop-in, then reload and restart. Never set hosts in both places: the daemon refuses to start when it finds them in both.
sudo systemctl edit docker # remove -H tcp://... from the ExecStart override
sudo systemctl daemon-reload
sudo systemctl restart dockerManage the host over SSH instead
A Docker context over SSH reaches the daemon through its unix socket on the far side, with no port open at all.
docker context create remote --docker host=ssh://user@host
docker --context remote psIf TCP is unavoidable, require mutual TLS
Run the daemon on 2376 with client certificate verification, so only clients holding a certificate signed by your CA can connect, and firewall the port to the addresses that need it.
dockerd --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem --tlskey=server-key.pem -H=0.0.0.0:2376
sudo ufw allow from 203.0.113.10 to any port 2376 proto tcp--tls without --tlsverify encrypts the connection but accepts any client, so it does not count.
Verify the fix
# No daemon socket should be listening on TCP at all:
ss -tlnp | grep -E ':(2375|2376)\b' # expected: no output
ps -eo args | grep '[d]ockerd' # expected: no -H tcp://
sudo grep -n hosts /etc/docker/daemon.json # expected: no tcp:// entry
# If TCP is deliberate, prove mutual TLS is actually enforced:
curl -s http://<host>:2376/version # expected: fails (TLS required)
curl -s https://<host>:2376/version --cacert ca.pem # expected: fails without a client cert
curl -s https://<host>:2376/version --cacert ca.pem --cert cert.pem --key key.pem # expected: worksDebugging
Check all three sources above; a systemd drop-in is the usual culprit (systemctl cat docker | grep ExecStart), not daemon.json. Note that specifying hosts in both daemon.json and the unit makes the daemon refuse to start, which is why the drop-in is often the real source.
The check assumes TLS from the port number only. Confirm with the curl sequence above: if plain HTTP or a certificate-less client works, treat this as a FAIL, not a warning. Real enforcement needs --tlsverify, --tlscacert, --tlscert, --tlskey.
The check would miss a daemon on a non-standard TCP port that is set somewhere other than the three sources, and it cannot see a socket-forwarding proxy (a container publishing the socket over TCP, socat, or an exposed docker.sock behind a web server). Look for those with sudo ss -tlnp and the Docker Socket In Containers check.
That signal is silently unavailable; the command-line and daemon.json checks still run. Install iproute2 for full coverage.
Do not expose TCP. Use docker context over SSH (docker context create remote --docker host=ssh://user@host), which needs no open port at all.
Sources
- Docker: Protect the Docker daemon socket
- Docker: Docker daemon attack surface
- Docker: dockerd
--host/hosts - Docker: Use SSH to protect the daemon socket
- OWASP Docker Security Cheat Sheet (Rule #1)
- Trend Micro: exposed Docker APIs abused for cryptojacking (example campaign)
How the script reads this
Next
Re-run the Daemon & Socket audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 8 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 8 Daemon & Socket fixes