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.
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.
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:
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.