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
This fixture requires root in a disposable Linux system with systemd as PID 1, working mount/network namespaces, Python 3.8 or later at /usr/bin/python3, and user-management utilities. Keep Python assertions enabled; the generated worker must be readable by the service account. Earlier records report Debian 12 with systemd 252.39; that run has not been reproduced here. It creates a uniquely named account, runtime unit, state directory and files. Both phases use the same non-root workload:
from pathlib import Path
from tempfile import mkdtemp
import grp,json,os,pwd,socket,subprocess,sys,uuid
def cleanup_steps(steps, primary):
failures=[]
for label, action in steps:
try:action()
except BaseException as error:
failures.append(label+': '+type(error).__name__+': '+str(error))
if failures:
message='cleanup incomplete or uncertain: '+'; '.join(failures)
try:print(message,file=sys.stderr)
except (OSError,ValueError):pass
if primary is None:raise RuntimeError(message)
return failures
def remove_file(path):
try:path.unlink()
except FileNotFoundError:pass
def require_empty(value):
if value:raise RuntimeError('residual resources: '+str(value))
suffix=uuid.uuid4().hex[:12];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 os.geteuid()==0,'requires an explicitly prepared disposable systemd test system'
for path in [unit_path,state,marker,scratch]:assert not os.path.lexists(path),path
def account_exists():
try:pwd.getpwnam(name);return True
except KeyError:return False
def group_exists():
try:grp.getgrnam(name);return True
except KeyError:return False
assert not account_exists() and not group_exists()
def command(args):
result=subprocess.run(args,capture_output=True,text=True,timeout=10)
assert result.returncode==0,(args,result.stderr)
return result.stdout.strip()
def ctl(*args):return command(['systemctl',*args])
def delete_account():
if account_exists():command(['userdel',name])
def delete_group():
if group_exists():command(['groupdel',name])
root=None;listener=connection=None;owned_files=[];account_attempted=False;unit_attempted=False
# Reserve each ordinary file exclusively and record it before writing or chmod.
def create_file(path,text,mode):
with path.open('x') as output:
owned_files.append(path)
output.write(text)
path.chmod(mode)
print('fixture:',name,'unit:',unit_path,'state:',state,'marker:',marker,'scratch:',scratch,flush=True)
try:
root=Path(mkdtemp(prefix=name+'-',dir='/run'));root.chmod(0o755)
outside=root/'outside';create_file(outside,'initial\n',0o666)
create_file(marker,'host namespace marker\n',0o644)
create_file(scratch,'',0o666)
listener=socket.socket();listener.settimeout(2)
listener.bind(('127.0.0.1',0));listener.listen(2)
worker=root/'worker.py'
create_file(worker,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))
""",0o644)
definition=f'''[Service]
Type=oneshot
User={name}
Group={name}
StateDirectory={name}
StateDirectoryMode=0700
TimeoutStartSec=5s
TimeoutStopSec=5s
KillMode=control-group
ExecStart=/usr/bin/python3 {worker} {state} {marker} {scratch} {listener.getsockname()[1]} PHASE
'''
account_attempted=True
command(['useradd','--system','--no-create-home','--user-group','--shell','/usr/sbin/nologin',name])
create_file(unit_path,definition.replace('PHASE','baseline'),0o644)
unit_attempted=True
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();connection=None
original=outside.read_text()
settings='ProtectSystem=strict\nPrivateTmp=yes\nPrivateNetwork=yes\nNoNewPrivileges=yes\n'
unit_path.write_text(definition.replace('PHASE','hardened')+settings)
command(['systemd-analyze','verify',str(unit_path)])
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:
primary=sys.exc_info()[1];failures=[];quiescent=not unit_attempted
# A failed stop does not skip the independently attempted state query or socket close.
def attempt(label,action):
try:return action()
except BaseException as error:
failures.append(label+': '+type(error).__name__+': '+str(error))
if unit_attempted:
attempt('stop '+unit,lambda:ctl('stop',unit))
status=attempt('unit state',lambda:ctl('show',unit,'-p','ActiveState','-p','MainPID','-p','ControlPID'))
if status is not None:
properties=dict(line.split('=',1) for line in status.splitlines() if '=' in line)
quiescent=properties.get('ActiveState') in ('inactive','failed') and properties.get('MainPID')=='0' and properties.get('ControlPID')=='0'
if connection is not None:attempt('accepted socket close',connection.close)
if listener is not None:attempt('listener close',listener.close)
if quiescent:
if unit_attempted:
attempt('reset failed state',lambda:ctl('reset-failed',unit))
for path in reversed(owned_files):attempt('file '+str(path),lambda path=path:remove_file(path))
if unit_attempted:attempt('daemon reload',lambda:ctl('daemon-reload'))
if unit_attempted and os.path.lexists(state):
if state.is_symlink():failures.append('unexpected state symlink; retained '+str(state))
else:
attempt('state result',lambda:remove_file(state/'result.json'))
attempt('state directory',state.rmdir)
if account_attempted:
attempt('account '+name,delete_account)
attempt('group '+name,delete_group)
if root is not None:attempt('temporary directory '+str(root),root.rmdir)
for path in owned_files+([state] if unit_attempted else [])+([root] if root is not None else []):
attempt('absence '+str(path),lambda path=path:require_empty(str(path) if os.path.lexists(path) else ''))
if account_attempted:
attempt('account absence',lambda:require_empty(name if account_exists() else ''))
attempt('group absence',lambda:require_empty(name if group_exists() else ''))
else:
failures.append('unit may still run; retain its unit, account/group, state and files for manual recovery: '+name)
if failures:
message='cleanup incomplete or uncertain: '+'; '.join(failures)
try:print(message,file=sys.stderr)
except (OSError,ValueError):pass
if primary is None:raise RuntimeError(message)
This revised fixture is unrun. Use an exclusively controlled disposable system; root or container markers do not prove isolation. Guarded setup checks scratch/account/group preexistence, exclusively creates files and sets worker/directory modes to 0644/0755; ACLs still require verification. Management commands time out after ten seconds; unit start/stop limits are five seconds, accept two. Stop failure does not skip state checks or socket closure. Uncertain service state retains its files/account for manual recovery. Otherwise removals and residual checks are independent. Errors preserve the original exception; cleanup-only failures also fail. Inspect printed names after timed-out operations settle. Process creation, filesystem/NSS stalls and abrupt termination remain unbounded.
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
- systemd 252 execution environment, mount namespaces, managed directories, private networking, and privilege controls, checked 2026-09-12.
- systemd 252 resource control, IP allow/deny precedence and implementation requirements, checked 2026-09-12.
- systemd 252 analysis commands, unit verification and its scope, checked 2026-09-12.