服务加固应该把一种明确不需要的操作变为被拒绝的操作,同时保留应用必须完成的工作。systemd 为文件系统视图、命名空间、权限和其他资源提供执行设置,但这些是边界各不相同的独立控制。添加四条熟悉的指令,并不意味着所有可读文件、可接触进程、继承的文件描述符或允许的系统调用都会消失。判断依据应是配置的真实语义与进程的实际行为。

坏掉的样子

ProtectSystem=strict 让服务挂载命名空间内的大部分文件系统层级变为只读。只读不代表不可读取,而且该设置有文档明确的例外,例如还需要单独控制的接口文件系统。服务仍然可能泄露它能够读取的信息,也仍然可以破坏可写目录中的数据。因此应该先使用专用服务身份,列清必要访问,再选择对应限制,而不是把一个只读开关当成覆盖所有资源的隔离墙。

PrivateTmp=yes 给服务提供私有临时目录。如果应用原本依赖另一个服务通过普通 /tmp 交换文件,这项设置可能直接打断协作。PrivateNetwork=yes 创建拥有独立回环接口的网络命名空间,因此宿主回环上的监听器不再是里面的同一个端点。需要直接连接数据库或公共接口的程序,若没有有意安排所需连接,就不适合直接套用这一设置。应先查真实调用路径,不能假定网络隔离不会影响正常功能。

NoNewPrivileges=yes 阻止执行程序时通过设置用户身份的二进制文件、文件能力等机制取得新的特权,并不删除进程已经拥有的权限。同样,单独写一条 IPAddressAllow,不意味着其他地址全部被拒绝:地址过滤有允许与拒绝的优先关系,还依赖内核功能支持。选项名称不是完整安全策略,每项控制都需要按其契约理解,尤其不能把未验证的限制写成已经成立的保证。

四张卡片比较同一非 root 工作负载在不同 systemd 限制前后的行为;下方分支保留受管理状态与私有临时目录写入。文字区分可写视图与普通权限,并保留清理不确定性及执行限制。 静态说明原文;制作本图未执行文章实验。
不同 systemd 控制拒绝选定操作,同时保留受管理状态与临时写入。

快速验证

这个实验要求一次性 Linux 系统中的 root 身份、作为 PID 1 的 systemd、可用的挂载与网络命名空间、位于 /usr/bin/python3 的 Python 3.8 或更新版本,以及用户管理工具。保持 Python 断言启用;生成的 worker 必须能被服务账号读取。早期记录报告 Debian 12、systemd 252.39;这里没有复现该次运行。脚本创建唯一命名的账号、运行时单元、状态目录与文件。前后两阶段使用同一个非 root 工作负载:

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)

修订后的夹具尚未运行,只能使用独占控制的一次性系统;root 身份或容器标记不证明隔离。准备前建立保护,预检 scratch、账号和组,排他创建文件,并把 worker 与目录权限设为 0644/0755;ACL 仍须核验。管理命令设十秒超时,单元启动及停止限五秒,接受连接限两秒。停止失败不会跳过状态检查或套接字关闭;服务状态不明时保留文件与账号,供人工恢复。其余删除及残留检查独立尝试;错误不替换最初异常,只有清理失败时也会失败。超时操作稳定后须检查打印出的名称。进程创建、文件系统或 NSS 停滞与突然终止仍不受时限约束。

基线阶段故意让该账号能够写入一个样本文件,同时在管理器看到的临时目录里创建标记,并在其回环接口启动监听器。尚未加限制时,工作进程能够追加文件、看到标记、连接监听器,且读取到零值的 NoNewPrivs 标志。这一步排除了普通文件权限本来就禁止写入、或者监听器根本不存在等混淆因素,之后的拒绝才有明确对照。

加固阶段加入四项执行设置,然后重复同样的探测。外部样本文件写入以 EROFS 失败,因此不是仅仅因为文件所有者不匹配而失败。原来的临时标记在新视图中不可见,连接原监听端点得到 ECONNREFUSED,而进程报告 NoNewPrivs=1。这些证据来自实际工作进程,不是只检查单元解析器是否认识几个配置名字;每个结果都对应本次改变的具体边界。

必要的写入仍然成功:StateDirectory 创建服务使用的受管理可写状态位置,私有临时目录也允许写入自己的样本文件。从管理器命名空间观察,同名临时文件仍保留基线阶段的内容,说明第二次写入发生在另一份临时目录视图中。外部样本文件同样保持此前内容,进一步确认失败操作没有悄悄改变目标文件。

脚本断言这些精确结果,并在启动前验证修改后的单元定义。它没有测试漏洞利用、系统调用过滤或基于 BPF 的地址策略。因此本实验是在配置检查之外,补上所展示几项控制的运行时证据,而不是声称所有可能的访问路径都已经被封闭。部署时如果增加其他控制,应为那些控制寻找对应的行为验证,不能借用本例结果替代。

让必要访问保持明确

限制应施加在真实部署的服务定义上,同时考虑覆盖文件和执行身份。可写路径例外改变的是挂载视图,不会授予普通 Unix 文件权限,也不能让底层本来只读的文件系统变成可写。若状态、缓存或运行时目录的生命周期和所有权契约适合应用,优先使用对应的受管理目录,减少手工建目录造成的权限和清理差异。

加固之后,应按应用实际支持的能力检查启动、正常请求、维护、重载与停止。一个命令刚运行一次成功,仍可能在稍后轮转文件或建立新连接时失败。应定位具体被拒绝的操作,调整真正必要的边界,并保留“服务完成有用工作”与“不需要的操作仍被阻止”两类检查。这样配置才能说明当前已验证的限制,而不是一份看似严格却与工作负载脱节的开关清单。

延伸阅读

来源