在Debian系统中,通过systemd的沙箱机制限制服务的系统调用,核心方法是在unit文件中使用SystemCallFilter、SystemCallFilterNumber、SystemCallArchitectures等指令,配合CapabilityBoundingSet、PrivateTmp、ProtectSystem等安全选项,构建一个最小权限运行环境。这套机制本质上是利用Linux内核的seccomp-bpf过滤器,在用户态拦截特定系统调用,一旦服务尝试执行被禁止的调用,内核会直接终止进程并返回EPERM错误。下面我会从原理、配置、实战、验证四个层面,把这件事讲透。

一、systemd沙箱限制系统调用的底层原理

systemd的沙箱能力并非systemd自己实现的,它底层依赖的是Linux内核的两大安全机制:seccomp(Secure Computing Mode)和namespaces。当你在unit文件中配置SystemCallFilter时,systemd会在启动服务进程之前,通过prctl系统调用将seccomp-bpf过滤器加载到内核中。之后该进程发出的每一个系统调用,都会先经过这个过滤器的检查,不匹配白名单的调用会被直接拒绝。

具体来说,seccomp-bpf允许你定义一套规则,比如"只允许read、write、exit、futex这几个调用",其他全部拦截。systemd把这个能力封装成了人类可读的指令,你不需要手写bpf字节码,只需要在配置文件里写一行SystemCallFilter=@basic-io就能实现。这种封装让安全配置的门槛大幅降低,同时又不失灵活性。

二、核心配置指令详解

要限制系统调用,你需要熟悉以下几个关键指令,它们通常写在[Service]段中:

SystemCallFilter:指定允许的系统调用白名单。可以使用预定义组,比如@basic-io、@chown、@sync、@timer、@signal等。也可以用具体的系统调用名,如SystemCallFilter=read write exit。

SystemCallFilterNumber:和SystemCallFilter类似,但使用系统调用号而非名称。这在某些场景下更精确,因为不同架构的系统调用号可能不同。

SystemCallArchitectures:限制进程只能使用特定CPU架构的系统调用。比如设置为native,就只允许本机架构的调用;设置为空,则禁用所有系统调用(极端情况)。

SystemCallErrorNumber:当进程触发被过滤的系统调用时,返回给进程的错误号。默认是EPERM(1),也可以改成ENOSYS(38),让进程以为这个调用根本不存在。

三、实战配置:从简单到严格的三级方案

下面我给出三个递进的配置示例,适用于不同安全等级的场景。

第一级:基础限制,适合普通网络服务。

[Unit]
Description=Basic Sandboxed Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/myservice
SystemCallFilter=@basic-io @chown @network @sync @timer @signal
SystemCallArchitectures=native
CapabilityBoundingSet=
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myservice

[Install]
WantedBy=multi-user.target

这个配置做了几件事:只允许基本IO、文件权限变更、网络相关、同步、定时器和信号相关的系统调用;去掉所有capability;禁止提权;使用独立的临时目录;系统目录只读;主目录不可访问;只允许写入指定的数据目录。

第二级:严格限制,适合高风险服务。

[Unit]
Description=Strict Sandboxed Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/myservice
SystemCallFilter=@basic-io
SystemCallFilterNumber=231 232 233
SystemCallArchitectures=native
SystemCallErrorNumber=38
CapabilityBoundingSet=
NoNewPrivileges=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
LockPersonality=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myservice

[Install]
WantedBy=multi-user.target

这里SystemCallFilter只保留了@basic-io,也就是read、write、close、lseek、fstat、mmap、mprotect、brk、rt_sigreturn等最基本的调用。同时加入了PrivateDevices(不允许访问任何设备节点)、ProtectKernelTunables(禁止修改内核参数)、RestrictNamespaces(禁止创建新的namespace)等硬核限制。MemoryDenyWriteExecute禁止内存同时可写可执行,防止代码注入。

第三级:极端隔离,适合不信任的第三方程序。

