Service hardening should turn a specific unwanted operation into a denied operation while preserving the work the application must perform. systemd supplies execution settings for filesystem views, namespaces, privileges, and other resources. They are separate controls with separate limits. Adding four familiar directives does not make every readable file, reachable process, inherited descriptor, or allowed system call disappear.

When it breaks

ProtectSystem=strict makes most of the filesystem hierarchy read-only inside the service’s mount namespace. Read-only does not mean unreadable. The setting also has documented exceptions, including API filesystems that need separate controls. A service can still disclose information it can read, and writable directories remain places where it can damage data. Start with a dedicated service identity and an explicit inventory of required access.

PrivateTmp=yes gives the service private temporary directories. An application expecting another service to exchange files through the ordinary /tmp may consequently fail. PrivateNetwork=yes creates a network namespace with its own loopback interface; a listener on the host’s loopback is no longer that same endpoint. This is inappropriate for a program that must directly reach a database or public API unless its connectivity is intentionally arranged.

NoNewPrivileges=yes prevents execution from granting new privileges through mechanisms such as set-user-ID binaries and file capabilities. It does not remove privileges the process already has. Similarly, IPAddressAllow alone does not imply that all other addresses are denied: address filtering has allow/deny precedence and requires supported kernel functionality. Inspect each control’s contract instead of reading an option name as a complete security policy.

Check it quickly

Run this Python script as an administrator in a disposable Linux system with systemd as PID 1, Python, and the standard user-management utilities. The recorded environment was Debian 12 with systemd 252.39. It creates a uniquely named system account, runtime service, state directory, and harmless files, then removes them. Both phases use the same non-root identity and workload:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,shutil,socket,subprocess,uuid

suffix=uuid.uuid4().hex[:8];name='xbcatm-hardening-'+suffix
unit=name+'.service';unit_path=Path('/run/systemd/system')/unit
state=Path('/var/lib')/name;marker=Path('/tmp')/(name+'-marker');scratch=Path('/tmp')/(name+'-scratch')
assert not unit_path.exists() and not state.exists() and not marker.exists()
def ctl(*args):
    result=subprocess.run(['systemctl',*args],capture_output=True,text=True,timeout=10)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory, socket.socket() as listener:
    root=Path(directory);root.chmod(0o755);outside=root/'outside';outside.write_text('initial\n');outside.chmod(0o666)
    marker.write_text('host namespace marker\n');listener.bind(('127.0.0.1',0));listener.listen(2)
    worker=root/'worker.py'
    worker.write_text(r"""from pathlib import Path
import json,socket,sys
root=Path(__file__).parent;state=Path(sys.argv[1]);marker=Path(sys.argv[2]);scratch=Path(sys.argv[3]);port=int(sys.argv[4]);phase=sys.argv[5]
result={}
try:
    with (root/'outside').open('a') as output:output.write(phase+'\n')
    result['outside_write']=True
except OSError as error:result['outside_write']=False;result['outside_errno']=error.errno
result['host_tmp_visible']=marker.exists()
scratch.write_text(phase);result['tmp_write']=scratch.read_text()==phase
try:
    with socket.create_connection(('127.0.0.1',port),timeout=0.5):pass
    result['network_connect']=True
except OSError as error:result['network_connect']=False;result['network_errno']=error.errno
status=dict(line.split(':',1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line)
result['no_new_privs']=status['NoNewPrivs'].strip()
(state/'result.json').write_text(json.dumps(result))
""")
    definition=f'''[Service]
Type=oneshot
User={name}
Group={name}
StateDirectory={name}
StateDirectoryMode=0700
ExecStart=/usr/bin/python3 {worker} {state} {marker} {scratch} {listener.getsockname()[1]} PHASE
'''
    subprocess.run(['useradd','--system','--no-create-home','--user-group','--shell','/usr/sbin/nologin',name],check=True)
    try:
        unit_path.write_text(definition.replace('PHASE','baseline'))
        ctl('daemon-reload');ctl('start',unit)
        baseline=json.loads((state/'result.json').read_text())
        assert baseline=={'outside_write':True,'host_tmp_visible':True,'tmp_write':True,'network_connect':True,'no_new_privs':'0'},baseline
        connection,_=listener.accept();connection.close()
        original=outside.read_text()
        settings='ProtectSystem=strict\nPrivateTmp=yes\nPrivateNetwork=yes\nNoNewPrivileges=yes\n'
        unit_path.write_text(definition.replace('PHASE','hardened')+settings)
        subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True)
        ctl('daemon-reload');ctl('start',unit)
        hardened=json.loads((state/'result.json').read_text())
        assert hardened['outside_write'] is False and hardened['outside_errno']==30,hardened
        assert hardened['host_tmp_visible'] is False and hardened['tmp_write'] is True
        assert hardened['network_connect'] is False and hardened['network_errno']==111,hardened
        assert hardened['no_new_privs']=='1'
        assert outside.read_text()==original and scratch.read_text()=='baseline'
        print('baseline: outside write, host tmp visibility, loopback connection permitted; NoNewPrivs=0')
        print('hardened: outside write=EROFS; host tmp hidden; host listener refused; NoNewPrivs=1')
        print('managed state directory and private temporary writes still succeed')
    finally:
        ctl('stop',unit)
        if ctl('show',unit,'-p','ActiveState','--value')=='failed':ctl('reset-failed',unit)
        unit_path.unlink(missing_ok=True);ctl('daemon-reload')
        marker.unlink(missing_ok=True);scratch.unlink(missing_ok=True)
        if state.exists():shutil.rmtree(state)
        subprocess.run(['userdel',name],check=True)

The baseline intentionally makes one sample file writable by that account. It also creates a marker in the manager’s temporary-directory view and a listener on its loopback interface. Before restrictions, the worker appends to the file, sees the marker, connects to the listener, and reports a zero NoNewPrivs flag. This establishes that ordinary permissions or an absent listener did not already prevent the operations.

The hardened invocation adds the four execution settings and repeats the same probes. Its outside write fails with EROFS, rather than merely failing because of file ownership. The original temporary marker is absent from its view, and connecting to the original listener produces ECONNREFUSED. The process reports NoNewPrivs=1. These observations test actual process behavior, not only whether the unit parser accepted a spelling.

Useful writes still work: StateDirectory creates the service’s managed writable state location, and the private temporary directory accepts its own scratch file. The scratch file seen from the manager’s namespace still contains the baseline value, demonstrating that the second write occurred in a different temporary view. The outside sample also retains its previous contents.

The script checks these exact outcomes and validates the changed unit before activation. It does not test exploitation, syscall filtering, or BPF address policies. A successful configuration check is therefore supplemented by runtime evidence for the particular controls exercised, with no claim that every possible access path has been contained.

Keep required access narrow and visible

Apply restrictions to the actual deployed service definition, including its drop-ins and execution identity. A writable-path exception changes the mount view; it does not grant ordinary Unix permission or turn an inherently read-only filesystem writable. Prefer managed state, cache, or runtime directories when their lifetime and ownership match the application’s data.

After hardening, exercise startup, normal requests, maintenance, reload, and shutdown where the application supports them. A command that works once may still fail later when it rotates a file or opens a new connection. Inspect the specific denial and adjust the necessary boundary. Retain an operational check for useful service behavior alongside the evidence that the unwanted operation remains blocked.

Further reading

Sources