Map

Dropping Privileges in Go

Wiki summarysecuritysandboxinggosystems-programming โ†ณ show in map Markdown
title
Dropping Privileges in Go
type
summary
summary
A Go program self-restricting at startup โ€” chroot, setrlimit, pledge/unveil, seccomp, Landlock โ€” via x/sys/unix
tags
security, sandboxing, go, systems-programming
created
2026-07-21
updated
2026-07-21

Most programs run with far more privilege than their job requires. A chat client started by a normal user can read that user's SSH private key even though it has no business touching it โ€” and if the client is exploitable, a crafted message can turn that reach into an exfiltration. oxzi's writeup (go-privdrop) walks through how a Go program can shed the privileges it doesn't need at startup, so that a later exploit lands in a much smaller blast radius. See privilege-dropping for the general principle; this page is the Go-specific how-to.

The whole thing is a fit for a Go codebase because every mechanism here is exposed through golang.org/x/sys/unix โ€” no cgo, no shelling out. The two Linux libraries that aren't in x/sys/unix (seccomp and Landlock) are also pure Go.

The core model

Privileges you give up cannot be regained. Once the program denies itself filesystem access, no file can be opened again โ€” there is no call to undo it. That constraint decides the order of operations: acquire everything you need first, then funnel down. The metaphor oxzi uses is an upside-down cone โ€” start wide with all privileges, drop them along the way, continue with the bare minimum.

The canonical example is a daemon that needs a privileged port. It starts as root, binds the port (keeping the file descriptor), then drops to an unprivileged user. It keeps accepting connections on the port it already bound, but can no longer bind other privileged ports. The dangerous capability was used once and then thrown away.

chroot + user switch (any POSIX system)

The classic dance dates to the early 1990s and works on any POSIX-like OS. chroot(2) changes the process root โ€” after Chroot("/var/empty") plus Chdir("/"), a read of /etc/passwd actually resolves under /var/empty. chroot alone is not a security boundary (an attacker can break out of a bare chroot), but it's a building block. Then setresuid/setresgid set the real, effective, and saved IDs to an unprivileged user, and setgroups trims the supplementary group list.

uid, gid, _ := uidGidForUserGroup("demoworker", "demoworker") // lookup FIRST
unix.Chroot("/var/empty")
unix.Chdir("/")
unix.Setgroups([]int{gid})
unix.Setresgid(gid, gid, gid)
unix.Setresuid(uid, uid, uid)

Two ordering traps. The user/group lookup reads /etc/passwd and /etc/group, so it has to happen before the chroot โ€” chrooting into /var/empty first makes the lookup fail with "no such file or directory". And Go's os/user caches the current user with no way to invalidate it: user.Current() always returns whoever ran the process first, so don't rely on it after the switch. The whole approach needs root to begin with, which is fine for daemons but a blocker for GUI end-user apps.

setrlimit โ€” resource caps

After dropping root the process can still burn resources. A parser hit with a billion-laughs attack can peg a core or exhaust memory. setrlimit(2) sets hard limits โ€” RLIMIT_CPU caps CPU seconds, RLIMIT_DATA caps the data segment. Both abort the process when reached. The showcase caps RLIMIT_DATA at 10 MiB. The guidance is to set them high enough that normal operation never touches them, so they act as a safety net rather than a functional constraint.

OpenBSD: pledge and unveil

pledge(2) is a string-of-promises syscall filter โ€” space-separated keywords like stdio, rpath (read-only filesystem), exec. A pledge can only be tightened, never loosened: call it again with a shorter list to give up more. On violation the kernel kills the process, unless error is in the promise list, in which case the denied call just returns an error instead. It's in x/sys/unix as Pledge / PledgePromises / PledgeExecpromises, and GOOS=openbsd go doc -all golang.org/x/sys/unix renders the docs even on Linux.

unix.PledgePromises("stdio rpath error")
// ... read the input file ...
unix.PledgePromises("stdio error") // tighten: file reads no longer needed

unveil(2) is the filesystem allow-list. Each call exposes one path with permissions (read/write/exec/create); a finalizing call with two empty arguments locks the set. After unveiling only Download, a path-traversal attempt like ../.ssh/id_ed25519 fails even though the file exists โ€” which is exactly the mitigation the opening chat-client example needed.

unix.Unveil("Download", "rwc")
unix.UnveilBlock()

Linux: seccomp BPF and Landlock

Linux seccomp BPF hands the kernel a Berkeley Packet Filter program that decides which syscalls are allowed by number and some arguments. Fine-grained but painful to author by hand โ€” the exact syscall set shifts when you update Go or a dependency, and it varies by CPU architecture. The pure-Go github.com/elastic/go-seccomp-bpf builds these filters. On top of it, oxzi wrote github.com/oxzi/syscallset-go, which ports systemd's SystemCallFilter group syntax (@system-service, @basic-io) into a pledge-like string API. Unlike pledge there is no error group โ€” every violation kills the process.

syscallset.LimitTo("@system-service")
// ... read input ...
syscallset.LimitTo("@basic-io")

Landlock is Linux's answer to unveil, plus network isolation, via github.com/landlock-lsm/go-landlock. It restricts filesystem paths and, more recently, inbound/outbound TCP by port. Landlock is versioned and grows with kernel releases, so V5.BestEffort() falls back to whatever the running kernel actually supports.

landlock.V5.BestEffort().RestrictPaths(landlock.RWDirs("Download"))
landlock.V5.BestEffort().RestrictNet(landlock.ConnectTCP(443)) // 80 denied, 443 allowed

landlock.BindTCP restricts which ports can be bound โ€” a hedge against an attacker spawning a listening shell.

Not covered

Linux cgroups, FreeBSD's capsicum(4), and cgroup eBPF-based network filtering are all out of scope in the source. The examples live at codeberg.org/oxzi/go-privsep-showcase.

The framing oxzi leaves you with: for most software the question is not if it can be broken, but when. Dropping privileges is what decides how much an attacker gets once that happens.

  • privilege-dropping โ€” the general least-privilege-at-startup principle these mechanisms implement
  • sandboxing-ai-agents โ€” the same layers (filesystem isolation, syscall filtering) applied to constraining coding agents
  • bubblewrap-dev-env โ€” namespace-based Linux isolation, an external wrapper doing what this does from inside the process
  • syscall-binary-rewriting โ€” a heavier syscall-interception technique than seccomp BPF
  • ephemeral-credentials โ€” a complementary defence: shrink what a leaked secret is worth, not just what can reach it
  • oxzi-blog โ€” the author's blog