技术指南

systemd 服务沙箱化:从有效 unit 到最小权限的可回滚加固

面向自管 Linux VPS 的 systemd system service 加固指南。重点不是追求一个漂亮分数,而是把服务所需的身份、可写目录、设备、网络和 syscall 边界变成可审计、可回滚的配置。

先做一个决定:如果服务被利用,攻击者需要看到和改动什么?本指南的目标不是把 `systemd-analyze security` 的数字压到最低,而是让答案从“整台主机”收窄到“这个服务确实需要的目录、身份、设备、地址族和系统调用”。对一个只读配置、写入单一状态目录、通过 TCP 提供 API 的服务,优先考虑非 root 身份、ProtectSystem=strict、确认无需读取 home 后使用 ProtectHome=yes、PrivateTmp=yes、NoNewPrivileges=yes,再根据证据加入 PrivateDevices、ProtectProc、RestrictAddressFamilies 和 SystemCallFilter。任何一项导致业务失败,都应回到上一份 drop-in,而不是在生产机上连续试错。

先确认边界和回滚通道

systemd 沙箱是 unit 进程视图的限制,不是虚拟机边界。本文假设服务由 PID 1 启动、配置以文件存在、状态可单独目录化,并且你能从控制台、串口或另一条管理路径恢复 unit。需要读写宿主挂载、创建 mount、访问 `/dev` 设备、观察其他用户进程、使用任意地址族、加载插件或依赖 setuid helper 的服务,应先列出例外;套用“最严格模板”可能让服务无法启动,甚至掩盖故障。

证据模型:需求清单比分数重要

边界先收集的证据可形成的决策
身份User/Group、实际 PID 的 `/proc/PID/status`、是否需要低端口或特权操作先降为专用账号;只有明确需要才保留 capability
文件FragmentPath、DropInPaths、ExecStart、配置/状态/缓存/日志路径和写入错误ProtectSystem=strict 后只 allow-list 写目录
临时与进程视图是否共享 `/tmp`、是否读取其他进程 `/proc`、是否需要 ptracePrivateTmp、ProtectProc、ProcSubset 按需求启用
设备`/dev` 访问、GPU/FUSE/块设备/USB 依赖及日志PrivateDevices 或 DeviceAllow;未知时保持关闭并验证
网络与 syscall监听 socket、DNS、Unix socket、依赖库和失败日志RestrictAddressFamilies/SystemCallFilter 以最小集合试行
IPCD-Bus、通知 socket、凭据代理和 sidecar 依赖把 IPC 视为额外信任边界,不能只看 unit 分数

先保存有效 unit 和基线

把 example.service 换成目标 unit;先保存输出和当前服务版本。`security` 是 systemd 沙箱暴露度估计,不是应用漏洞结论。
unit=example.service
stamp=$(date --utc --iso-8601=seconds)
printf 'incident_utc=%s\nunit=%s\n' "$stamp" "$unit"
systemctl cat "$unit"
systemctl show "$unit" --no-pager \
  -p FragmentPath -p DropInPaths -p User -p Group -p DynamicUser \
  -p ExecStart -p WorkingDirectory -p StateDirectory -p CacheDirectory \
  -p LogsDirectory -p ProtectSystem -p ProtectHome -p PrivateTmp \
  -p PrivateDevices -p ProtectProc -p ProcSubset -p NoNewPrivileges \
  -p CapabilityBoundingSet -p AmbientCapabilities \
  -p RestrictAddressFamilies -p SystemCallFilter
systemd-analyze security --no-pager "$unit"

`systemctl cat` 看到的是主 unit 与 drop-in 的合成来源,`systemctl show` 才适合核对 PID 1 当前解析的属性。将输出放入权限为 0600 的证据目录,路径、用户名、命令行参数和日志可能暴露租户或内部拓扑。记录一段正常业务窗口的启动耗时、健康检查、请求错误、DNS、日志写入和状态目录变化,之后每次只增加一组限制;否则失败时无法知道是哪条边界造成。

