Host namespace sharing as attack surface
--pid, --ipc, --net, --uts=host and what they expose.
The install docs for a monitoring agent tell you to run it with --pid=host --net=host. That is the quickest way to make the agent see everything on the box, so the flags sit in the docs and everybody copies them. The command works. Nobody looks at it again. Months later an attacker gets code running inside that agent and finds they are standing on the host: every process listed, every root-owned secret readable, the host's whole network within reach. The container was non-root. Its filesystem was read-only. None of it helped. Those two flags had given the host away before any of the hardening got a vote.
What you hand over when you share a namespace
A namespace is the kernel giving a process its own private room. Same building, same plumbing, but the glass is frosted, so the process only sees its own furniture. Linux keeps a separate room for each kind of resource: one for process IDs (PID, the number the kernel hands every running program), one for the network stack, one for shared memory (IPC, short for inter-process communication), one for the machine name (UTS, an old acronym for Unix time-sharing system), and a few more besides. A normal container gets a fresh set of rooms when it starts, so its /proc lists only its own processes and its network shows only its own interfaces. Each =host flag you add swaps one of those private rooms for the host's room. The frosted glass comes down for that resource. The kernel labels every room with an inode number, the same kind of ID a filesystem gives a file, and that number turns out to be the cleanest way to catch sharing later.
--pid=host puts the whole host process tree on screen
Share the PID namespace and the container's /proc stops being its own. It becomes the host's. Run ps inside and you get systemd, sshd, dockerd, the database, every daemon on the machine, each with its PID and its full command line. That is already a leak, because anything passed as an argument (tokens, connection strings, a password baked into a startup script) is now on screen. Then there is /proc/<pid>/environ, the file holding a process's environment variables. Plenty of services still keep API keys and database passwords there. A container running as root can read that file for any host process that also runs as root, which covers most system daemons. Add CAP_SYS_PTRACE on top (capabilities are the roughly 40 slices Linux chops root's power into, and this slice grants debugger-level access to other processes) and the container can attach to a host process and inject code into it, from inside the container.
# --pid=host: the container's ps lists HOST processes, not just its own$ docker run --rm --pid=host alpine ps | head -6PID USER TIME COMMAND1 root 0:03 /sbin/init611 root 0:18 /usr/bin/dockerd927 114 0:04 postgres: checkpointer # host UID, no name in the container's passwd1120 root 0:00 sshd: /usr/sbin/sshd -D2044 root 0:00 ps# a normal container sees only itself: PID 1 is its own command$ docker run --rm alpine psPID USER TIME COMMAND1 root 0:00 ps
# read the environment of host processes through the shared /proc (secrets live here)$ docker run --rm --pid=host alpine sh -c \'for f in /proc/[0-9]*/environ; do tr "\0" "\n" < "$f" 2>/dev/null; done | grep -iE "token|password|secret"'AWS_SECRET_ACCESS_KEY=wJalrXUtn... # from a root-owned host daemonDB_PASSWORD=S3cr3t! # neither of these belongs to the container
--net=host takes the network wall down entirely
Share the network namespace and the container stops having a network of its own. No veth pair (the virtual cable that normally links a container to Docker's bridge), no bridge, no address translation. It plugs straight into the host's stack, sitting on the same interfaces the host sits on and reaching everything the host can reach. That includes the services a host binds to 127.0.0.1 precisely because loopback is supposed to stay private: admin ports, a local Redis, an unauthenticated metrics endpoint. On a cloud instance it also includes the metadata service at 169.254.169.254, which will hand the node's credentials to anything that asks. Published-port mapping stops meaning anything as well. And with CAP_NET_RAW (the capability that allows raw packet capture) the container can sniff traffic on the host's interfaces.
# --net=host: the container sees the HOST's real interfaces, not an isolated veth$ docker run --rm --net=host nicolaka/netshoot ip -br addrlo UNKNOWN 127.0.0.1/8 ::1/128eth0 UP 10.0.1.20/24docker0 UP 172.17.0.1/16# and it reaches a service the host bound to loopback, meant to be host-only$ docker run --rm --net=host alpine wget -qO- http://127.0.0.1:9090/-/healthyPrometheus Server is Healthy.
--ipc=host and --uts=host look harmless. They are not
--ipc=host shares System V shared memory and /dev/shm with the host, so the container can read and modify the memory segments other processes are working in. A database keeping its shared buffers there becomes readable, and corruptible, by anything running in that container. --uts=host shares the hostname and domain name. The direct damage is small, though a container holding CAP_SYS_ADMIN (the capability covering most system administration operations) could rename the host, and the shared name tells an attacker exactly which machine they landed on. Watch for --cgroupns=host too, which exposes the host's control-group layout (a cgroup, or control group, is the kernel's tree for accounting and capping CPU and memory) and shows up in several of the classic escape write-ups. The pattern is the same every time. Each share you grant is one wall the kernel stops enforcing.
Detecting it: the inode number never lies
Config files go stale, wrapper scripts bury flags, and the person who wrote the run command left two jobs ago. Ask the kernel instead. Every namespace carries an inode number, and readlink /proc/self/ns/<type> prints it. Run it on the host, run it in the container, compare the two. Same number means they share that namespace, no argument. When you have a whole fleet and a shell on none of it, docker inspect reads the same fact out of the daemon's own config, so you can sweep every running container in one go.
# ground truth: compare the PID-namespace inode. Same number = shared with host.$ readlink /proc/self/ns/pid # on the hostpid:[4026531836]$ docker run --rm --pid=host alpine readlink /proc/self/ns/pidpid:[4026531836] # identical -> SHARED$ docker run --rm alpine readlink /proc/self/ns/pidpid:[4026532248] # different -> isolated# audit every running container from the daemon config in one sweep$ docker ps -q | xargs docker inspect \-f '{{.Name}} pid={{.HostConfig.PidMode}} net={{.HostConfig.NetworkMode}} ipc={{.HostConfig.IpcMode}} uts={{.HostConfig.UTSMode}}'/metrics-agent pid=host net=host ipc=private uts= # pid + net shared -> review now/web pid= net=appnet ipc=private uts= # nothing host-shared -> good
The fix: deny by default, scope the rare exception
Treat these flags the way you treat --privileged. Off by default. Granted only when a tool has proved it needs one specific namespace and cannot do the job any other way. Never on a workload that takes untrusted input or answers to the internet. When you do grant one, grant that one alone and keep the container small, trusted and boring: drop capabilities, mount the root filesystem read-only, block privilege escalation. Never stack host-namespace sharing on top of --privileged or a mounted Docker socket, because either of those turns a visibility problem into a one-step host takeover. Kubernetes calls the same three controls hostPID, hostNetwork and hostIPC, and both the Baseline and Restricted Pod Security levels reject them outright, so let admission control do the arguing for you.
# a host-process metrics agent that genuinely needs PID visibility, scoped tightly$ docker run -d --name node-agent \--pid=host \ # the ONE share it proved it needs-p 127.0.0.1:9100:9100 \ # published on loopback, NOT --net=host--cap-drop ALL \ # no caps, so no ptrace / no code injection--read-only \--security-opt no-new-privileges=true \prom/node-exporter:latestnode-agent# verify it got only what you granted, nothing more$ docker inspect -f 'pid={{.HostConfig.PidMode}} net={{.HostConfig.NetworkMode}} caps_dropped={{.HostConfig.CapDrop}}' node-agentpid=host net=default caps_dropped=[ALL]
Sharing the host's PID, network, IPC or UTS namespace dissolves isolation on purpose. --pid=host lets you watch and signal host processes. --net=host skips the network namespace altogether. Put the two together and you have a fine debugging tool and a production hazard wearing the same clothes.
If a workload genuinely needs host namespaces, run it as a separate operations pod under tight admission rules, not as the default shape of your app deployment.
An attacker who lands in a host-net container inherits the host's ports and, very often, a route to the metadata service and to the admin listeners you filed away as "local only".
Fold the inode check into your change process. After any change window that touched container runtime settings, run the readlink comparison again, paste the command and its output into the ticket, and refuse to close the change if the number moved.
Put the docker inspect sweep on a schedule too. A weekly job that lists every container with a non-empty PidMode or a NetworkMode of host costs nothing to run, and it catches the agent somebody redeployed last Tuesday using the vendor's copy-paste command.
Keep a short written list of the exceptions you have granted: which container, which namespace, who asked for it, and what breaks without it. An exception with a name attached gets reviewed. An undocumented one quietly becomes permanent.
Catch it earlier than production as well. A pipeline step that scans your compose files, Helm values and run scripts for pid=host, network_mode: host, hostPID and hostNetwork stops most of these before they ever reach a machine.
During an incident, the first question about any container that shared a host namespace is what else it could see. Assume the host process list, every command line on the box and everything bound to loopback were all readable, and scope your credential rotation from there.
Prefer the tightest scope that still lets the workload run. It is a small habit, and it compounds across every host you own and every pipeline that deploys to them.
Try this
Run the same ps twice, once in a plain container and once with --pid=host, and watch the wall disappear. Then do the network version and look at which interfaces show up.
$ docker run --rm alpine ps aux | head -5PID USER TIME COMMAND1 root 0:00 ps aux$ docker run --rm --pid=host alpine ps aux | head -8PID USER TIME COMMAND1 root 0:02 /sbin/init...# host processes visible — that is the trade you are making$ docker run --rm --net=host alpine ip -br a | head -5lo UNKNOWN ...eth0 UP ...
Takeaway
Host namespace sharing weakens isolation on purpose, so make somebody decide it out loud and write down why. Keep --pid, --net and --ipc=host for tightly controlled operations tooling, and off your ordinary app containers.