Audit a systemd Service With systemd-analyze security
Last edited on August 11, 2026

An OK result from systemd-analyze security is not proof that a daemon is safe, and an EXPOSED result is not proof that it has a vulnerability. The command measures how much a service uses systemd’s available isolation and privilege controls. It does not test the application’s code, its protocol, its data permissions, or whether the hardened workload still works.

Use the score as a prioritization and policy gate, then require a separate workload acceptance gate. This guide demonstrates both on a disposable loopback HTTP service: the baseline serves and writes state at exposure 8.2; an incompatible address-family restriction lowers exposure but prevents the listener; the corrected policy serves, writes state, and passes an exposure threshold at 1.7.

This guide assumes a Linux operator with root access on a systemd host. You should already know the target service’s listener, writable paths, child processes, devices and outbound dependencies. The reproduction needs Debian 13 or a comparable current Linux system, systemd, Python 3, curl, ss, and an unused TCP port. Run it first on an isolated host; root-access Linux VPS options provide operating-system control for that lab without turning a production daemon into the experiment.

Treat the Exposure Score as One Signal

In the official systemd-analyze manual, security analyzes service sandboxing and security settings. Red Hat’s systemd security walkthrough makes the essential boundary explicit: the score concerns protections systemd can enforce. A low number does not include an application’s input validation, TLS configuration, authentication or dependency vulnerabilities.

Start with identity, not edits. Save the installed unit and relevant runtime properties so the comparison survives package upgrades and later reviews.

set -Eeuo pipefail
SERVICE=voxfor-systemd-sandbox-lab.service
systemd-analyze --version | head -n 1
systemctl cat "$SERVICE" 2>/dev/null || true
systemctl show "$SERVICE" \
  -p FragmentPath -p DropInPaths -p User -p Group \
  -p DynamicUser -p StateDirectory -p RestrictAddressFamilies \
  2>/dev/null || true

For a real daemon, also record the package version, configuration hash, listener list, health endpoint, normal state change and any worker process. If a new drop-in causes repeated start failures, the systemd start-limit diagnosis explains how to separate the original process failure from systemd’s later restart throttle.

Freeze the Workload Contract Before Hardening

A service contract is a short list of behaviors that must remain true after each control. For this fixture the contract is deliberately small: bind 127.0.0.1:18080, answer /health with HTTP 200, and append a line beneath the managed StateDirectory. The negative test must prove that an AF_UNIX-only policy cannot satisfy the IPv4 contract.

Contract item Test Accept Reject
Listener ss or curl on 127.0.0.1:18080 HTTP 200 refused or timed out
Managed state requests.log under StateDirectory line count increases missing or unwritable
Unit state systemctl show active while tested failed or exited
Exposure policy systemd-analyze security --threshold=40 exit 0 nonzero exit

The score and the workload are independent decisions:

systemd hardening acceptance matrixA two by two matrix showing that a lower systemd exposure score is accepted only when the real workload also passes.REJECTLower scorebut service is brokenACCEPTLower scoreand workload passesREPAIR FIRSTHigh exposureand workload failsHARDEN NEXTHigh exposurebut workload passesWORKLOAD RESULT: FAILS → PASSESLOWEREXPOSUREHIGHEREXPOSURE
Accept only the lower-exposure quadrant that also passes the real workload.

SUSE’s systemd service hardening guide gives the right operational warning: a directive such as PrivateNetwork= can be valuable and still be categorically wrong for a network service. Write that incompatibility down before changing the unit.

Build a Guarded Disposable Baseline

For the exact reproduction, use a transient unit under /run/systemd/system, a unique application directory and systemd-managed state. The full fixture source is compact: a Python listener writes one line to $STATE_DIRECTORY/requests.log for every successful health request. Use one shell for all six input blocks so the guarded variables and exit trap remain active. The bootstrap refuses any pre-existing unit, path or port, and installs its exit trap only after those checks pass.

