DEV Community

Neeraj Singhi
Neeraj Singhi

Posted on Originally published at neerajsinghi.com

Go Daemon Hardening: Process Isolation, Capability Dropping, and the Privilege Escalation Surface Inside Your Service

Go Daemon Hardening: Process Isolation, Capability Dropping, and the Privilege Escalation Surface Inside Your Service

Most Go services are deployed with more privilege than they require. The process binds a port, opens a TLS certificate, connects to Redis, and then runs for weeks with whatever ambient capability set the container runtime or systemd unit handed it at start. That ambient set is the attack surface. When a deserialization bug, a path traversal, or a confused-deputy in an internal RPC fires, the question is not whether the attacker reaches your process—it is what they can do once they are inside it.

This article focuses on the concrete Linux privilege primitives that a Go backend engineer can engage at startup, the tradeoffs each one introduces, and the failure modes that appear in production when they are applied carelessly.

Why Go Daemons Accumulate Privilege by Default

A Go binary starts in the OS-assigned process credential set: uid, gid, supplementary groups, and a Linux capability bounding set. In a container, that set is typically whatever the OCI runtime allows—which, with Docker defaults, includes CAP_NET_BIND_SERVICE, CAP_CHOWN, CAP_DAC_OVERRIDE, and a dozen others your service will never legitimately use.

Go's runtime adds its own surface. The net package uses cgo on some platforms for DNS resolution, which means a C resolver is loaded into the process. The os/exec package will happily fork-exec anything on PATH. plugin.Open loads arbitrary shared objects. Each of these is a reachable capability that an attacker controlling a goroutine can exercise.

The temptation is to handle this at the orchestration layer—set runAsNonRoot: true in the pod spec, drop capabilities in the securityContext—and stop there. That is necessary but not sufficient. Kubernetes security contexts are enforced at container start and can be misconfigured, overridden by admission webhooks, or silently ignored by older runtimes. Defense-in-depth requires the process itself to shed privilege after it no longer needs it.

The Privilege Lifecycle of a Hardened Daemon

A well-structured Go daemon has three phases of privilege:

  1. Startup: Elevated. The process binds privileged ports, reads secrets from the filesystem or KMS, loads TLS certificates, opens raw sockets if needed.
  2. Transition: Drops what it no longer needs. Calls syscall.Setgroups, syscall.Setgid, syscall.Setuid, sets the PR_SET_NO_NEW_PRIVS bit, installs a seccomp filter, and enters the main serve loop.
  3. Runtime: Least privilege. No capability can be re-acquired. The seccomp filter blocks syscalls the service never uses.

Go's syscall and golang.org/x/sys/unix packages expose the primitives for phases one and two. The ordering matters: you must drop supplementary groups before changing uid, and you must install seccomp after forking any threads that need broader access (the Go runtime starts its own threads early, so seccomp must be applied after runtime.LockOSThread on the goroutine doing the drop).

package main

import (
    "log"
    "net"
    "runtime"
    "syscall"

    "golang.org/x/sys/unix"
)

func dropPrivileges(uid, gid int) error {
    runtime.LockOSThread()
    // Never unlock: this OS thread stays bound to this goroutine.
    // Privilege drop on Linux is per-thread in the kernel;
    // LockOSThread ensures the Go scheduler does not migrate us.

    if err := syscall.Setgroups([]int{}); err != nil {
        return err
    }
    if err := syscall.Setgid(gid); err != nil {
        return err
    }
    if err := syscall.Setuid(uid); err != nil {
        return err
    }
    // PR_SET_NO_NEW_PRIVS: child processes and exec calls cannot
    // regain privileges via setuid bits or file capabilities.
    if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
        return err
    }
    return nil
}

func main() {
    ln, err := net.Listen("tcp", ":443") // requires CAP_NET_BIND_SERVICE
    if err != nil {
        log.Fatal(err)
    }

    if err := dropPrivileges(65534, 65534); err != nil { // nobody:nogroup
        log.Fatal(err)
    }

    // Listener is already open; the privilege drop does not close it.
    serve(ln)
}
Enter fullscreen mode Exit fullscreen mode

The critical detail: net.Listen before dropPrivileges. Once you call Setuid, the kernel will deny bind on ports below 1024 regardless of file capabilities, because CAP_NET_BIND_SERVICE is now absent from the effective set of the non-root uid. Open the socket first.

Linux Capabilities: Surgical Reduction

Dropping to a non-root uid removes most capabilities but not all. File capabilities set on the binary itself can survive. To be precise, use cap_set_proc via golang.org/x/sys/unix to explicitly zero the permitted, effective, and inheritable sets after the uid drop:

func clearCapabilities() error {
    // capHeader identifies the thread.
    hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3}
    data := [2]unix.CapUserData{}
    // All fields zero = all capabilities cleared.
    return unix.Capset(&hdr, &data[0])
}
Enter fullscreen mode Exit fullscreen mode

