# Stepping Up Guix System Security

Source: https://gofranz.com/blog/stepping-up-guix-system-security/

I spent a couple of weeks reading up on what Linux distributions do at runtime to contain an attack: not packaging or CVE cadence, just what the running system does once something goes wrong. Guix System doesn't do much. `%default-sysctl-settings` is two entries (`fs.protected_hardlinks` and `fs.protected_symlinks`, that's it). Neither `%base-services` nor `%desktop-services` includes a firewall. Thirteen programs are setuid or capability-privileged out of the box, and most Shepherd services run as root.

Guix does make fixing this cheap: the whole system configuration is one Scheme file in git, and `guix system roll-back` takes seconds when a change breaks something. Over the last months I worked through the list, roughly in order of effort. This is what stuck, written so it applies to any Guix System install. Nothing here needs a custom kernel.

If you're on a stock `linux-libre` kernel, one caveat up front: it ships no CPU microcode, and half of the Spectre-class mitigations live in microcode. On an affected part, `/sys/devices/system/cpu/vulnerabilities/*` will read "Vulnerable" no matter what you set below. The fix is the `nonguix` channel (kernel plus `microcode-initrd`), which is an unofficial channel with no security team of its own. I use it; decide for yourself.

### sysctl

This block extends the two defaults rather than replacing them:

```scheme
(use-modules (gnu services sysctl))

(modify-services %desktop-services
  (sysctl-service-type config =>
    (sysctl-configuration
     (inherit config)
     (settings
      (append (sysctl-configuration-settings config)
              '(("kernel.dmesg_restrict" . "1")
                ("kernel.kptr_restrict" . "2")
                ("kernel.yama.ptrace_scope" . "1")
                ("kernel.kexec_load_disabled" . "1")
                ("kernel.unprivileged_bpf_disabled" . "1")
                ("net.core.bpf_jit_harden" . "2")
                ("kernel.perf_event_paranoid" . "3")
                ("kernel.io_uring_disabled" . "2")
                ("kernel.sysrq" . "0")
                ("dev.tty.ldisc_autoload" . "0")
                ("fs.protected_fifos" . "2")
                ("fs.protected_regular" . "2")
                ;; Network
                ("net.ipv4.conf.all.rp_filter" . "1")
                ("net.ipv4.conf.default.rp_filter" . "1")
                ("net.ipv4.conf.all.accept_redirects" . "0")
                ("net.ipv4.conf.default.accept_redirects" . "0")
                ("net.ipv6.conf.all.accept_redirects" . "0")
                ("net.ipv6.conf.default.accept_redirects" . "0")
                ("net.ipv4.conf.all.send_redirects" . "0")
                ("net.ipv4.conf.default.send_redirects" . "0")
                ("net.ipv4.conf.all.accept_source_route" . "0")
                ("net.ipv4.conf.default.accept_source_route" . "0")
                ("net.ipv6.conf.all.accept_source_route" . "0")
                ("net.ipv4.tcp_syncookies" . "1")
                ("net.ipv4.icmp_echo_ignore_broadcasts" . "1")))))))
```

Two of these are worth explaining.

