# Dropping Privileges in Go: chroot, setrlimit, pledge/unveil, seccomp, Landlock

# 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](https://en.wikipedia.org/wiki/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](https://www.usenix.org/legacy/publications/library/proceedings/sec4/carson.html) 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](https://github.com/earthquake/chw00t)).

```go
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.

```go
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](https://en.wikipedia.org/wiki/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.

```c
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.

```go
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()`.

```go
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](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html) 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`](https://github.com/elastic/go-seccomp-bpf) builds fine-grained filters. The author wrote [`github.com/oxzi/syscallset-go`](https://github.com/oxzi/syscallset-go), which ports systemd's [`SystemCallFilter`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#System%20Call%20Filtering) 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.

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

## Linux: Landlock LSM — File System And Network Access

[Landlock LSM](https://docs.kernel.org/security/landlock.html) is Linux's answer to `unveil(2)`, and more — it recently grew network isolation. Library: [`github.com/landlock-lsm/go-landlock`](https://github.com/landlock-lsm/go-landlock).

```go
// 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:

```go
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](https://codeberg.org/oxzi/go-privsep-showcase)