分层加固:从低耦合项开始

  1. 身份层:用专用 User/Group 运行服务,先确认配置、证书、Unix socket 和状态目录的属主;不要把 root 当作“省事的兼容模式”。
  2. 文件层:启用 `ProtectSystem=strict`;服务不需要用户私有数据时使用 `ProtectHome=yes`,确需只读访问时才退到 `read-only`,并记录具体路径。把写入集中到 `StateDirectory=example`、`CacheDirectory=example`、`LogsDirectory=example` 或明确的 `ReadWritePaths=`。不要用 `ReadWritePaths=/` 抵消只读保护。
  3. 临时层:启用 `PrivateTmp=yes`,验证上传、临时文件、套接字和外部清理任务;需要与另一个 unit 共享临时文件时,改为明确的专用目录和权限,而不是关闭隔离。
  4. 权限层:启用 `NoNewPrivileges=yes`,再清理 `CapabilityBoundingSet`。若低端口确实需要 `CAP_NET_BIND_SERVICE`,只保留该能力并验证实际监听;不要凭经验保留 `CAP_SYS_ADMIN`、`CAP_DAC_OVERRIDE` 或 `CAP_SYS_PTRACE`。
  5. 观察层:非 root 且没有 CAP_SYS_PTRACE 的服务可尝试 `ProtectProc=invisible`;`ProcSubset=pid` 会隐藏大量非进程 `/proc` API,上游明确说明它只适合少数特定服务,不应放入通用基线。监控、调试、导出器和需要读取其他 PID 的程序必须列为例外。
  6. 网络层:根据 socket 证据设置 `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` 等最小集合。它只限制进程通过 `socket(2)` 创建地址族,不影响 socket activation 传入的 fd、`socketpair()` 或 io_uring 创建路径,也不会替代防火墙、DNS 访问控制或应用认证。
  7. 调用层:最后才试 `SystemCallArchitectures=native` 和 `SystemCallFilter=@system-service` 等集合。先在预览实例收集拒绝日志,按应用启动、TLS、DNS、数据库和插件路径逐项回退;不要把 syscall 集合当作静态兼容清单。

用 drop-in 做候选配置

这是候选基线,不是可复制的生产模板。设备、proc、地址族和 syscall 项必须按服务需求删改。
# sudo systemctl edit example.service
[Service]
User=example
Group=example
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectProc=invisible
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallArchitectures=native
# 仅在预览验证后考虑:
# ProcSubset=pid
# SystemCallFilter=@system-service
StateDirectory=example
LogsDirectory=example
# 若实际写入既有路径,改用精确的 ReadWritePaths=

`StateDirectory=` 和 `LogsDirectory=` 让 systemd 创建并管理专用目录,并把路径通过环境变量提供给服务;应用必须实际读取这些变量或本来就使用对应标准路径。若既有目录属主与 `User=/Group=` 不同,systemd 可能递归调整目录及其内容的属主,因此共享目录、备份与恢复权限必须先审计。程序只认其他固定路径时,使用一个最小的 `ReadWritePaths=`,并核对目录的实际属主;该设置只改变 unit mount namespace 的可写性,不会绕过普通 DAC 或只读文件系统。DynamicUser=yes 会进一步缩短账号生命周期并隐含多项保护,但 UID 回收、文件共享和外部备份权限必须重新评估。对已有发行版 unit,drop-in 是可追踪的变更面;不要直接编辑 `/usr/lib/systemd/system/` 下的供应商文件。

静态检查、隔离验证和单变量发布

verify 只检查 unit 文件和引用关系,不证明业务健康;重启前应有独立恢复通道和保存的旧 drop-in。
fragment=$(systemctl show example.service -p FragmentPath --value)
test -n "$fragment"
sudo systemd-analyze verify "$fragment"
sudo systemctl daemon-reload
systemctl show example.service -p User -p ProtectSystem -p ReadWritePaths \
  -p PrivateTmp -p PrivateDevices -p RestrictAddressFamilies \
  -p SystemCallFilter -p CapabilityBoundingSet
# 先在预览或维护窗口启动;不要在唯一管理通道上盲目执行
sudo systemctl restart example.service
systemctl is-active example.service
systemctl status example.service --no-pager --full
journalctl -u example.service --since=-5min --output=short-precise --no-pager

验证顺序要覆盖真实路径而非只看 active:健康检查、一次完整 API 请求、DNS 解析、TLS 握手、数据库或队列连接、日志落盘、状态目录写入、优雅停止和再次启动。每一组限制观察一个完整业务窗口,保存 unit 的退出码、journal 中的 `Permission denied`/`Operation not permitted`/`Address family not supported`/syscall 拒绝、延迟和错误率。若启用 `PrivateDevices` 后只有硬件探针失败,回退该项并记录原因;不要为了让绿色状态恢复而放宽所有项。

