{"id":365,"date":"2026-10-08T07:22:09","date_gmt":"2026-10-08T14:22:09","guid":{"rendered":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/08\/least-privilege-with-systemd-sandboxing\/"},"modified":"2026-10-20T20:23:12","modified_gmt":"2026-10-21T03:23:12","slug":"least-privilege-with-systemd-sandboxing","status":"publish","type":"post","link":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/08\/least-privilege-with-systemd-sandboxing\/","title":{"rendered":"Least privilege with systemd sandboxing"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><em>Part 2 of 4 in the Hardening a Linux server series. Previously: <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/07\/ssh-hardening-beyond-disable-root-login\/\">Part 1, SSH hardening beyond &#8220;disable root login&#8221;<\/a>.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Most services on a Linux box run with far more access than they need. A web app that reads a config file, writes to one data directory and listens on one port can, by default, read most of the filesystem, write anywhere its user owns, call every syscall the kernel offers and open any kind of socket. When that app gets popped through a deserialization bug, all of that becomes the attacker&#8217;s starting position.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">systemd has grown a large set of sandboxing directives that fix this with a few lines in a unit file. No containers, no AppArmor profiles to maintain, no code changes. I think they&#8217;re the best return on effort in Linux hardening right now, and they&#8217;re still rarely used outside of the units systemd itself ships.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Measure first<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"640\" src=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/least-privilege-with-systemd-sandboxing-diagram.png\" alt=\"Bar chart: measured systemd-analyze security exposure score falling from 9.2 at baseline to 1.0 as filesystem, kernel, capability, syscall and network restrictions are added\" class=\"wp-image-363\" srcset=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/least-privilege-with-systemd-sandboxing-diagram.png 1200w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/least-privilege-with-systemd-sandboxing-diagram-300x160.png 300w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/least-privilege-with-systemd-sandboxing-diagram-1024x546.png 1024w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/least-privilege-with-systemd-sandboxing-diagram-768x410.png 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><code>systemd-analyze security<\/code> scores every running service from 0 (locked down) to 10 (wide open) and lists each check it applied:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>systemd-analyze security\nsystemd-analyze security nginx.service<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The number is a heuristic, a weighted sum of which directives are set, so don&#8217;t treat 2.1 versus 2.4 as meaningful. It is very good at showing you the gaps. On most distributions the bulk of third-party services score 9 or above.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Measured: one unit, hardened step by step<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I took a trivial unit (<code>python3 -m http.server<\/code> running as a dedicated user) and added directives in groups, scoring each version with <code>systemd-analyze security<\/code> on systemd 249. The chart near the top of this post shows the result. The baseline scored 9.2. The final unit scored 1.0.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is roughly what that final unit looks like, adjusted for a real service:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[Service]\nExecStart=\/opt\/webapp\/bin\/webapp\nDynamicUser=yes\nStateDirectory=webapp\nConfigurationDirectory=webapp\n\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\nPrivateDevices=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\nProtectClock=yes\nProtectHostname=yes\nProtectProc=invisible\n\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n\nSystemCallArchitectures=native\nSystemCallFilter=@system-service\nSystemCallFilter=~@privileged @resources\nSystemCallErrorNumber=EPERM\n\nRestrictAddressFamilies=AF_UNIX AF_INET  \"AF_INET6\nRestrictNamespaces=yes\nRestrictSUIDSGID=yes\nRestrictRealtime=yes\nLockPersonality=yes\nMemoryDenyWriteExecute=yes\nUMask\" =0077<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That looks like a lot. Each line is cheap, though, and they fall into a few families.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Privilege escalation inside the service<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>NoNewPrivileges=yes<\/code> sets the kernel&#8217;s <code>no_new_privs<\/code> flag, so nothing the service executes can gain privileges through setuid binaries or file capabilities. An attacker with code execution as the service user can&#8217;t then run a vulnerable setuid helper to become root. Several other directives quietly turn it on as well, which is fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Capabilities are the other half. <code>CapabilityBoundingSet=<\/code> with an empty value drops every capability for good. The example keeps exactly one, <code>CAP_NET_BIND_SERVICE<\/code>, and grants it as an ambient capability so a non-root process can bind port 443. That is the right shape for most daemons that historically started as root just to grab a low port.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>DynamicUser=yes<\/code> allocates a throwaway UID when the service starts. It implies a stricter set of defaults (read-only system, private <code>\/tmp<\/code>, no setuid), and combined with <code>StateDirectory=<\/code> systemd creates and chowns <code>\/var\/lib\/webapp<\/code> for you. If the service doesn&#8217;t need a stable UID shared with other software, use it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Filesystem view<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ProtectSystem=strict<\/code> mounts the entire filesystem read-only for the service, apart from the API filesystems like <code>\/proc<\/code> and <code>\/sys<\/code>. Writable paths are then opt-in through <code>StateDirectory=<\/code>, <code>CacheDirectory=<\/code>, <code>LogsDirectory=<\/code> or <code>ReadWritePaths=<\/code>. This is the directive that stops an attacker dropping a cron job, a systemd unit or a modified binary for persistence. <code>ProtectHome=yes<\/code> hides <code>\/home<\/code>, <code>\/root<\/code> and <code>\/run\/user<\/code> entirely, which matters more than people think, since home directories are where SSH keys and cloud credentials live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>PrivateTmp=yes<\/code> gives the service its own <code>\/tmp<\/code>, which closes off the classic symlink and race games other local users can play in a shared <code>\/tmp<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Kernel attack surface<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A sandbox that lives in userspace can&#8217;t save you from a kernel bug, but it can make most kernel bugs unreachable. <code>SystemCallFilter=@system-service<\/code> is an allowlist of syscalls that ordinary services use, curated by systemd. The second line then removes <code>@privileged<\/code> and <code>@resources<\/code>. By default a blocked syscall kills the process with <code>SIGSYS<\/code>; <code>SystemCallErrorNumber=EPERM<\/code> makes it fail with an error instead, which is friendlier while you&#8217;re tuning.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>RestrictNamespaces=yes<\/code> deserves a specific mention. A good number of recent Linux privilege escalation bugs are only reachable by unprivileged users through user namespaces, because creating one hands you capabilities inside it. CVE-2022-0185, a heap overflow in the filesystem context code, is the textbook case: exploitation needed <code>CAP_SYS_ADMIN<\/code> in a user namespace, which any unprivileged user could get on distributions that allowed unprivileged user namespaces. A service that can&#8217;t create namespaces can&#8217;t reach that code path at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>PrivateDevices=yes<\/code>, <code>ProtectKernelModules=yes<\/code> and <code>ProtectKernelTunables=yes<\/code> take away raw device nodes, module loading and writes to <code>\/proc\/sys<\/code>. <code>SystemCallArchitectures=native<\/code> blocks the 32-bit syscall ABI on 64-bit hosts, a path that has been used to dodge seccomp filters written for one ABI only.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Network<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>RestrictAddressFamilies=<\/code> limits which socket types the service may open. Most need <code>AF_UNIX<\/code>, <code>AF_INET<\/code> and <code>AF_INET6<\/code>, nothing else, and certainly not <code>AF_PACKET<\/code> or <code>AF_NETLINK<\/code>. If you want per-service egress control, <code>IPAddressDeny=any<\/code> with a narrow <code>IPAddressAllow=<\/code> uses a cgroup BPF filter to drop traffic to anything else. A compromised internal API that can&#8217;t reach the internet is a lot less useful as a staging point.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Rolling it out without breaking things<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t edit vendor units. Use <code>systemctl edit webapp.service<\/code> to write a drop-in under <code>\/etc\/systemd\/system\/webapp.service.d\/<\/code>, which survives package upgrades. You can try options on a throwaway command before committing to them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemd-run --pty --wait -p ProtectSystem=strict -p PrivateTmp=yes \\\n  -p SystemCallFilter=@system-service \/opt\/webapp\/bin\/webapp --selftest<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The usual breakage is predictable. <code>MemoryDenyWriteExecute=yes<\/code> breaks anything with a JIT, so Node, the JVM and PCRE&#8217;s JIT mode will fail in confusing ways. <code>ProtectSystem=strict<\/code> breaks services that write somewhere surprising, which the journal will show as <code>EROFS<\/code> errors. Syscall filters occasionally catch a library doing something unusual, and with <code>SystemCallErrorNumber=EPERM<\/code> the audit log or <code>strace -f<\/code> tells you which call. Add one family at a time and run your integration tests between each.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This doesn&#8217;t replace patching or a proper MAC policy, and it isn&#8217;t a VM boundary. What it does is shrink the blast radius of the next RCE in your app from &#8220;the box&#8221; to &#8220;one directory and a socket&#8221;. Part 3 covers auditd, so that when something does get through you can see what it tried to do.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Other parts of the Hardening a Linux server series: <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/07\/ssh-hardening-beyond-disable-root-login\/\">Part 1: SSH hardening beyond &#8220;disable root login&#8221;<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/09\/auditd-what-to-log-for-investigations\/\">Part 3: auditd and what to log<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/12\/patching-unattended-upgrades-live-patching-reboots\/\">Part 4: Patching without fear<\/a>.<\/em><\/p>\n\n","protected":false},"excerpt":{"rendered":"<p>Part 2 of 4 in the Hardening a Linux server series. Previously: Part 1, SSH hardening beyond &#8220;disable root login&#8221;. Most services on a Linux box run with far more access than they need. A web app that reads a config file, writes to one data directory and listens on one port can, by default, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":367,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[22,21],"tags":[88,95,94,96,93],"class_list":["post-365","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux","category-security","tag-hardening-a-linux-server-series","tag-least-privilege","tag-sandboxing","tag-seccomp","tag-systemd"],"_links":{"self":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/365","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/comments?post=365"}],"version-history":[{"count":1,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/365\/revisions"}],"predecessor-version":[{"id":393,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/365\/revisions\/393"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media\/367"}],"wp:attachment":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media?parent=365"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/categories?post=365"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/tags?post=365"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}