SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-09-01

systemd ProtectSystem 沙箱配置与故障排查

详解 ProtectSystem、PrivateTmp、DynamicUser 和 NoNewPrivileges:说明各指令阻止什么、可能破坏什么,以及服务启动失败时如何定位具体原因。

systemd 沙箱的作用

systemd 沙箱配置是编写 unit 文件的后半部分。Type=决定服务如何启动,ProtectSystem=PrivateTmp=等指令决定服务运行后可以访问哪些资源。这些功能由内核提供,包括挂载命名空间和 seccomp 过滤器。systemd 会在进程获得控制权之前应用这些限制。应用程序无法感知这些限制,也不需要修改代码。

默认情况下不启用任何限制。未配置沙箱的 unit 以 root 身份运行,可以写入任意位置,读取系统上的所有文件,还可以加载内核模块。如果该 unit 是可从互联网访问的 Web 应用,一个文件上传漏洞就可能导致整个服务器被入侵。在下面的 unit 配置下,同一应用将使用只读文件系统、空的 /home、其他进程无法看到的 /tmp,即使找到 setuid 二进制文件,也无法获得 root 权限。

这些设置每次只应用于一个 unit。加固某个服务不会影响相邻服务,因此应先处理监听公网端口的服务。

一次性编写 unit 文件

notes 是一个小型 Web 服务。它监听 localhost,并将 SQLite 数据库存放在 /var/lib/notes 下,前面由 nginx 反向代理。Type=exec 适用,因为该二进制程序会保持在前台运行;而 Type=simple、exec、forking 和 notify 的区别决定了 systemd 如何跟踪启动过程。下面将进一步解释每条指令,包括它能防止的问题以及常见的故障原因。