把隐含依赖变成可核对清单

很多加固失败不是 systemd 语法错误,而是服务依赖没有写在 unit 里。先从 `ExecStart` 展开实际二进制、参数和 wrapper,再看服务启动后的 PID、子进程和打开的文件;不要只检查主进程。`/proc/PID/fd` 可以提示配置、证书、Unix socket、日志和状态文件,`ss -lntup` 可以提示监听地址族,但它们都是瞬时观察,不应把一次快照当成完整证明。若服务按需加载插件、启动 worker 或通过 helper 执行外部程序,应在一次完整请求和优雅停止期间重复采样。

只读盘点命令;输出可能包含内部路径、账号、地址和命令参数,证据文件应限制为运维团队可读。
pid=$(systemctl show example.service -p MainPID --value)
test "$pid" != 0
cat /proc/$pid/status | grep -E '^(Name|Uid|Gid|Threads):'
find /proc/$pid/fd -mindepth 1 -maxdepth 1 -type l -printf '%l\n' 2>/dev/null | sort -u
ss -H -lntup 2>/dev/null | grep "pid=$pid," || true
journalctl -u example.service --since '-10 min' --output=short-precise --no-pager

将盘点结果分成“必须保留”“可以替代”“未知”三列。必须保留的是业务协议本身,例如 TCP 监听、DNS、证书读取和状态写入;可以替代的是把 `/tmp` 改成 RuntimeDirectory、把 root 改成专用账号、把任意状态路径迁移到 StateDirectory;未知项包括只在异常请求才执行的插件、备份脚本和管理员诊断接口。未知项不能被默认为允许,也不能被默认为拒绝:在预览实例制造合法的功能路径,记录拒绝原因,再决定是补一个窄例外还是修复应用。

按服务类型选择边界,而不是按模板抄值

服务类型通常可先试常见例外
HTTP/API 服务专用 User、ProtectSystem=strict、PrivateTmp、NoNewPrivileges、AF_UNIX/AF_INET/AF_INET6TLS helper、动态插件、上传目录、Unix socket 权限和 IPv6 健康检查
队列/定时 worker状态目录、PrivateTmp、ProtectHome、无设备、无多余 capability共享队列 socket、子进程启动、临时批文件和外部备份命令
反向代理/网络守护专用账号、只读系统、地址族 allow-list、去掉文件能力低端口 CAP_NET_BIND_SERVICE、透明代理、AF_NETLINK/AF_PACKET 或原始 socket
监控/诊断 agent只读系统、PrivateTmp、NoNewPrivileges、明确日志目录读取其他 PID、/proc、设备、eBPF、perf 或宿主网络命名空间
数据库/状态服务专用 StateDirectory、ProtectHome、PrivateTmp、最小网络族共享内存、块设备、备份/恢复工具、动态扩容和 Unix socket 组权限

这张表只是提出起点,不是兼容性承诺。代理需要低端口不代表它需要 root;可以用专用账号配合 `CAP_NET_BIND_SERVICE`,但仍要验证 capability 是否确实存在于进程的 effective/permitted 集合。监控 agent 读取 `/proc` 不等于应该看到所有租户的环境和命令行;优先使用受限导出接口或专用 socket。数据库写目录通常是恢复点和审计的核心,不应为了通过 ProtectSystem 而把整个 `/var/lib` 设为可写。

从拒绝日志反推缺口,不凭分数放宽

看到 `Permission denied` 时先判断对象是路径、用户、LSM 还是只读挂载;看到 `Operation not permitted` 时再区分 capability、no_new_privs、seccomp 或 namespace。`Address family not supported` 只说明创建了被 RestrictAddressFamilies 排除的族,不能证明该网络目标不安全。SystemCallFilter 的拒绝日志可能只显示 syscall 名称,真正原因还要和调用栈、应用版本及请求路径对齐。每条例外都要写出“谁在什么场景需要它、能否改成目录/Unix socket/专用 helper、如何验证不扩大权限”。

  • 路径拒绝:先确认进程的真实 cwd、符号链接目标、挂载点和目录属主;只开放最终目标,不要顺手开放父级整棵树。
  • 能力拒绝:用服务功能复现来证明需要的 capability,优先改成高端口加反向代理或专用设备 ACL;保留 CAP_SYS_ADMIN 等宽能力应视为未解决风险。
  • syscall 拒绝:先撤掉 filter 以确认关联,再把应用升级、插件替换或启动参数修复放在增加 syscall 之前;一个过宽的 `~@obsolete` 不能替代审计。
  • 网络拒绝:对 DNS、IPv4、IPv6、Unix socket 和本地代理分别测试;不要因为一次 IPv4 成功就删掉 IPv6,也不要因为外连失败就直接开放所有地址族。