set -Eeuo pipefail
UNIT=/run/systemd/system/voxfor-systemd-sandbox-lab.service
DROPIN=/run/systemd/system/voxfor-systemd-sandbox-lab.service.d
APP=/opt/voxfor-systemd-sandbox-lab
STATE=/var/lib/voxfor-systemd-sandbox-lab
PRIVATE_STATE=/var/lib/private/voxfor-systemd-sandbox-lab

for path in "$UNIT" "$DROPIN" "$APP" "$STATE" "$PRIVATE_STATE"; do
  sudo test ! -e "$path" || { echo "Refusing $path" >&2; exit 3; }
done
ss -H -ltn "sport = :18080" | grep -q . && exit 4 || true

cleanup_systemd_lab() {
  sudo systemctl stop voxfor-systemd-sandbox-lab.service >/dev/null 2>&1 || true
  sudo systemctl reset-failed voxfor-systemd-sandbox-lab.service >/dev/null 2>&1 || true
  sudo rm -rf "$DROPIN"
  sudo rm -f "$UNIT"
  sudo systemctl daemon-reload >/dev/null 2>&1 || true
  sudo rm -rf "$APP" "$STATE" "$PRIVATE_STATE"
}
trap cleanup_systemd_lab EXIT

sudo install -d -m 0755 "$APP"

sudo tee "$APP/server.py" >/dev/null <<'PY'
#!/usr/bin/env python3
import json, os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

log_path = Path(os.environ["STATE_DIRECTORY"]) / "requests.log"
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/health":
            self.send_error(404); return
        with log_path.open("a", encoding="utf-8") as handle:
            handle.write(f"pid={os.getpid()} path={self.path}\n")
        body = json.dumps({"status":"ok", "state_writable":log_path.is_file()}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, _format, *_args):
        return
ThreadingHTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
PY
sudo chmod 0755 "$APP/server.py"

sudo tee "$UNIT" >/dev/null <<'UNIT'
[Unit]
Description=Voxfor disposable systemd sandbox audit
After=network.target

[Service]
Type=simple
DynamicUser=yes
StateDirectory=voxfor-systemd-sandbox-lab
ExecStart=/usr/bin/python3 /opt/voxfor-systemd-sandbox-lab/server.py
Restart=no
UNIT
sudo chmod 0644 "$UNIT"
sudo systemctl daemon-reload
sudo SYSTEMD_UNIT_PATH=/run/systemd/system:/usr/lib/systemd/system \
  systemd-analyze verify "$UNIT"

Even before the broader sandbox, the unit uses DynamicUser=yes and StateDirectory=. That gives the process an automatically managed writable location while avoiding an arbitrary permanent account. The systemd.exec reference documents how these directory settings interact with filesystem protection and dynamic users.

Run the baseline before judging any score. A successful systemctl start alone is insufficient; the command can return before a short-lived process exits.

sudo systemctl start voxfor-systemd-sandbox-lab.service
baseline_http=
for _ in {1..20}; do
  if baseline_http="$(curl --fail --silent --max-time 1 \
    http://127.0.0.1:18080/health)"; then break; fi
  sleep 0.1
done
test -n "$baseline_http"
printf '%s\n' "$baseline_http"
sudo test -s /var/lib/voxfor-systemd-sandbox-lab/requests.log
sudo systemd-analyze security --no-pager \
  voxfor-systemd-sandbox-lab.service | tail -n 1
sudo systemctl stop voxfor-systemd-sandbox-lab.service

Prove That a Lower Score Can Break the Service

The incompatible policy combines broadly useful controls with one deliberate contract violation: RestrictAddressFamilies=AF_UNIX. The service needs IPv4, so allowing only local Unix sockets must reject the listener. Other directives remove capabilities, block privilege gain, protect kernel and system paths, limit devices and namespaces, and filter system calls. They remain candidates, not universal defaults.

Install the incompatible drop-in, verify the merged unit, start it, then inspect both state and behavior. Expect the HTTP request to fail.