[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible

NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes

ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes

[Install]
WantedBy=multi-user.target

将其写入 /etc/systemd/system/notes.service,运行 sudo systemctl daemon-reload,然后运行 sudo systemctl restart notes.service。对于来自软件包的 unit,不要编辑供应商文件。sudo systemctl edit notes.service 会在 /etc/systemd/system/notes.service.d/override.conf 中打开一个 drop-in 文件,其中只包含你的 [Service] 配置,并且在软件包升级后仍会保留。systemctl cat notes.service 会按供应商文件在前、合并结果在后的顺序输出最终配置。

服务以哪个用户身份运行

如果不设置 User=,服务将以 root 身份运行,这里的其他指令都只能用于降低风险。解决方法有两种。

静态系统用户。 使用 sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes 创建该用户,然后在单元中设置 User=notes。服务重启或系统重启后,UID 都保持不变。如果服务需要拥有自身状态目录之外的文件,或者备份任务需要读取这些文件,这一点很重要。为每个服务分配专用的非特权账户的原理在这里同样适用。

DynamicUser=yes systemd 会在服务启动时从保留范围分配一个 UID,并在服务停止时释放该 UID。磁盘上不会创建账户,因此删除服务后不会留下任何账户。服务运行期间,getent passwd notes 通过 systemd 的 NSS(名称服务切换)模块解析该名称。服务停止后,该名称也会消失。

DynamicUser=yes 还会为您启用另外 4 项设置:RemoveIPC=yesPrivateTmp=yesProtectSystem=strictProtectHome=read-only。一行配置就能实现大部分沙箱限制,因此示例单元仍然很简短。

它会导致什么问题。 动态 UID 不能拥有任意位置的文件,因为该数字会在不同服务之间重复分配。持久化数据必须通过 StateDirectory=CacheDirectory=LogsDirectory= 访问,systemd 会在每次启动时创建这些目录,并将其所有者改为当前 UID。使用 DynamicUser=yes 时,实际目录是 /var/lib/private/notes,而 /var/lib/notes 是指向该目录的符号链接。/var/lib/private 的权限模式为 0700,所有者为 root,因此以普通用户身份运行的备份任务在该路径上会得到 Permission denied,即使该路径对 root 来说完全可读。凡是需要固定所有者、SSH 密钥、NFS 导出目录,或需要让第二个服务读取文件的场景,都应改用静态用户。

文件系统的形态:ProtectSystem、ProtectHome、PrivateTmp

ProtectSystem= 接受 3 个值。yes 会将 /usr 和启动目录挂载为只读。full 还会加入 /etcstrict 会将整个目录层次挂载为只读,但内核 API 目录 /dev/proc/sys 除外;其他指令会覆盖这些目录。请从 strict 开始,然后按需放开限制,因为先采用宽松配置、之后再收紧通常不会发生。

放开的路径使用 ReadWritePaths=/srv/notes/uploads 指定。所需路径比预期少:在 ProtectSystem=strict 下,StateDirectory=LogsDirectory=CacheDirectory=RuntimeDirectory= 会自动保持可写,因此示例单元完全没有 ReadWritePaths= 行。在 strict 下,/tmp 也会变为只读,除非 PrivateTmp=yes 为该单元提供专用的可写目录。

会破坏什么。 现在,所有对这些路径之外位置的写入都会因 Read-only file system 失败。应用将自身配置写回 /etc、直接在 /run 中创建 PID 文件,或将插件解压到 /opt 时,都会遇到此问题。请从错误信息中读取实际路径,只添加该路径,不要添加其父目录。ReadWritePaths= 中列出的路径如果不存在,会导致启动失败,而不是显示警告,因此可选路径前应加连字符:ReadWritePaths=-/srv/notes/uploads

只读不等于隐藏。在 ProtectSystem=strict 下,服务仍可读取 /etc/passwd,也能读取其他应用所属的、对所有用户可读的任何机密文件。InaccessiblePaths=/etc/ssh /srv/otherapp 会将某个子目录从该单元的视图中完全移除。对于服务自身的机密文件,LoadCredential=dbpass:/etc/notes/dbpass 会将文件复制到每个单元专用的目录中,只有该服务可以读取;应用可在 $CREDENTIALS_DIRECTORY 下找到它。

ProtectHome=yes 会使 /home/root/run/user 显示为空。Web 服务不应访问 home 目录,这也能阻止路径遍历漏洞访问 /root/.sshread-onlytmpfs 是限制较弱的值。凡是数据确实存放在 home 目录中的程序都会受影响,许多安装在 /home/app 下的手动安装应用都属于这种情况。请将数据移到 /var/lib,或设置 ProtectHome=read-only,并接受较小的安全收益。

PrivateTmp=yes 会为服务提供专用的 /tmp/var/tmp,在服务启动时创建,在服务停止时删除。这样可以消除服务之间整类临时文件竞争问题;服务崩溃后,也不会再将机密文件留在每个系统用户都能列出的目录中。

会破坏什么。 任何将 /tmp 作为共享位置使用的程序都会受影响。配置为通过 /tmp/mysql.sock 连接 MySQL 的服务现在会报告 Can't connect to local MySQL server through socket '/tmp/mysql.sock',因为数据库在主机的 /tmp 中创建了套接字,而服务正在自己的目录中查找。请将其指向 127.0.0.1,或指向 /run 下的实际套接字路径。调试时也会遇到同样的问题:服务写入 /tmp 的文件不会出现在你 shell 的 /tmp 中。若要查看这些文件,请进入该服务的挂载命名空间。

pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmp

PrivateDevices=yes 会用一小组伪设备替换 /dev,例如 /dev/null/dev/zero/dev/urandom。物理设备将完全不可见。磁盘设备节点、/dev/kvm/dev/net/tun、声卡和 GPU 都会消失,因此执行硬件转码的媒体服务器无法打开 /dev/dri/renderD128,只能回退到软件转码或退出。服务确实需要某个设备节点时,请对该单元关闭 PrivateDevices=,并使用 DeviceAllow=/dev/dri/renderD128 rw 指定该节点;这仍然比默认允许系统中的所有设备严格得多。

服务可获得的权限:NoNewPrivileges 与 capabilities

NoNewPrivileges=yes 设置一个内核永远不会清除的进程标志。此后,该进程及其启动的所有子进程都无法通过 setuid 二进制文件或文件 capabilities 获得权限。这是该单元中最有价值的一行,因为它能让大多数本地权限提升链在第一步就停止。

它会导致哪些功能失效。 服务内部调用 sudo 的操作会失败,并显示 sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges?。PAM(可插拔认证模块)通过 shell 调用 unix_chkpwd 执行的密码检查也会以同样方式失败;需要 newuidmap 的 rootless 容器工具也一样。如果服务依赖其中任何一项,应移除该依赖,而不是移除这条指令。

CapabilityBoundingSet= 限制该单元中的进程可以持有的 capabilities,空值赋值会删除全部 capabilities。对于已经以非 root 用户运行的服务,这是第二道锁,而不是第一道锁,因为 NoNewPrivileges=yes 会阻止通常的 capability 获取方式。两者都应保留,因为它们的失败方式不同,可以让问题被检测两次。

Web 服务通常需要的唯一 capability 是 CAP_NET_BIND_SERVICE,用于监听低于 1024 的端口。非 root 进程需要实际获得该 capability,而不只是被允许使用它,因此必须同时配置这两行。

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

systemd 会在降权前应用 ambient capabilities,因此它仍可与 NoNewPrivileges=yes 配合使用。缺少这些配置时,服务会启动,然后退出并显示包含端口信息的错误,例如 listen tcp :443: bind: permission denied。在大多数 VPS 环境中,更好的做法是让服务绑定 127.0.0.1:8080,再由 nginx 或 Caddy 负责监听 443,这样单元完全不需要该 capability。前置的 ~ 会反转列表,因此 CapabilityBoundingSet=~CAP_SYS_ADMIN 会阻止该 capability,同时允许其他 capabilities。优先使用允许列表:拒绝列表难以长期维护,因为新的 capabilities 会不断增加。

内核暴露的内容

ProtectKernelTunables=yes 会将 /proc/sys/sys 及其下的可写文件设为只读。任何在启动期间设置 sysctl 的服务都会因此失败:启动脚本会输出 sysctl: setting key "vm.max_map_count": Read-only file system,然后退出。应将该值改为写入 /etc/sysctl.d/,因为这是正确的位置,而且重启后仍会保留;随后继续启用此指令。

ProtectKernelModules=yes 会阻止加载模块。运行 modprobe 的单元会收到 modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted。应通过 /etc/modules-load.d/ 在启动时加载模块,而不是从服务中加载。

ProtectKernelLogs=yes 会移除 dmesgProtectControlGroups=yes 会将 /sys/fs/cgroup 设为只读,容器运行时以及管理自身 cgroup 的程序会立即发现这一点。ProtectProc=invisible 会隐藏 /proc 中其他用户的进程,因此受入侵的服务无法读取其他守护进程的命令行,以及有人通过命令行传递的密码。遍历 /proc 的监控代理需要关闭此限制。

RestrictNamespaces=yes 会阻止服务创建新的命名空间。容器运行时需要此功能,攻击者也会利用它构建逃逸路径。LockPersonality=yes 会阻止更改内核执行域,RestrictSUIDSGID=yes 会阻止服务创建 setuid 文件。这两项限制开销低,通常不会影响普通应用。

服务可以连接到哪些对象

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6允许使用本地套接字以及 IPv4 和 IPv6,并使 socket() 对其他所有对象返回 EAFNOSUPPORT。这是一个 seccomp 过滤器,因此可在 x86-64 和 arm64 上工作,当前的 VPS 基本都支持这两种架构。

它会阻止什么。 AF_NETLINK,实际发生的频率远高于预期。glibc 的 getifaddrs() 使用 netlink 套接字,Go、Java 和 .NET 运行时的接口枚举路径也会使用 netlink。因此,只想获取自身 IP 地址的服务也可能因 OSError: [Errno 97] Address family not supported by protocol 或该语言中的等效错误而退出。此时,列表会变为 AF_UNIX AF_INET AF_INET6 AF_NETLINK,但仍比默认配置严格得多。AF_PACKET 是原始数据包捕获所需的权限,除此之外几乎不应授予任何服务。

IPAddressDeny=anyIPAddressAllow=localhost 使用的是另一种机制:将 BPF 过滤器附加到该单元的 cgroup。它按单元生效,且 nft list ruleset 无法看到它。因此,如果服务只能访问同一主机上的数据库,这种机制很合适;但对于之后排查该主机的人员来说,问题可能并不明显。如果内核不支持 cgroup BPF,systemd 会记录该单元正在配置 IP 防火墙,但本地系统不支持 BPF/cgroup 防火墙。此时规则会静默失效。因此,应查看 journal,不要想当然地认为规则已生效。

加固后的单元为什么无法启动?

上面的每条指令都可能让原本正常的服务启动失败,而且故障表现通常与触发问题的指令完全不同。排查步骤始终相同:读取日志,只放宽一条指令,然后重新测试。

sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pager

用于定义沙箱的行通常如下:

notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE

226/NAMESPACE 表示 systemd 无法构建文件系统视图,因此二进制程序根本没有运行。通常原因是 ReadWritePaths=BindPaths=InaccessiblePaths= 中的路径不存在。228/SECCOMP 表示应用 SystemCallFilter=SystemCallArchitectures= 失败。code=killed, status=31/SYS 则不同:进程已经启动,随后调用了被过滤器拒绝的系统调用,内核将其终止。排查过程中请打开无法启动的单元的 systemd 退出代码参考,因为退出代码是区分命名空间问题和应用问题的最快方法。

一次只放宽一条指令,并通过 drop-in 修改,这样结果才具有诊断意义。运行 sudo systemctl edit notes.service,然后在其中加入一行:

[Service]
ProtectSystem=full

重启服务。如果服务现在能够启动,说明你已经确定了需要收紧的指令,而不是直接删除它。将其恢复为 strict,再为应用实际需要的路径添加 ReadWritePaths= 行,然后再次重启。服务无法启动就删除整个配置块,最终会导致单元失去所有保护,只留下没人记得为何写下的注释。

若要在不涉及服务的情况下测试沙箱,请在沙箱中运行一个 shell:

sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bash

在该 shell 中,touch /etc/test 会返回 Read-only file system,而 ls /home 不会显示任何内容。这是了解应用实际可见内容的最快方法,因为你可以手动运行应用命令,并观察具体是哪条命令失败。

还有一种故障模式不会产生任何错误。拼写错误的指令只会触发警告,服务仍会在没有该指令的情况下启动:

/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.

单元正常运行,但沙箱并未启用,之后也不会有任何提示。使用两个命令即可发现这一点。sudo systemd-analyze verify /etc/systemd/system/notes.service 会按需打印相同的警告,systemctl show 会打印运行中服务实际收到的配置:

systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivileges

如果该命令返回 ProtectSystem=no,而你写的是 strict,说明单元加载的配置并不是你以为的内容。

将 systemd-analyze security 用作检查清单

sudo systemd-analyze security notes.service会列出单元可以使用的所有沙箱设置、当前对每项设置的配置情况,以及每一行对应的评估结果。不带参数运行时,它会列出此服务器上所有已加载的服务。添加 --offline=true 并指定路径后,可以在安装前检查单元文件。

将输出视为待办事项。逐行检查工具标记的项目,并分别回答一个问题:此服务是否需要该访问权限?大多数情况下,答案是否定的,此时应添加相应配置行。有时答案是肯定的。媒体服务器需要设备节点。备份代理需要读取 /home。这些项目会一直处于标记状态,这是正确结果,并不表示检查失败。

末尾的汇总分数是对上方各行结果的汇总。它不了解服务的实际用途、所持有的数据,也不知道应用本身是否存在漏洞。某个单元可能满足工具检查的所有项目,却仍然是服务器上最薄弱的组件,因为工具衡量的是单元文件的暴露程度,而不是软件的暴露程度。自托管密码管理器可以具体说明这一差距:Vaultwarden 单元可能通过所有项目,但其管理令牌和备份文件仍然决定密码库是否安全。追逐分数会导致人们直接粘贴自己不了解的指令,而这些指令正是软件包升级后最容易导致服务故障的配置,最终没有人能解释当初为什么要添加该行。

沙箱的边界

这些指令控制服务可以访问哪些资源。它们不限制服务可以消耗多少资源,因此即使单元已完全进行沙箱隔离,仍可能占用主机上的所有 CPU 核心和内存。这属于另一组设置,详见systemd 服务的 CPU 和内存限制指南

这些指令也不能替代强制访问控制。命名空间按单元隔离,由编写单元文件的人员设置;而SELinux 会在整个系统范围内强制执行统一策略。两者可以配合使用,不能相互替代。

最后,这些设置只涵盖 systemd 在此单元中启动的进程。如果服务通过套接字将工作交给辅助 daemon,则辅助 daemon 并未受到沙箱隔离。也要将同一配置块写入该 daemon 的单元,并使用 systemctl show 检查结果,不要只相信文件内容。

FAQ

ProtectSystem=strict 实际上会将哪些内容设为只读?

/dev/proc/sys 外,整个文件系统层次结构都会被设为只读;这 3 个路径分别由 PrivateDevices=ProtectKernelTunables=ProtectControlGroups= 另行处理。其中包括 /etc/var/srv/opt/tmp。systemd 会自动恢复写入权限的例外路径,是它负责管理的目录:StateDirectory=CacheDirectory=LogsDirectory=RuntimeDirectory=。服务需要写入其他路径时,必须显式添加 ReadWritePaths= 条目。请注意,只读不等于不可读取,因此主机其他位置的机密文件仍可被服务打开,除非将其列入 InaccessiblePaths=

为什么我的服务会以 status=226/NAMESPACE 状态失败?

systemd 无法构建挂载命名空间,因此可执行文件根本没有启动。其上一行日志通常显示为 Failed to set up mount namespacing: No such file or directory。几乎所有情况下,ReadWritePaths=BindPaths=InaccessiblePaths= 中的某个路径都不存在。创建该目录,或在条目前加连字符(ReadWritePaths=-/srv/notes/uploads),这样路径缺失时 systemd 会跳过该条目。如果路径确实存在,请检查单元文件中是否有拼写错误,并使用 systemctl cat notes.service 确认,因为 drop-in 配置可能添加了你当前没有查看的条目。

使用 PrivateTmp 的服务仍能通过 /tmp 共享文件吗?

不能,这正是 PrivateTmp 的作用。服务会获得仅在其运行期间存在的独立 /tmp/var/tmp,因此其他进程在主机 /tmp 中创建的套接字或文件对它不可见。位于 /tmp/mysql.sock 的数据库套接字最容易受到影响。解决方法是通过 127.0.0.1 连接,或将客户端指向 /run 下的真实套接字。要检查服务自己的临时文件,请从 systemctl show --property=MainPID --value 获取其主 PID,然后使用 sudo nsenter --target <pid> --mount 进入其挂载命名空间。

沙箱化服务不使用 root,如何监听 443 端口?

只授予一个 capability,不要授予整个 root 账户。设置 AmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICE,保留 User=DynamicUser=yes,进程即可绑定低位端口,但不能执行其他特权操作。只设置 bounding set 是常见错误:此时 capability 仅被允许使用,却从未实际授予服务,服务会退出并报告涉及该端口的权限错误。如果 VPS 上已经运行反向代理,更简洁的做法是在服务中绑定 127.0.0.1:8080,让 nginx 占用 443,这样该单元完全不需要 capability。

我应该使用 DynamicUser,而不是创建系统用户吗?

如果服务的所有数据都保存在 StateDirectory=CacheDirectory=LogsDirectory= 中,可以使用 DynamicUser。这涵盖大多数小型自托管 Web 应用。服务运行期间才会存在一个 UID,同时还会自动启用 PrivateTmp=ProtectSystem=strictProtectHome=read-onlyRemoveIPC=。如果 UID 必须保持稳定,则应使用静态系统用户,例如文件的所有者位于这些目录之外、需要使用 SSH 密钥或 NFS 挂载,或者另一个进程需要读取相同的数据。请记住,使用 DynamicUser=yes 时,数据实际位于 /var/lib/private/notes 中;该目录权限为 0700,所有者为 root,因此非 root 备份任务会失败。