Rootless Namespaces from Go: A 60-Line Sandbox with SysProcAttr and the Re-exec Pattern
Rootless Namespaces from Go: A 60-Line Sandbox with SysProcAttr and the Re-exec Pattern
Thesis
Containers are built from ordinary Linux processes placed in fresh namespaces. With a user namespace, an unprivileged user can create one: you become “root” inside it, mapped to your own uid outside. Go makes this short to write, with one Go-specific constraint. A Go program is always multithreaded, and the kernel won’t let a multithreaded process unshare(CLONE_NEWUSER). Go programs therefore request the namespaces at clone time through syscall.SysProcAttr, and re-execute themselves (/proc/self/exe) to run setup code inside them.
This chapter builds nsbox, which uses new user, PID, UTS, mount and network namespaces with no root and no dependencies. It then checks from inside what the sandbox actually isolates, including one leak that surprises most people the first time.
Environment: Go 1.27.2 on Linux 6.12 (x86-64), run as an ordinary user (uid 1000) with unprivileged user namespaces enabled (/proc/sys/user/max_user_namespaces > 0). Some distributions restrict them, for example through AppArmor policy on Ubuntu 24.04+. If they are restricted, the run step fails with operation not permitted.
nsbox
// nsbox: run a command in new user, PID, UTS, mount and network namespaces,
// without root, using the re-exec pattern.
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: nsbox run|child CMD [ARGS...]")
os.Exit(2)
}
switch os.Args[1] {
case "run":
run(os.Args[2:])
case "child":
child(os.Args[2:])
default:
os.Exit(2)
}
}
// run is the parent: it re-executes this binary as "child" inside new namespaces.
func run(argv []string) {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, argv...)...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET,
// Map our unprivileged uid/gid to root inside the user namespace.
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getuid(), Size: 1}},
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getgid(), Size: 1}},
}
fmt.Printf("[parent] host pid %d starting child\n", os.Getpid())
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr, "[parent] child:", err)
os.Exit(1)
}
fmt.Printf("[parent] child pid on the host was %d\n", cmd.Process.Pid)
}
// child runs as PID 1 of the new PID namespace, before exec'ing the user's command.
func child(argv []string) {
must(syscall.Sethostname([]byte("nsbox")))
// Stop mount events propagating back to the host, then mount a fresh /proc
// so tools like ps see only this PID namespace.
must(syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""))
must(syscall.Mount("proc", "/proc", "proc", syscall.MS_NOSUID|syscall.MS_NODEV|syscall.MS_NOEXEC, ""))
path, err := exec.LookPath(argv[0])
must(err)
must(syscall.Exec(path, argv, os.Environ()))
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "[child]", err)
os.Exit(1)
}
}The parent and child are the same binary:
run(parent) starts/proc/self/exe child CMD...withSysProcAttr:Cloneflagslists the new namespaces.UidMappings/GidMappingswrite/proc/<pid>/uid_mapandgid_map, mapping in-namespace 0 to our real uid and gid. Theuser_namespaces(7)rules allow an unprivileged process to map exactly one id, its own. The Go runtime also writesdenytosetgroupsbefore writing the gid map, as the kernel requires.
childruns inside the namespaces as PID 1. It sets the hostname (UTS namespace) and makes every mountMS_PRIVATEso nothing propagates back to the host (mount_namespaces(7)). It then mounts a new/procso that/procdescribes this PID namespace, and finallyexecs the user’s command. That command takes over PID 1.
What it looks like from inside
#!/bin/sh
# what the world looks like from inside nsbox
echo "id: $(id -u):$(id -g) ($(id -un))"
echo "hostname: $(hostname)"
echo "my pid: $$"
echo "processes:"; ps -eo pid,user,cmd
echo "net devices per /proc/net/dev:"; tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' '
echo "net devices per /sys/class/net (host's sysfs):"; ls /sys/class/net | tr '\n' ' '; echo
mount -t sysfs sysfs /sys && echo "after mounting a fresh sysfs:" && ls /sys/class/net
echo "try to write a host-root file:"; touch /etc/nsbox-test 2>&1 || trueecho "\$ id -u; hostname; ls /sys/class/net | wc -l"
id -u; hostname; ls /sys/class/net | wc -l
echo "\$ ./nsbox run ./inside.sh"
./nsbox run ./inside.sh
echo "\$ hostname # unchanged on the host"
hostname$ id -u; hostname; ls /sys/class/net | wc -l
1000
grok-bot-vm-399265740
3
$ ./nsbox run ./inside.sh
[parent] host pid 1671445 starting child
id: 0:0 (root)
hostname: nsbox
my pid: 1
processes:
PID USER CMD
1 root /bin/sh ./inside.sh
10 root ps -eo pid,user,cmd
net devices per /proc/net/dev:
lo
net devices per /sys/class/net (host's sysfs):
docker0 enp0s4 lo
after mounting a fresh sysfs:
lo
try to write a host-root file:
touch: cannot touch '/etc/nsbox-test': Permission denied
[parent] child pid on the host was 1671451
$ hostname # unchanged on the host
grok-bot-vm-399265740
Reading it line by line:
- User namespace.
idreports0:0 (root), buttouch /etc/nsbox-testis still denied. Root inside maps to uid 1000 outside, and files owned by host root are outside our mapping. A user namespace gives you capabilities over resources owned by that namespace, not over the host. - PID namespace (
pid_namespaces(7)). The shell is PID 1, andpssees only the shell and itself. The same process is PID 1671451 on the host (the parent prints it). - UTS namespace. The hostname is
nsboxinside, and the host’s hostname is unchanged afterwards. - Network namespace (
network_namespaces(7))./proc/net/devshows onlylo, so the sandbox has no route out. However,/sys/class/netstill lists the host’sdocker0andenp0s4. sysfs reports the network namespace of whoever mounted it, and/syswas inherited from the host. Mounting a fresh sysfs (allowed because our user namespace owns the new network namespace) shows onlylo. Real runtimes mount new/procand/sysfor this reason. Tools that read/sys/class/netwould otherwise get the host’s view.
What nsbox is not (yet)
nsbox shares the host’s root filesystem, read-only in effect for host-owned files. A real runtime would add:
- A root filesystem. Unpack an image into a directory, bind-mount it onto itself, then call
pivot_root(2)into it and unmount the old root.chrootalone is escapable. - Resource limits. Create a cgroup v2 directory, write
memory.max/pids.max/cpu.max, and add the child’s PID. Rootless this works only inside a cgroup subtree delegated to your user, usually by systemd. - Networking. Connect a veth pair, or a user-mode stack such as slirp4netns or pasta, to the empty network namespace.
- Syscall filtering and capability dropping. Use seccomp,
no_new_privs, and a reduced capability set.
Takeaways
- Go programs can’t
unshare(CLONE_NEWUSER)because they are always multithreaded. Request namespaces at clone time withSysProcAttr.Cloneflagsand re-exec/proc/self/exefor the setup code. - With
UidMappings/GidMappings, an unprivileged user becomes root inside a namespace without gaining any power over host-owned resources. - Remount
/procand/sysinside the sandbox. Otherwise/sys/class/netstill shows host interfaces even in an empty network namespace. - In-process
setns/unsharefrom Go requiresruntime.LockOSThreadand a thread that is never unlocked.
Sources
- Linux man-pages: namespaces(7), user_namespaces(7), pid_namespaces(7), mount_namespaces(7), network_namespaces(7), unshare(2), pivot_root(2)
- Go:
syscall.SysProcAttrandruntime.LockOSThread