**`kernel.io_uring_disabled=2`** has the best evidence behind it. [Google's kCTF numbers](https://security.googleblog.com/2023/06/learnings-from-kctf-vrps-42-linux.html) for the year to mid-2023: 60% of exploit submissions went through io_uring, and it featured in every submission that bypassed their mitigations. The sysctl has existed since 6.6; `2` means `io_uring_setup` fails with `EPERM` for everyone. On a desktop the only thing I found using it was libuv (Node, Electron), which falls back to its threadpool on that error. Recent PostgreSQL and QEMU builds can use it; if you run those natively, `1` plus `kernel.io_uring_group` is the softer setting.

**`kernel.yama.ptrace_scope=1`** is the one you'll notice: `gdb -p` and `strace -p` on a process that isn't your descendant need `sudo` from now on. Chromium, Firefox and Wine already call `prctl(PR_SET_PTRACER)` for their own crash handlers, so they keep working.

Left out on purpose: anything that disables unprivileged user namespaces. On Guix that breaks the unprivileged daemon (see below).

### Kernel arguments

The rest goes on the kernel command line. Keep `%default-kernel-arguments` at the end or you lose Guix's module blacklist:

```scheme
(kernel-arguments
 (cons* "slab_nomerge"
        "randomize_kstack_offset=on"
        "page_alloc.shuffle=1"
        "init_on_alloc=1"
        "proc_mem.force_override=never"
        %default-kernel-arguments))
```

`init_on_alloc=1` is the only one with a measurable cost: upstream measured +7.75% sys time on hackbench and -0.13% wall on a kernel build, so it depends entirely on what you do. I left `init_on_free=1` out - +8.38% wall and +24.42% sys on a kernel build, and Kees Cook's own note is that catching a write between free and alloc is "pretty rare". Kicksecure and secureblue run both; no mainstream distro does.

A few things you might expect here are already handled by the kernel config Guix ships, so there's nothing to add: `vsyscall=none` (`CONFIG_LEGACY_VSYSCALL_NONE`), `TIOCSTI` compiled out, `SLAB_FREELIST_RANDOM` and `_HARDENED`, `FORTIFY_SOURCE`, `HARDENED_USERCOPY`, `STRICT_DEVMEM`. Check your own with `zcat /proc/config.gz` or the `.config` next to `bzImage` in the store, because the same file also tells you what's *not* there: in the 7.2 config I looked at, `CONFIG_SECURITY_LOCKDOWN_LSM` and `CONFIG_MODULE_SIG` are both unset. So `lockdown=integrity` is silently ignored, and you'd need `customize-linux` and a local kernel build to get it.

### A firewall

Guix's example ruleset for `iptables-service-type` is called `%iptables-accept-all-rules`, and it does exactly that. Here's a default-deny replacement. Open what you serve; I've left SSH in as the obvious example:

```scheme
(use-modules (gnu services networking))

(define %firewall-ipv4
  (plain-file "iptables.rules" "*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
COMMIT
"))

;; NDP and MLD are untracked by conntrack, so the ESTABLISHED,RELATED
;; accept never matches them. Without the ICMPv6 lines below, IPv6
;; simply never comes up - no neighbour ever resolves.
(define %firewall-ipv6
  (plain-file "ip6tables.rules" "*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 1 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 2 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 3 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 4 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 130 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 134 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 135 -j ACCEPT
-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type 136 -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
COMMIT
"))

(service iptables-service-type
         (iptables-configuration
          (ipv4-rules %firewall-ipv4)
          (ipv6-rules %firewall-ipv6)))
```

The ICMPv6 types are the part of [RFC 4890](https://datatracker.ietf.org/doc/html/rfc4890)'s must-not-drop list a host actually receives: the four error types, MLD queries, router advertisements and neighbour solicitation/advertisement. Echo and redirect are left out on purpose (`accept_redirects=0` above). I found the conntrack gap on a v6 network that looked configured and moved zero packets.

### Fewer privileged programs

This is where Guix is ahead of other distros: the setuid list is a field in your config, not something you discover with `find / -perm -4000`. The default is thirteen entries: `ping`, `ping6`, `passwd`, `chfn`, `sg`, `su`, `newgrp`, `newuidmap`, `newgidmap`, `sudo`, `sudoedit`, `fusermount`, `fusermount3`. `ping` and `ping6` are already capability-based rather than setuid, which is the right model.

I never use `chfn`, `sg`, `newgrp` or `su` (sudo covers it), and nothing mounts fuse2 here, so:

```scheme
(use-modules (gnu system privilege)
             (gnu system setuid)
             (gnu packages admin)       ;; sudo, shadow, inetutils
             (gnu packages linux))      ;; fuse

(privileged-programs
 (cons* (privileged-program
         (program (file-append inetutils "/bin/ping"))
         (capabilities "cap_net_raw=ep"))
        (privileged-program
         (program (file-append inetutils "/bin/ping6"))
         (capabilities "cap_net_raw=ep"))
        (map file-like->setuid-program
             (list (file-append shadow "/bin/passwd")
                   (file-append shadow "/bin/newuidmap")
                   (file-append shadow "/bin/newgidmap")
                   (file-append sudo "/bin/sudo")
                   (file-append sudo "/bin/sudoedit")
                   (file-append fuse "/bin/fusermount3")))))
```

`newuidmap` and `newgidmap` stay if you run rootless Podman; it writes the subuid maps through them. Drop them too if you don't.

`%desktop-services` adds more. `mount.nfs` and `mount.ntfs-3g` arrive setuid through a `simple-service` named `mount-setuid-helpers`, and because a `simple-service` wraps the target type in a fresh one, `modify-services` can't delete it by type - filter by name instead:

```scheme
(define %desktop-services-trimmed
  (filter (lambda (service)
            (not (eq? 'mount-setuid-helpers
                      (service-type-name (service-kind service)))))
          %desktop-services))
```

`slock` and `xlock` come setuid-root via `screen-locker-service-type` as well. If you're on Wayland with `swaylock`, delete both and declare your own locker with `(using-setuid? #f)` and `(using-pam? #t)`.

Four more you can't remove: `unix_chkpwd` (PAM), `dbus-daemon-launch-helper`, and polkit's `polkit-agent-helper-1` and `pkexec`. The last one comes bundled with polkit's extension, and `polkit-service-type` has no option to drop it.

### Run guix-daemon unprivileged

Since 1.5, `guix-daemon` can run as its own user instead of root, with per-build UID isolation done through user namespaces instead of the `guixbuilder01..10` pool:

```scheme
(guix-service-type config =>
  (guix-configuration
   (inherit config)
   (privileged? #f)))
```

Every daemon-side root escalation of the last two years - CVE-2024-27297, CVE-2024-52867, the 2026 substitute bugs - lands in a plain user account instead. That's a structural fix for a whole class of bugs, and it's one line.

Three consequences, none of them obvious from the docs:

- **The first reconfigure chowns the entire store.** On my ~180 GB store that's a blocking walk over ~90k items. Do it when you have time.
- **Store files are now owned by `guix-daemon`, not root.** Anything that insists on root-owned config - `auditd`, `auditctl`, `pam_u2f`'s authfile - will reject a file you deploy through `etc-service-type`, because that's a symlink into the store. Copy such files out with an activation service and `chown` them (example below).
- **Unprivileged user namespaces must stay on.** No `user.max_user_namespaces=0`, no AppArmor userns restriction. That's the one headline hardening move of the last few years (Ubuntu's 24.04 default; ~44% of observed kernel exploit chains reportedly need userns) that's off the table here, because the daemon uses exactly that mechanism for build isolation. I left a comment in the sysctl block so I don't add it back by accident.

### Delete services you don't use

The cheapest attack-surface reduction is not running things. From `%desktop-services`, on a laptop:

```scheme
(modify-services %desktop-services-trimmed
  ;; Hands browsers WiFi-derived street-level location via beacondb.net.
  (delete geoclue-service-type)
  ;; Announces the hostname on every LAN; nsswitch has no mdns anyway.
  (delete avahi-service-type)
  ;; Sends AT commands to any USB serial device; runs as root on insert.
  (delete modem-manager-service-type)
  (delete usb-modeswitch-service-type)
  ;; Replaced by chrony with NTS - authenticated time.
  (delete ntp-service-type))
```

NetworkManager depends on none of them. Check with `herd status` after a reboot what's still running as root, and whether you need each one.

### auditd

`auditd-service-type` is upstream. The value on a single-user machine is a log of who read your keys, and of every `execve` as root by a logged-in user (daemons carry `auid -1`, so they're excluded):

```
-w /home/you/.ssh -p rwa -k sensitive-files
-w /home/you/.gnupg -p rwa -k sensitive-files
-w /home/you/.password-store -p rwa -k sensitive-files
-w /etc/sudoers -p wa -k privilege
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k root-exec
-a always,exit -F arch=b32 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k root-exec
-e 2
```

`-e 2` locks the rules until reboot, so a root compromise can't quietly drop a watch. It also means rule changes land at the next boot, not on reconfigure. Note `-w <dir>` is a recursive subtree watch; point it at secrets, not at a state directory with a 300 MB index in it.

Two traps, both caused by the unprivileged daemon above. `auditd` refuses a config file not owned by uid 0, so copy it out of the store:

```scheme
(simple-service 'auditd-config activation-service-type
  #~(begin
      (mkdir-p "/etc/audit")
      (chown "/etc/audit" 0 0)
      (chmod "/etc/audit" #o700)
      (mkdir-p "/var/log/audit")
      (for-each (lambda (source target)
                  (false-if-exception (delete-file target))
                  (copy-file source target)
                  (chown target 0 0)
                  (chmod target #o600))
                (list #$%auditd-conf #$%audit-rules)
                (list "/etc/audit/auditd.conf" "/etc/audit/audit.rules"))))

(service auditd-service-type
         (auditd-configuration
          (configuration-directory "/etc/audit")))
```

And log into `/var/log/audit/`, not `/var/log/audit.log` directly: `auditd` chmods the log file's parent directory to `0700` on every start and rotation. Pointed at `/var/log` it locks the whole directory, and the unprivileged daemon can no longer write its build logs under `/var/log/guix`.

The service doesn't load rules itself; a one-shot Shepherd service running `auditctl -R /etc/audit/audit.rules` after `auditd` does. Once locked, `auditctl -s` reports `enabled 2` and a reload is refused, so treat that as success or your reconfigure fails every time.

### USBGuard

Not the biggest risk for most people. RHEL ships it disabled; it's the one piece from there I wanted. The approach has two halves. The kernel argument `usbcore.authorized_default=0` makes every USB device start unauthorized, closing the window between boot and the daemon coming up. USBGuard then authorizes devices matching a rule and leaves everything else blocked:

```
$ usbguard list-devices --blocked
$ usbguard allow-device -p <id>
```

Upstream Guix packages `usbguard` but has no service for it. I wrote [one](https://codeberg.org/gofranz/panther/src/branch/master/px/services/usbguard.scm) in my [panther channel](https://codeberg.org/gofranz/panther); if you'd rather not pull a channel for it, the service is small: a Shepherd service running `usbguard-daemon` with a config that sets `ImplicitPolicyTarget=block`, `PresentDevicePolicy=apply-policy`, `AuthorizedDefault=none`, and an IPC group so your user can run the CLI without sudo.

Two warnings. **Check what your keyboard is.** On my laptop it's on i8042, so the LUKS prompt and greeter work with no USB at all. If yours is internal USB, `authorized_default=0` locks you out at the disk prompt. And this guards against unknown hardware, not against a process already running as you - anyone in the IPC group can allow a device.

### AppArmor, for one app at a time

Guix has no MAC service of any kind. The kernel side is there, though: `linux-libre` is built with AppArmor and Landlock, but its `CONFIG_LSM` string leaves Landlock out and puts SELinux, Smack and TOMOYO in (none of which you can use). So you need an explicit `lsm=` on the command line, an `apparmor` userspace, and something that mounts `securityfs` and runs `apparmor_parser -r` on your profiles at boot. Again, the [service](https://codeberg.org/gofranz/panther/src/branch/master/px/services/apparmor.scm) lives in panther; the same channel carries `apparmor` 5.0.2, because a profile that denies a unix socket by path needs `network_v9` policy, which the 4.1.2 in Guix can't emit.

```scheme
"lsm=landlock,yama,apparmor"
```

The realistic use isn't confining your whole session (Fedora doesn't either; your desktop is `unconfined_t` there too). It's containing the one app you trust least. Mine is an Electron chat client that I'd rather not have talking to NetworkManager and BlueZ for location-revealing scan results:

```
abi <abi/5.0>,
include <tunables/global>

profile chat /gnu/store/*-chat-desktop-*/{bin/chat,lib/chat/chat} {
  include <abstractions/base>

  userns,        # Electron's Chromium sandbox needs it
  file,
  capability,
  network,

  # The session bus at /run/user/*/bus stays open: tray, notifications
  # and portals keep working. Only the system bus is off limits.
  audit deny /run/dbus/system_bus_socket rw,
  audit deny /var/run/dbus/system_bus_socket rw,
  audit deny network netlink,
}
```

The store path changes on every upgrade, hence the glob. Two gotchas: `deny` rules are inert under `flags=(complain)`, contrary to what `aa-complain(8)` suggests, so this has to run in enforce mode from day one. And without the `abi <abi/5.0>` line the socket deny stops `open()` but not `connect()`, so the rule looks like it works while the bus stays reachable. Denials show up in the same audit log as everything above: `ausearch -m AVC`.

To be clear about what this buys: MAC contains userspace lateral movement. It does nothing against a kernel exploit (Dirty COW ran straight through SELinux). It's worth having; it's not a guarantee.

### Integrity, the Guix way

For the `/usr` equivalent you don't need AIDE at all: `guix gc --verify=contents` rehashes the store against names the attacker can't rewrite without also breaking every path. I still run AIDE daily, but only over `/etc`, `/var` and `/boot` - the parts that aren't hash-addressed - via `mcron-service-type`, and Lynis weekly for the "did I regress something" check.

### What I left out, and why

- **Disabling unprivileged user namespaces.** Breaks the unprivileged daemon. See above.
- **`lockdown=integrity` and module signing.** Not compiled into Guix's kernel. Also, lockdown refuses hibernation the kernel didn't encrypt itself, and I hibernate to a swapfile inside LUKS.
- **Secure Boot.** No signing path for Guix's GRUB. With LUKS the kernel and initrd already live inside the encrypted root, so the exposed piece is `grub.efi` on the ESP, and that's an evil-maid problem I accept.
- **hardened_malloc.** Kicksecure, whose ecosystem produced it, stopped enabling it by default: it breaks Firefox, Chromium and Electron, and cryptsetup runs ~6x slower. No published overhead number exists.
- **`nosmt`, `init_on_free`.** On a current AMD part every entry in `/sys/devices/system/cpu/vulnerabilities` reads "Not affected" or "Mitigation". SMT off costs about 15% throughput, with nothing left to mitigate.
- **`noexec /tmp`.** `/dev/shm` is mounted exec, and one writable-plus-executable mount makes the other nearly moot. Also, `guix-daemon` builds under `$TMPDIR`, so you'd have to move that first.
- **SELinux.** Not in the kernel config, no service, and a labelling model that doesn't fit an immutable store.

### Where this lands

None of this is exotic; most of it is what Ubuntu or Arch already do out of the box, written by hand because Guix doesn't ship it. The parts that are better than mainstream - a setuid list you can read, a build daemon that isn't root, a store you can verify - come from how Guix is built. The parts that are worse - no MAC service, no firewall, two sysctls - are a few hundred lines in one file.

And the file is in git. The last time a change here broke something (that `auditd` chmod), I was back on the previous generation in under a minute.
