Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Systems Programming

Published:

Tags:
  • go
  • linux
  • security
  • capabilities
  • setuid
  • networking

Linux Capabilities vs setuid Root: Why CAP_NET_BIND_SERVICE Is the Better Way to Bind Port 80 in Go

Every Go developer hits this one eventually. You write a perfectly ordinary HTTP server:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("hello"))
	})

	srv := &http.Server{
		Addr:    ":80",
		Handler: mux,
	}
	log.Fatal(srv.ListenAndServe())
}

You run it as yourself, not as root, because running network services as root is the kind of thing you'd rather not explain to a security reviewer later. And you get:

listen tcp :80: bind: permission denied

This isn't a filesystem permission, a firewall, or anything Go-specific. It's a Linux (and generally Unix) kernel check: any TCP or UDP port below 1024 can only be bound by a process with the right privilege. The usual "fix" people reach for is either running the whole binary as root, or the classic setuid-root binary that drops privileges after the bind call. Both are worse than the actual answer, which is a single Linux capability: CAP_NET_BIND_SERVICE.

Why 1024 is special, and why it's a weaker guarantee than it looks

The privileged-ports convention goes back to early BSD Unix and the "r-commands" (rsh, rlogin). The idea was that if a connection came from a low-numbered source port, the remote host could infer it originated from a privileged (trusted) process, because only root could have bound it. That's a laughably weak trust model by modern standards, it says nothing about who's running the code and nothing about the network path, but the kernel check that enforces it is still very much alive: bind() on a port under 1024 requires the effective capability CAP_NET_BIND_SERVICE (or, for backward compatibility, an effective UID of 0).

So the actual requirement was never "be root". It was always "hold one specific capability". Running as root has just historically been the only way most programs knew how to get it.

The setuid approach, and where it gets awkward in Go

The traditional non-root-but-still-privileged pattern is a setuid-root binary: the file is owned by root with the setuid bit set, so it starts running as root regardless of who invokes it, binds port 80, then calls setuid() to permanently drop down to an unprivileged user before doing anything else (parsing untrusted input, serving requests, and so on).

This is workable in C, but it has a genuinely nasty pitfall in Go that's worth knowing about even if you never touch it: for a long time, syscall.Setuid on Linux didn't reliably drop privilege for the whole process. Linux's raw setuid(2) syscall is per-thread, not per-process. glibc papers over this by broadcasting the change to every thread in the process via a signal (the NPTL "setxid" mechanism), but Go's syscall package made the raw syscall directly, bypassing glibc's synchronisation. Since the Go runtime schedules goroutines across multiple OS threads, a call to syscall.Setuid could drop privilege on the calling thread while other OS threads, quite possibly the ones about to service an incoming request, kept root credentials. This was tracked for years as golang/go#1435 and only got a proper fix in Go 1.16, which added an internal all-threads syscall mechanism so Setuid and friends actually apply process-wide.

That fix means privilege-dropping in modern Go is no longer silently broken, but the setuid pattern still carries the sharper problems it always had: the binary needs a root-owned setuid file on disk (a permanent, standing attack surface independent of whether the process is even running), it briefly holds the full root capability set on every startup, and dropping privilege correctly means getting the order right (drop supplementary groups, then GID, then UID, and check every return value, because a failed setuid() that's silently ignored leaves you running as root indefinitely). It's fixable. It's just more machinery than the problem deserves.

What a capability actually is

Linux capabilities split "the things root can do" into around 40 independent units, introduced so a process could hold exactly one of root's powers instead of all of them. CAP_NET_BIND_SERVICE is one of the smallest and most self-contained: it permits binding to a TCP or UDP port below 1024, and does nothing else. There's no privilege escalation path hiding in it: it doesn't let you read arbitrary files, change ownership, or trace other processes.

A process's capabilities live in several sets (permitted, effective, inheritable, and since Linux 4.3 also an ambient set), but for this use case you can mostly ignore the theory and use one command. A capability can be attached directly to a binary's inode as a file capability:

go build -o myserver .
sudo setcap 'cap_net_bind_service=+ep' ./myserver
./myserver   # runs as your normal user, can still bind :80

The +ep means "add to the Effective and Permitted sets on exec". When the kernel loads that binary, it raises CAP_NET_BIND_SERVICE into the process's effective set immediately, no code in the Go program needs to know anything happened. Everything else about the process runs as your ordinary, unprivileged user: no setuid bit, no root ownership requirement on the running process, no window where the process holds more than the one capability it needs.

The gotchas that actually bite in practice

File capabilities are stored as an extended attribute (security.capability) on the inode, and that has real operational consequences:

  • They don't survive a rebuild. Every go build produces a new inode, so setcap has to run again after every build. If you're building in CI and shipping an artefact, the setcap step belongs in the same pipeline stage as the build, not as a one-off someone ran on a laptop three deployments ago.
  • They don't survive careless copying. Plain cp without -p/--preserve=xattr, or a naive tar without --xattrs, will silently drop them. A Dockerfile COPY of a pre-setcap'd binary is not guaranteed to preserve the attribute either, depending on the builder and base image, so it's more reliable to run setcap as a RUN step inside the image build, after the binary is in place.
  • They need a filesystem that supports the xattr, and a mount that isn't stripping it. This is rarely an issue on ext4 or xfs, but is worth checking on network filesystems or unusual overlay setups.
  • They're attached to the ELF binary specifically, not to anything that execs it. If you wrap your Go binary in a shell script (to set environment variables, say) and run the script instead, the capability doesn't apply. The script isn't the file the capability was set on, and the kernel doesn't honour file capabilities on interpreters/scripts the way it does on the interpreted binary itself.

The systemd-native version

If the service is going to run under systemd anyway, there's an alternative to setcap that keeps the whole thing in one declarative file and survives rebuilds without extra pipeline steps, because the capability is granted to the process at launch time rather than baked into the binary on disk:

[Service]
ExecStart=/usr/local/bin/myserver
User=myserver
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

AmbientCapabilities grants the capability to the process systemd starts, regardless of what's on the binary's inode. CapabilityBoundingSet is worth setting explicitly too: it caps the maximum set of capabilities the process (and anything it execs) could ever hold, so even if something else grants extra capabilities later, this unit can't use them. NoNewPrivileges=yes blocks the process from gaining more privilege via execve of a setuid or file-capability binary. Together, this is a service that never runs as root, never has a setuid file anywhere on disk, and holds precisely one narrow, well-understood extra permission.

In a container

The same capability has to be granted at the container runtime level too, since containers start with a restricted default capability set of their own:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myimage

or, in a Kubernetes pod spec:

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]

Dropping everything and adding back exactly one capability is a more precise version of the same principle as the setcap approach: start from nothing, add only what the bind call needs, and never touch root at all.

None of this is exotic; it's the same reasoning that makes CAP_NET_BIND_SERVICE preferable to root inside the process. If you'd rather not touch capabilities at all, putting a reverse proxy in front that owns port 80/443 and forwards to your Go service on a high port is a perfectly reasonable choice too, and probably the more common one in practice. But if you want the Go binary itself to hold the socket, on bare metal or in a container, the capability is a smaller, better-understood grant than anything involving root:root and a setuid bit.