预先写下回滚条件

加固变更的回滚条件应在发布前写进工单:启动失败一次、健康检查连续失败、真实请求错误率超过既定基线、队列积压持续增长、关键日志丢失、备份/恢复路径不能访问,或出现无法解释的 capability/syscall 拒绝,就停止继续加固并恢复上一版。回滚只撤销本次 drop-in,不要执行 `reset-failed` 来掩盖启动频率或退出原因;保留失败 journal、有效配置和服务版本,待根因被复现后再开新的候选变更。

若 unit 使用 `Restart=always`,沙箱配置错误可能制造重启循环并淹没拒绝日志。首次试验前可在维护窗口临时停用自动重启,或确保 StartLimit 和独立控制台可用;不要用反复重启来测试限制。状态型服务还要确认 WAL、队列租约和幂等语义,避免一次权限回滚变成重复消费或半写入。任何需要恢复旧配置的动作都应经过 `systemd-analyze verify`,再按最小范围重启目标 unit。

把沙箱当作版本化接口维护

把 drop-in 纳入与服务二进制同一版本库,记录 systemd、内核、LSM、运行库和插件版本。每次升级后重新导出 `systemctl show`,比较新增的 ExecStart 参数、写路径、监听 socket、子进程和 capability;再运行静态检查与真实业务验收。监控不只看 unit 是否 active,还应关注拒绝日志速率、健康检查、请求错误、队列、状态目录空间和日志完整性。若供应商 unit 被替换,确认 drop-in 仍被加载;若改用 user service 或容器,重新审视 manager 的继承限制和 namespace 叠加。

不要混淆几种隔离手段

手段解决的问题不能替代的东西
User/Group 或 DynamicUser减少文件和进程的身份权限,限制 UID 直接读写不能撤销同一 UID 已有的文件权限,不能代替目录权限设计
ProtectSystem/ProtectHome/ReadWritePaths限制 unit mount namespace 中的路径读写不能阻止已读出的秘密,不能保护另一个有权限的服务
NoNewPrivileges/CapabilityBoundingSet阻止 execve 获得新权限并收窄 capability不能清除当前进程已经拥有的普通权限或修复应用漏洞
PrivateTmp/PrivateDevices/ProtectProc隔离临时目录、设备节点和进程元数据视图不能替代硬件 ACL、监控授权、内核 LSM 或 IPC policy
RestrictAddressFamilies/SystemCallFilter缩小创建 socket 和调用内核接口的范围不能替代出站防火墙、TLS、认证、补丁和应用级审计

这些手段的组合关系决定了安全收益。只开 ProtectSystem 而保留 root、CAP_SYS_ADMIN 或可写的管理 socket,服务仍可能请求另一个高权限组件完成危险操作;只开 NoNewPrivileges 而让服务读取整台主机的 secrets,也没有形成有意义的边界;只开 SystemCallFilter 而不验证文件和网络路径,则可能得到一份难以维护、却没有减少数据暴露的配置。每次评审都要问“限制生效后,攻击者还剩哪条可达路径”,并把答案写进变更记录。

发布前至少准备四组测试:冷启动、热重载、正常请求、异常请求。冷启动发现目录和 capability 配置问题,热重载发现凭据、证书和 socket 生命周期问题,正常请求验证业务路径,异常请求验证错误处理、重试和临时文件清理。对有队列或定时任务的服务,再加入一次暂停、恢复和重复执行测试;对代理或双栈服务,分别走 IPv4、IPv6、Unix socket 和 DNS 失败路径。只有四组都通过,才把候选 drop-in 标记为 known-good。

