CoursesAdvanced container securityNamespaces from first principles

Namespaces from first principles

Build a container by hand with unshare — no Docker.

Advanced16 min · lesson 1 of 25

Run readlink /proc/1/ns/pid on any Linux box and the answer comes back as a number in square brackets. That number is the closest thing the Linux kernel has to a container. There is no container object in the kernel source, no create_container() system call (a syscall is how a program asks the kernel to do something for it), nothing in /proc named container. What exists is a namespace, and the honest picture of a namespace is the one-way mirror in a police interview room. The person inside sees a wall. Everyone on the other side watches the whole room. Docker wraps a handful of those mirrors around an ordinary process and hands you the result under a nicer name. Build that stack by hand once and you stop believing the glass will hold anybody, because glass is all it is.

terminal
$ ls -l /proc/self/ns
total 0
lrwxrwxrwx 1 you you 0 Jul 16 10:22 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 net -> 'net:[4026531840]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 user -> 'user:[4026531837]'
lrwxrwxrwx 1 you you 0 Jul 16 10:22 uts -> 'uts:[4026531838]'

Each of those symlinks points at one namespace, and the number in brackets is an inode number (the serial number a filesystem stamps on every object it tracks, borrowed here to tag each mirror). The rule is short. Same number, same namespace, same view. Different number, different view. That one comparison is the entire detection technique for the rest of this lesson. The 4026531xxx range is the host's starting set, the low fixed numbers the kernel hands its very first namespaces at boot, which is why pid:[4026531836] reads the same on every Linux machine you will ever touch. Namespaces created later get higher numbers, something like 4026532677. Memorize pid:[4026531836]. If a container reports it, that process is standing in the host's own process namespace and has no private one at all.

Build a container by hand

unshare is a small program that starts another program inside brand new namespaces. No daemon, no image, no registry. The single command below hands /bin/sh its own process tree, its own mount table, its own hostname, its own network stack, and its own user mapping. The --map-root-user part then remaps you, the caller, to root inside the new user namespace (userns for short), so you can hold all of this without any real privilege on the host.

terminal
$ unshare --pid --mount --uts --net --user --map-root-user --fork --mount-proc /bin/sh
# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
# hostname box; hostname
box
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:24 pts/0 00:00 /bin/sh
root 7 1 0 10:24 pts/0 00:00 ps -ef
# ip -o link show
1: lo: <LOOPBACK> mtu 65536 state DOWN

Look at what changed. Inside, sh is process ID 1 (PID 1, the first process, the one the kernel starts and every other process descends from), and ps can find nothing else on the machine. You can rename the host and the real host never hears about it. The network is an empty stack with a single loopback interface, and even that one is down. You have built the visible shell of a container with a binary that ships in every distribution. Now walk over to a second terminal and look at what you actually created.

Stacking one-way mirrors: a container, one namespace at a time
1plain process
sees every process, mount, socket, and user ID (UID) on the host
2+ unshare --pid
own process tree; it becomes PID 1, host PIDs leave its view
3+ unshare --mount
own mount table; pivot into a private root filesystem
4+ unshare --net
own interfaces and ports; host network drops out of view
5+ unshare --user
container root maps to an unprivileged host UID
6container
isolated view, one shared kernel underneath: same syscalls, same bugs
terminal
# in a SECOND terminal on the host, the unshared sh is still running:
$ lsns --type pid
NS TYPE NPROCS PID USER COMMAND
4026531836 pid 291 1 root /sbin/init
4026532677 pid 2 30512 you /bin/sh
$ sudo readlink /proc/1/ns/pid /proc/30512/ns/pid
pid:[4026531836]
pid:[4026532677]

There it is, in two lines. Your container is host PID 30512 wearing a private pid namespace numbered 4026532677, parked in the process list right beside init like any other program. docker ps will never show it, because Docker had nothing to do with it. lsns and /proc show it every single time. The two inode numbers differ, so this shell genuinely cannot reach host processes. Correct isolation, viewed from the outside, looks like a different number. That is the whole tell.

The mirror hides; it doesn't disarm

