Dropping Privileges in Go: chroot, setrlimit, pledge/unveil, seccomp, Landlock
- title
- Dropping Privileges in Go: chroot, setrlimit, pledge/unveil, seccomp, Landlock
- author
- oxzi (0x21.biz)
- fetched
- 2026-07-21
Dropping Privileges in Go
Computer programs may do lots of things, both intended and unintended. What they can do is limited by their privileges. Since most operating systems execute programs as a certain user, the program has all the user's precious privileges.
To take a concrete example, if a user has an SSH private key laying around and runs, e.g., a chat program, then this program is able to read the private key even though it has nothing to do with it. Assuming that this chat is exploitable, then an attacker might instruct the chat through a crafted message to exfiltrate the private key.
The damage is rooted in the fact that a program was able to access a resource that it should not be able to access in the first place. The private key could have been saved if the principle of least privilege had been enforced by some means. It says, in a nutshell, that each component should only have the necessary privileges and nothing more.
When developing software, the developer should know what their tool should be able to do. Thus, they are able to carve out the allowed territories, denying everything else with the help of system features. As a metaphor, think of a werewolf chaining themself up before full moon.
For most applications out there, the question is not if they can be broken, but when they will be broken.
Changes In Software Architecture
The idea of self-restricting software is that given up privileges cannot be gotten back. For example, once the program denied itself file system access, no more files can be opened.
The software starts as a certain user, sometimes root, e.g., to use a restricted network port. After starting to listen on this port, this privilege can be dropped, e.g., by switching to an unprivileged user while keeping the file descriptor. The software continues accepting connections on the prior bound port, but cannot start listening on other restricted ports.
This limitation must be taken into account when planning the software. Resources must be acquired in the beginning phase before self-restricting. Think about a funnel or an upside down cone: the program starts with all these privileges, dropping them along the way until it continues with just the bare minimum.
Good Old Chroot And User-Switch
The classic approach of chrooting and user/group changing goes back to the early 1990s and works on any POSIX-like operating system (BSDs, Linux and friends). The annoyance is that the necessary system calls are reserved for the root user. While in most cases this is not a problem for daemons, it is a blocker for end user applications, like GUIs.
chroot(2)
chroot(2) changes the process' root directory to the supplied one. For example, / becomes /var/empty and accessing /etc/passwd would actually try to open /var/empty/etc/passwd. To activate the chroot(2), the process needs to chdir(2) into it.
Unless further actions are taken, an attacker can break out of a chroot. Chrooting is not a security feature by itself, but can be used to build one (known limitations).
if err := unix.Chroot("/var/empty"); err != nil {
log.Fatalf("chroot: %v", err)
}
if err := unix.Chdir("/"); err != nil {
log.Fatalf("chdir: %v", err)
}
setuid(2) / setresuid(2)
To give up root privileges, the process switches user rights to an unprivileged user. While not strictly part of POSIX, the setresuid(2) syscall is available on most operating systems. It sets the real, effective and saved user ID. Here all three are set to the same unprivileged user. The same applies to groups with setresgid(2), and the group list can be shortened via setgroups(2).
Order matters: do the user/group lookup first (it needs /etc/passwd and /etc/group), then chroot(2), then the user/group switching. A chroot to /var/empty before lookup would fail with open /etc/passwd: no such file or directory.
Caution: Go's os/user package caches the current user and cannot invalidate this cache; user.Current() always returns whatever executed it first.
uid, gid, err := uidGidForUserGroup("demoworker", "demoworker")
// ... chroot to /var/empty, chdir("/") ...
unix.Setgroups([]int{gid})
unix.Setresgid(gid, gid, gid)
unix.Setresuid(uid, uid, uid)
Limiting Resources The POSIX Way β setrlimit(2)
After chrooting and dropping root, the code can still burn cycles. A parser may be vulnerable to a billion laughs attack, causing 100% CPU load or memory exhaustion. setrlimit(2) limits resources; RLIMIT_CPU caps CPU time, RLIMIT_DATA caps the data segment (memory). Both are hard limits that abort the process when reached. Guidance: set limits high enough not to be hit during normal operation, so they act as a safety net. Soft limits are left as an exercise.
OpenBSD: pledge(2) β Restricting Syscalls
System call filtering lets a program restrict which syscalls it may use later. OpenBSD's pledge(2) provides a simple string-based API of space-separated keywords ("promises"). Promises are names of syscall groups, e.g., rpath for read-only filesystem calls. Adding exec allows executing other programs.
int pledge(const char *promises, const char *execpromises);
A pledge cannot be undone, only tightened (call again with a shorter list). On violation the process is killed, unless error is in the promise (then the denied call returns an error). Available in Go via golang.org/x/sys/unix as Pledge, PledgePromises, PledgeExecpromises. GOOS=openbsd go doc -all golang.org/x/sys/unix renders the docs on Linux.
unix.PledgePromises("stdio rpath error")
// ... read input file ...
unix.PledgePromises("stdio error") // tighten: reading files no longer needed
Most programs shipped with OpenBSD are pledged. One command drops a lot of privileges.
OpenBSD: unveil(2) β Restricting File System Access
Multiple unveil(2) calls build an allow-list of paths the program may access, each with permissions (read/write/exec/create). A finalizing call with two empty parameters enforces it. Go: unix.Unveil(path, flags) plus unix.UnveilBlock().
unix.Unveil("Download", "rwc")
unix.UnveilBlock()
// path traversal "../.ssh/id_ed25519" now fails even if the file exists
If the chat program in the opening example unveiled only the relevant directories β not ~/.ssh β the exfiltration exploit would have been mitigated.
Linux: seccomp BPF β Restricting Syscalls
Linux's Seccomp BPF lets each process supply a Berkeley Packet Filter program to the kernel deciding which syscalls are allowed, based on syscall number and (some) arguments. Fine-grained but hard to author: updating Go or dependencies changes which syscalls are used, and available syscalls differ by architecture.
The pure-Go, no-cgo package github.com/elastic/go-seccomp-bpf builds fine-grained filters. The author wrote github.com/oxzi/syscallset-go, which ports systemd's SystemCallFilter group syntax (e.g., @system-service, @basic-io) on top of go-seccomp-bpf, giving a string-based API similar to pledge. Unlike pledge there is no error group β every misstep kills the process.
syscallset.LimitTo("@system-service")
// ... read input ...
syscallset.LimitTo("@basic-io")
Linux: Landlock LSM β File System And Network Access
Landlock LSM is Linux's answer to unveil(2), and more β it recently grew network isolation. Library: github.com/landlock-lsm/go-landlock.
// Restrict filesystem access to ./Download
landlock.V5.BestEffort().RestrictPaths(landlock.RWDirs("Download"))
V5.BestEffort() falls back to what the target kernel supports (Landlock is versioned, growing with Linux releases). Network restriction currently allows/denies inbound and outbound TCP by port:
landlock.V5.BestEffort().RestrictNet(landlock.ConnectTCP(443))
// http (port 80) fails with permission denied; https (443) works
landlock.BindTCP restricts which TCP ports can be bound β useful if fearing an attacker launches a shell.
Is This All?
Not covered: Linux cgroups (a wide range of restrictions), FreeBSD's capsicum(4), cgroup eBPF-based network filtering. The main point: there are simple APIs to restrict privileges. OpenBSD ships simple APIs; for Linux the two Go libraries make big configurable features usable as a one-liner. Plus setrlimit(2) and the root-restricted chroot(2) / setresuid(2) dance.
Examples repository: https://codeberg.org/oxzi/go-privsep-showcase