若无法提供这些测试,就把变更标为实验而不是安全基线,并保留原配置。安全加固的价值来自可预测的失败半径:一个目录写错只影响一个 unit,一条地址族规则只影响一种连接,一组 syscall 过滤只在明确的调用路径上启用。将多个未知项同时收紧,会把失败半径扩大到无法解释的服务停机;这也是为什么本文坚持单变量、证据和回滚,而不发布一套所谓“一键硬化”脚本。

失败分类与精确回滚

症状较可能的边界恢复路径
启动即报只读或 Permission deniedProtectSystem=strict、ProtectHome 或写目录属主恢复旧 drop-in,找出真实写路径后只加该目录;不要开放根目录
上传、锁文件或临时 socket 消失PrivateTmp 或程序依赖共享 /tmp改用专用 RuntimeDirectory/StateDirectory,确认清理生命周期后再启用
DNS、IPv6、Unix socket 或数据库连接失败RestrictAddressFamilies 集合不完整从 journal 和 socket 证据补一个地址族,逐项验证
设备、GPU、FUSE、监控或调试失效PrivateDevices、ProtectProc 或 capability 被收紧只回滚相关项;评估 DeviceAllow/ptrace 需求和更低权限替代方案
服务启动但插件/TLS/运行库报 EPERMSystemCallFilter 或 NoNewPrivileges 与 helper 冲突先移除 syscall filter 定位缺口;不要把 setuid helper 当作默认依赖
systemd-analyze security 分数仍高服务确实需要网络/设备/IPC,或工具未覆盖应用层保留可解释的例外,补做应用、IPC、LSM 和网络策略审计
示例只展示可恢复顺序;不要把未知文件覆盖生产配置,也不要删除失败证据。
# 回滚前保存当前现场
sudo cp -a /etc/systemd/system/example.service.d/override.conf \
  /root/example.override.failed.$(date -u +%Y%m%dT%H%M%SZ)
# 恢复已审阅的旧文件(示例路径,按配置管理实际版本替换)
sudo cp -a /root/example.override.known-good.conf \
  /etc/systemd/system/example.service.d/override.conf
fragment=$(systemctl show example.service -p FragmentPath --value)
test -n "$fragment"
sudo systemd-analyze verify "$fragment"
sudo systemctl daemon-reload
sudo systemctl restart example.service
systemctl show example.service -p Result -p ExecMainCode -p ExecMainStatus -p MainPID

验收标准:限制有效且用户路径仍然工作

  • `systemctl show` 显示的属性与版本库中的 drop-in 一致,`systemd-analyze verify` 无未知键、路径或依赖错误。
  • 目标服务以预期的专用 UID/GID 运行;`/proc/PID/status`、监听 socket、状态/日志目录属主和 capability 现场均符合设计。
  • 在预定观察窗内,健康检查、真实请求、DNS/TLS、依赖连接、日志、优雅停止/启动和队列处理没有回归;journal 没有新增未解释的拒绝事件。
  • `systemd-analyze security` 的逐项结果被记录为风险信号而非通过/失败门槛;高分项目要么有业务理由,要么有后续加固计划。
  • 保留旧 drop-in、变更时间、验证命令、失败日志和回滚负责人;升级 systemd、内核、运行库或服务版本后重新验收。

诚实的未知项

发行版会回移或禁用 systemd 选项,旧内核可能不支持某些 mount/proc 语义,SELinux/AppArmor、容器 namespace、rootless user manager、服务自带 supervisor 和云平台控制面会改变有效边界。systemd-analyze security 不检查应用自身漏洞、依赖库、D-Bus policy 或上游网络;没有目标服务的完整调用、文件和 IPC 清单,就无法声称“最小权限”已经证明。本文没有给出通用的 syscall 白名单、capability 数量、观察时长或安全分数阈值;这些未知必须在实际服务、内核、systemd 版本和恢复预算中重新验证。

本文命令和 unit 键在 2026-08-19 按 systemd 上游、Linux 内核文档与 Linux man-pages 做语义和语法审阅;技术审校另在 systemd 255 上确认 standalone `override.conf` 不是 `systemd-analyze verify` 的合法 unit 文件输入,并改为验证有效主 fragment 与其 load path 中的 drop-in。没有在读者的 VPS、生产 unit 或硬件设备上执行重启或配置变更。把示例中的 unit、账号、目录和地址族替换为真实值前,先确认独立管理通道;不要用本文的候选 drop-in 直接覆盖供应商配置。

返回知识库