Stepping Up Guix System Security
Guix System ships two sysctl entries, no firewall and thirteen setuid binaries. Here's what I added on top, and what I left out.
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:
(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 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:
(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:
(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’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:
(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:
(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:
(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 throughetc-service-type, because that’s a symlink into the store. Copy such files out with an activation service andchownthem (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:
(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:
(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 in my panther channel; 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 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.
"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=integrityand 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.efion 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/vulnerabilitiesreads “Not affected” or “Mitigation”. SMT off costs about 15% throughput, with nothing left to mitigate.noexec /tmp./dev/shmis mounted exec, and one writable-plus-executable mount makes the other nearly moot. Also,guix-daemonbuilds 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.