Call this after dropPrivileges. The process now has no capability bits set anywhere in its thread's credential set. A compromised goroutine cannot call setuid(0), cannot open raw sockets, cannot ptrace other processes.

Seccomp: Blocking the Syscall Vocabulary

Capability dropping reduces what the kernel grants, but the process still has access to the full Linux syscall table. A memory-corruption exploit that hijacks the instruction pointer can call execve, clone, or socket directly. Seccomp filters restrict which syscalls the kernel will service, converting a successful exploit into a SIGSYS kill rather than a shell.

The practical approach for Go is an allowlist generated from the syscalls your service actually uses. Tools like strace -e trace=all or go-seccomp-bpf (the library behind gVisor's sandbox) let you capture the runtime syscall set during integration tests and compile a BPF filter.

The tradeoff: Go's runtime itself uses syscalls that vary by kernel version, GOMAXPROCS setting, and garbage collector phase. A filter too tight will kill your process during a GC cycle or a goroutine park. The safe defaults for a Go HTTP service typically include read, write, close, epoll_wait, epoll_ctl, futex, mmap, mprotect, clock_gettime, rt_sigaction, rt_sigreturn, exit_group, getpid, and the Go-specific set that the runtime uses for its scheduler. The filter must be validated against the exact Go version in your build pipeline because minor releases have introduced new syscalls.

Linux Namespaces: Isolating the Filesystem and Network View

If the service runs in a context where you can call unshare(2) (containers in privileged mode, or systems using user namespaces), mount namespaces allow you to construct a minimal chroot equivalent without root. After binding sockets and loading secrets:

  • Enter a new mount namespace with CLONE_NEWNS.
  • Bind-mount only the directories the service reads (/etc/ssl/certs, the config file path).
  • Make the rest of the root read-only or inaccessible with MS_RDONLY and MS_PRIVATE mounts.

In Go, syscall.Unshare(syscall.CLONE_NEWNS) enters a new mount namespace for the calling thread. Combined with runtime.LockOSThread, this affects only the bound OS thread, which is a footgun: other goroutines still see the original namespace. For whole-process namespace isolation, the unshare must happen before the Go runtime spawns its thread pool—which means before main() runs. The standard approach is a re-exec pattern: the binary detects an environment variable, performs namespace setup in a minimal init(), then exec-replaces itself with the same binary for the real main(). systemd's PrivateTmp, ProtectSystem, and ReadOnlyPaths directives implement the same mechanics at the unit level and are the operationally simpler path when you control the host.

The Failure Modes Nobody Patches

Setuid ordering inversion. Calling Setuid before Setgroups leaves the process still a member of privileged groups. On systems where the docker or sudo groups have broad socket access, this is a real escalation path.

LockOSThread scope. If dropPrivileges returns without unlocking but the goroutine is later GC'd and the OS thread is reused, the privilege state on that thread is gone but other goroutines running on other threads retain the original credentials. Go's threading model means per-thread credential changes do not propagate to the process credential set unless you use CLONE_THREAD-aware kernel calls. Always verify with /proc/self/status (CapEff line) post-drop.

Seccomp and CGO interaction. If your binary links C code via cgo—even transitively through a dependency—the C runtime may call syscalls not in your allowlist. malloc implementations use brk or mmap; TLS libraries use getrandom. A seccomp filter derived from pure-Go runtime profiling will kill a cgo binary unpredictably under load.

Ambient capability inheritance. PR_SET_NO_NEW_PRIVS blocks setuid-binary escalation but does not clear ambient capabilities set by the parent. If your container runtime sets ambient capabilities (some do for compatibility), you must explicitly clear them: prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0).

Decision Framework

Apply this sequence when hardening a Go daemon for production:

  1. Audit the startup privilege requirement. List every syscall and resource acquired before the serve loop. If nothing needs root, use User= in systemd or runAsUser in Kubernetes from the start.
  2. Bind privileged resources early, drop immediately after. Port binding, certificate loading, secret fetching all happen before dropPrivileges. No privileged operation should occur in the serve loop.
  3. Zero capabilities explicitly. Do not rely on uid drop alone. Call Capset with a zeroed data structure.
  4. Set PR_SET_NO_NEW_PRIVS unconditionally. It is a one-way door and costs nothing.
  5. Install seccomp from an integration-test-derived allowlist. Never hand-write a denylist. Default-deny with an allowlist is the only safe model.
  6. Validate with capsh --print and /proc/self/status in your readiness probe or startup test. If the service starts with non-zero CapEff, the drop failed; fail the probe.
  7. For cgo-linked binaries, regenerate the seccomp allowlist against the exact binary. Pure-Go and cgo daemons have different syscall vocabularies and cannot share filters.

Privilege reduction is not a deployment concern delegated to the platform team. It is a code-level contract the service makes with itself, and enforcing it in the binary ensures it survives cluster migrations, runtime upgrades, and misconfigured admission controllers.

Top comments (0)