sudo install -d -m 0755 "$DROPIN"
sudo tee "$DROPIN/security.conf" >/dev/null <<'DROPIN'
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectClock=yes
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
DROPIN
sudo systemctl daemon-reload
sudo SYSTEMD_UNIT_PATH=/run/systemd/system:/usr/lib/systemd/system \
  systemd-analyze verify "$UNIT"
sudo systemctl start voxfor-systemd-sandbox-lab.service || true
sleep 0.5
sudo systemctl show voxfor-systemd-sandbox-lab.service \
  -p ActiveState -p SubState -p Result -p ExecMainStatus
! curl --fail --silent --max-time 1 http://127.0.0.1:18080/health
sudo systemctl reset-failed voxfor-systemd-sandbox-lab.service

This is the key audit result: an attractive score cannot overrule a failed workload. Do not remove the service’s network requirement merely to make a report greener.

Accept Only a Compatible Policy

Change only the proven mismatch. The accepted drop-in keeps the hardening set but allows the address families the loopback HTTP workload needs. AF_INET6 is retained because many real services bind both IP families; if your service is verified IPv4-only, test whether it can be omitted.

After that one-line correction, the positive gate combines unit verification, process state, HTTP behavior, managed-state mutation, effective-property inspection and the exposure threshold. For systemd’s threshold option, 40 represents 4.0; the command exits nonzero when the service exceeds the configured maximum exposure.

sudo sed -i \
  's/^RestrictAddressFamilies=.*/RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6/' \
  "$DROPIN/security.conf"
sudo systemctl daemon-reload
sudo SYSTEMD_UNIT_PATH=/run/systemd/system:/usr/lib/systemd/system \
  systemd-analyze verify "$UNIT"
sudo systemctl start voxfor-systemd-sandbox-lab.service
hardened_http=
for _ in {1..20}; do
  if hardened_http="$(curl --fail --silent --max-time 1 \
    http://127.0.0.1:18080/health)"; then break; fi
  sleep 0.1
done
test -n "$hardened_http"
printf '%s\n' "$hardened_http"
sudo test "$(wc -l </var/lib/voxfor-systemd-sandbox-lab/requests.log)" -ge 2
sudo systemctl show voxfor-systemd-sandbox-lab.service \
  -p ActiveState -p SubState -p RestrictAddressFamilies
sudo systemd-analyze security --threshold=40 --no-pager \
  voxfor-systemd-sandbox-lab.service >/dev/null

ArchWiki’s current systemd sandboxing reference cautions that a near-perfect score is realistic only for very simple services. Stop at the lowest exposure that preserves the declared contract. A JIT runtime may reject MemoryDenyWriteExecute=; a service that creates namespaces may reject RestrictNamespaces=; hardware or FUSE workloads may need devices or system calls that this tiny listener does not.

environment=Debian GNU/Linux 13 (trixie) systemd 257 (257.13-1~deb13u1)
baseline_exposure=8.2 EXPOSED
baseline_http={"status":"ok","state_writable":true}
incompatible_control=start_rc=0 state=exit-code,1,failed,failed ipv4_http=rejected
hardened_http={"status":"ok","state_writable":true}
effective_address_families=AF_INET AF_INET6 AF_UNIX
state_log_lines=2
hardened_exposure=1.7 OK
threshold_4_0=passed
cleanup=passed unit_path_absent app_path_absent state_path_absent

The audit passes only when the baseline serves and writes state, the AF_UNIX-only negative control cannot serve IPv4 and enters a failed result, the compatible unit is active, /health returns HTTP 200, the managed log reaches at least two lines, the effective address families are AF_UNIX AF_INET AF_INET6, systemd-analyze security --threshold=40 exits zero, and cleanup proves the transient unit, application and both public and private state paths are absent.

Move the Lab Result Into a Production Change

Do not replace a vendor unit with the lab file. Use systemctl edit your.service or a named drop-in under /etc/systemd/system/your.service.d/, back up an existing drop-in, and apply small control groups. Run systemd-analyze verify, the workload acceptance suite, systemctl show and the threshold after each group. A package upgrade can change the vendor unit; the controlled unattended-upgrades workflow shows why package state and reboot policy belong in the same maintenance evidence.