[Unit]
Description=Maximum Isolation Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/untrusted
SystemCallFilter=@basic-io @process
SystemCallArchitectures=native
CapabilityBoundingSet=
NoNewPrivileges=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
LockPersonality=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectHostname=true
ProtectClock=true
ProtectKernelLogs=true
ReadWritePaths=/var/lib/untrusted
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

这个配置几乎把能关的都关了。RestrictAddressFamilies限制只能使用IPv4、IPv6和Unix域套接字,禁止使用其他网络协议族。ProtectHostname禁止修改主机名,ProtectClock禁止修改系统时钟,ProtectKernelLogs禁止读取内核日志。这基本上就是把服务关进了一个铁笼子里。

四、如何验证沙箱是否生效

配置写好之后,你必须验证它是否真正生效。最直接的方法是用strace跟踪进程的系统调用。

systemctl start myservice.service
pid=$(systemctl show myservice --property=MainPID --value)
strace -p $pid -e trace=all 2>&1 | head -50

如果你看到进程尝试执行某个系统调用后被终止,或者strace显示"Operation not permitted",说明过滤生效了。另一个方法是查看/proc/$pid/status中的Seccomp字段,如果显示为2(SECCOMP_MODE_FILTER),说明seccomp过滤器已加载。

cat /proc/$(systemctl show myservice --property=MainPID --value)/status | grep Seccomp

还可以用systemd-analyze security命令快速评估unit文件的安全等级:

systemd-analyze security myservice.service

这个命令会输出一个安全评分和具体的安全设置摘要,方便你快速发现遗漏的配置项。

五、常见踩坑点和解决方案

第一,配置过于严格导致服务无法启动。这是最常见的问题。比如你把SystemCallFilter设得太窄,服务需要的openat、stat、socket等调用被禁了。解决方法是先用SystemCallFilter=~@basic-io(注意波浪号,表示白名单模式)启动,然后用strace观察实际需要哪些调用,再逐步收紧。

第二,忽略了架构差异。在Debian上如果你同时支持amd64和arm64,SystemCallArchitectures=native没问题。但如果你在x86机器上写了x86专用的系统调用号,部署到arm机器上就会出问题。建议尽量使用预定义组而非具体调用号。

第三,忘记配合其他安全选项。单纯限制系统调用是不够的。如果你没有设置NoNewPrivileges=true,进程仍然可以通过setuid二进制文件提权;如果没有PrivateTmp=true,进程可能通过/tmp进行攻击。沙箱是一个组合拳,每个选项都是一块盾牌,缺一块就有漏洞。

第四,日志和监控被限制。过于严格的沙箱可能导致服务无法写入日志。你需要确保ReadWritePaths包含了日志目录,或者通过journald的StandardOutput=journal来让日志走systemd的日志通道,而不是写文件。

六、进阶:结合AppArmor和自定义seccomp规则

systemd的沙箱能力虽然强大,但它的SystemCallFilter本质上是一个粗粒度的白名单。如果你需要更精细的控制,比如"允许socket调用但只允许AF_INET族",systemd本身做不到。这时候你需要结合自定义seccomp规则。

你可以写一个独立的seccomp规则文件,然后在unit中指定:

[Service]
SystemCallFilter=/etc/systemd/system/myservice/seccomp-filter.bpf

或者使用AppArmor的profile来做更细粒度的文件访问控制。在Debian上,AppArmor和systemd沙箱是互补关系,不是替代关系。AppArmor管文件路径和网络访问,seccomp管系统调用,两者配合才是完整的纵深防御。

七、总结与建议

在Debian上用systemd沙箱限制系统调用,是一种低成本高收益的安全加固手段。它不需要额外安装软件,不需要修改内核,只需要在unit文件里加几行配置。但要注意,安全配置是一个渐进的过程,不要一上来就用最严格的策略,先跑起来,再逐步收紧。同时要建立监控机制,用strace和systemd-analyze security定期审计,确保安全策略没有因为服务升级而失效。真正的安全不是一次性配置,而是持续运营。