A namespace edits what a process sees. It edits nothing about what that process can do to the kernel sitting behind the glass, and that kernel is the same one carrying every other workload on the machine. Give the shell CAP_SYS_ADMIN (a Linux capability, one of roughly forty keys the kernel chopped root's power into) and it can mount and unmount filesystems inside its own view. Point it at a kernel bug and the isolation stops counting for anything, because every container on the box rides the same syscall path into the same code. Which is why deliberately sharing a host namespace is such a bad trade. It removes even the visibility barrier and lets the container reach into the host's world with both hands.

terminal
$ docker run -d --name apm --pid=host nginx # 'just so the agent can see host procs'
$ docker inspect -f '{{.HostConfig.PidMode}}' apm
host
$ cid=$(docker inspect -f '{{.State.Pid}}' apm)
$ sudo readlink /proc/1/ns/pid /proc/$cid/ns/pid
pid:[4026531836]
pid:[4026531836]

Identical inodes. The --pid=host flag took the mirror out entirely, so that container stands in the host's process namespace and can list, trace, and signal anything running on the machine. Add --cap-add SYS_PTRACE on top and it can read the memory of those processes too, secrets included. You detect it with the inode comparison above, or with a docker inspect that shows PidMode, NetworkMode, or IpcMode set to host. Search your run commands and Compose files for --pid=host, --net=host, and pid: host. Every hit is a mirror somebody took out on purpose.

Isolation you can't see from inside
A container can look completely sealed from within, with its own hostname, its own root filesystem, and a ps that lists only itself, while it quietly shares the host's network or PID namespace. The --net=host and --pid=host flags leave no fingerprint inside the container. Nothing in there tells you a mirror is missing. The only honest check runs from the host: compare the /proc/<pid>/ns/* inodes against /proc/1/ns/*, or read HostConfig.NetworkMode, PidMode, and IpcMode out of docker inspect. Believing the view from inside is exactly how shared namespaces survive a code review.

Close it with a user namespace

The strongest move is to keep every mirror you already have and add the one Docker leaves switched off by default: the user namespace. It maps container UID 0 (root's numeric user ID) onto a high, boring host UID, so root inside the container becomes a costume with no authority anywhere else. If a process does claw its way out of the other namespaces, it lands on the host as an account that owns no files and can open no doors. You turn it on for the whole daemon with the userns-remap setting.

terminal
# /etc/docker/daemon.json
# { "userns-remap": "default" }
$ sudo systemctl restart docker
$ docker run -d --name safe alpine sleep 300
$ ps -o pid,user,args -p $(docker inspect -f '{{.State.Pid}}' safe)
PID USER COMMAND
20531 165536 sleep 300
$ docker run --rm alpine cat /proc/self/uid_map
0 165536 65536

Container root is now host UID 165536, an account with nothing to its name and no rights to speak of. The uid_map line states the bargain in three numbers: UIDs 0 through 65535 inside the container correspond to 165536 and upward outside it. Take away --pid=host and --net=host as well, and every mirror is back where it belongs, with unprivileged glass behind it.

Building one by hand pays off twice. It shows you the kernel already owned every piece long before Docker existed, and it fixes the gap that matters in your head. The process inside believes its mount table, its PID 1, and its network stack are the whole machine. The host sees one ordinary program wearing a few extra numbers. Everything a vendor sells you as container isolation rests on that gap and on nothing else.

The mistake teams make is treating namespaces like a bank vault door. They hide; they do not disarm. A process that keeps CAP_SYS_ADMIN can still mount things, pivot the root filesystem, and prod /proc in ways that matter a great deal on a shared kernel. Namespaces plus a user namespace remap, plus dropped capabilities, plus a seccomp profile (a filter that decides which syscalls a process is allowed to make at all) is what turns the costume into a boundary you can defend in an incident review.

On call at 3am, the useful question is never "is this a container?" It is "which namespaces does this process hold, and which capabilities came along for the ride?" Read /proc/<pid>/ns and the CapEff (effective capabilities) line in that process's status file before you trust any isolation claim a dashboard makes.

After a change window, that same check is your close-out step. Run it, confirm the control is still on, paste the command and its output into the ticket, and refuse to sign the change off if the reading has drifted. Pick the tightest scope the workload will tolerate. It is a small habit, and it pays you back on every host and in every pipeline you own.

Try this

On a Linux lab box, never production, build a tiny "container" with unshare and no Docker anywhere in sight, then compare what you see inside against what the host sees. You want the PID and mount views to disagree with the host. You also want to feel, with your own hands, how little that disagreement does to slow down a privileged process.

terminal
$ # host view first
$ echo "host pid1=$(readlink /proc/1/exe)"; hostname; ls / | head -3
host pid1=/usr/lib/systemd/systemd
lab-host
bin
boot
dev
$ sudo unshare --mount --pid --fork --mount-proc /bin/bash
# inside the new namespaces:
$ echo "inner pid=$$"; ls /proc | head -5; hostname
inner pid=1
1
self
thread-self
lab-host
$ # still the same hostname unless you also unshare --uts
$ exit
$ echo "back on host pid=$$"
back on host pid=24881

Takeaway

Namespaces are the costume, not the vault. Real isolation is what you stack on top of what unshare showed you: user remapping, dropped capabilities, syscall filters. Then you verify it by reading /proc, never by reading a slide deck.

Quick check
01You run readlink /proc/1/ns/pid on the host, then again inside a container. Both print pid:[4026531836]. What have you learned?
Incorrect — Backwards. A matching inode number means the same namespace, which is the opposite of isolation.
Correct — Same inode, same namespace. That is the signature of --pid=host, and it is a clear window into the host.
Incorrect — Inode numbers are not a scarce pool that can run dry. Two identical numbers mean one shared namespace, nothing more.
Incorrect — That would be the net namespace, and it is unrelated. This comparison looks only at the pid namespace.
02This lesson describes a namespace as a one-way mirror. What does that comparison get right about how a namespace actually behaves?
Correct — A namespace narrows the view and stops there. The container still calls into the same host kernel, with the same syscalls and the same bugs.
Incorrect — No. Namespaces govern which kernel resources a process can see and name. Encryption plays no part in it.
Incorrect — No. Every container on the box shares the one host kernel. Only a virtual machine gets a kernel of its own.
Incorrect — No. The mirror faces one way. The host looks straight through it with lsns and /proc, and only the container's view is narrowed.
03You run unshare --pid --mount --uts --net --user --map-root-user --fork --mount-proc /bin/sh as an ordinary non-root user. Inside, id reports uid=0(root). Meanwhile lsns in a second host terminal lists that same shell under your unprivileged username. How is it root inside and unprivileged outside at the same time?
Incorrect — No. Nothing was escalated. --map-root-user is precisely the mechanism that lets an unprivileged user hold these namespaces.
Incorrect — No. The host view is the accurate one. On the host, that shell genuinely runs as your unprivileged UID.
Correct — User namespace root is a costume. Outside the namespace the kernel still sees your ordinary unprivileged UID.
Incorrect — No. Nothing escaped anything. The UID difference is the user namespace mapping doing exactly the job it was designed for.

Related