For a production service, extend the gate beyond one health request. Test authentication, one read, one durable write, dependency access, worker creation, restart, reload, log delivery and expected failure behavior. Preserve journalctl -u SERVICE, the old/new unit hashes, the systemd-analyze security output and the application-level receipt.

Systemd isolation is also not a virtual-machine boundary. Voxfor’s container and virtual-machine isolation comparison helps decide when a service needs a separate kernel or stronger recovery boundary instead of more unit directives.

Retain Security Evidence After the Window

Configured exposure is not runtime evidence. Pair the unit receipt with Linux Audit queue-loss diagnosis when a change requires evidence about privileged access or policy violations. Use AIDE baseline change control when the question is whether the unit or drop-in changed outside the approved deployment.

Keep one change record containing the service identity, package version, old and new unit hashes, controls added, exceptions retained, workload tests, score before and after, threshold, failure output, approver and rollback point. That receipt lets the next operator distinguish deliberate compatibility from forgotten exposure.

FAQ: Decisions Behind a systemd Security Score

What does systemd-analyze security actually measure?

It statically examines systemd unit security and sandboxing settings and assigns exposure to protections systemd can enforce. It does not scan application code, probe network behavior, validate authentication, or prove that dependencies are patched.

Is a lower systemd exposure score always better?

Only when the service still satisfies its workload contract. A lower score that blocks the listener, storage path, child process, JIT memory or required device is a failed change, not successful hardening.

Which systemd hardening directive should I add first?

Start from the service contract and remove privileges the process demonstrably does not need. Common candidates include NoNewPrivileges=, an empty CapabilityBoundingSet=, protected system paths and restricted address families, but each one requires a matching negative and positive workload test.

Why did systemctl start return zero when the service failed?

A start request can be accepted before the main process exits. Inspect ActiveState, SubState, Result and ExecMainStatus, then test the service endpoint or state transition that users actually depend on.

Can I use systemd-analyze security in CI?

Yes. --threshold= provides a machine-readable exit gate, while systemd-analyze verify checks unit syntax and references. CI still needs a runtime acceptance test because static configuration analysis cannot prove workload compatibility.

Should I copy a high-scoring unit from another service?

No. Two services can require different address families, capabilities, writable paths, devices, namespaces and system calls. Reuse review questions and test structure, not an unverified control set.

Does systemd sandboxing replace containers, SELinux or AppArmor?

They do not. These controls can complement one another, but they enforce different boundaries. Keep mandatory access control, network policy, application security, patching and stronger isolation decisions outside the exposure score.

Clean the Fixture or Restore the Approved Drop-in

Remove only the transient lab identities after confirming their exact paths. For a real service, restore the backed-up drop-in or remove only the new file, reload systemd, restart the prior unit, rerun the pre-change workload checks, and retain both failure and rollback receipts.

sudo test "/run/systemd/system/voxfor-systemd-sandbox-lab.service" = \
  "/run/systemd/system/voxfor-systemd-sandbox-lab.service"
cleanup_systemd_lab
trap - EXIT
sudo test ! -e /run/systemd/system/voxfor-systemd-sandbox-lab.service
sudo test ! -e /run/systemd/system/voxfor-systemd-sandbox-lab.service.d
sudo test ! -e /opt/voxfor-systemd-sandbox-lab
sudo test ! -e /var/lib/voxfor-systemd-sandbox-lab

The rollback boundary is the single named drop-in, not the vendor unit or all of /etc/systemd/system. Restore the mode-preserving backup with sudo install -m 0644 BACKUP /etc/systemd/system/SERVICE.d/security.conf, run sudo systemctl daemon-reload, restart SERVICE, and accept rollback only after the original listener, state-write, dependency and process checks pass. Keep the audit outputs; delete only the marker-bound disposable fixture.

A useful end state is not “the score is green.” It is the service has less systemd-mediated exposure, every declared behavior still works, the threshold is reproducible, and the operator can reverse exactly one approved change.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *