Linux 基础体系 · 第 37/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux Seccomp 与 Capabilities:系统调用过滤、最小权限和沙箱
在 Linux 中,“限制一个进程”至少涉及三个不同层次的问题:
- 它能调用哪些内核入口:由 Seccomp 过滤系统调用。
- 这些系统调用调用成功后能做什么:由 Linux Capabilities、文件权限、用户命名空间和 LSM 等共同决定。
- 它能看到和接触哪些对象:由 Mount、PID、Network、User 等命名空间,以及文件描述符、路径和设备权限决定。
Seccomp 和 Capabilities 经常被放在同一个安全配置中,但它们解决的不是同一个问题:
- Seccomp 约束“能调用什么”;
- Capabilities 约束“调用后是否有某种特权”;
- 命名空间约束“特权作用于哪个隔离范围”;
- SELinux/AppArmor 等 LSM 进一步约束“主体对具体对象执行什么操作”。
因此,下面这个推理通常是错误的:
进程没有 root,或者已经安装了 Seccomp,就已经被沙箱保护。
一个没有 root 的进程仍可能读取它有权限读取的敏感文件、使用继承来的网络连接,或者利用某个普通权限足够的内核接口;一个只过滤了少数危险系统调用的 Seccomp 配置,也不能替代文件系统和网络隔离。
一、先建立统一模型:进程究竟受哪些条件约束
一次系统调用是否最终成功,可以抽象成多个条件同时满足:
其中:
- 是调用线程;
- 是被操作的对象,例如文件、socket、进程或挂载点;
- 是系统调用及其参数;
seccomp判断该系统调用是否允许进入后续内核路径;kernel_semantics表示普通的 UID/GID、文件权限、对象状态等检查;capability表示是否拥有绕过某些普通权限检查的能力;LSM表示 SELinux、AppArmor 等安全模块的检查;namespace表示该对象是否存在于当前进程可见的隔离范围,以及相关特权作用在哪个用户命名空间中。
这个模型有两个重要推论。
1. Seccomp 拒绝发生得更早,但不能授予权限
如果 Seccomp 拒绝 openat(),即使进程拥有 CAP_DAC_OVERRIDE,也无法通过该调用打开文件。
反过来,如果 Seccomp 允许 openat(),进程仍可能因为:
- 文件模式不允许;
- 没有
CAP_DAC_OVERRIDE; - 被 SELinux/AppArmor 拒绝;
- 路径在当前 Mount namespace 中不可见;
而得到 EACCES、ENOENT 或其他错误。
2. Capabilities 不是系统调用白名单
拥有 CAP_NET_ADMIN 并不意味着进程只能调用网络相关系统调用;它仍然可以调用任意未被 Seccomp 阻止的系统调用。Capabilities 只在特定内核操作中参与权限判断。
同样,禁止 mount(2) 也不等于文件系统已经隔离。进程可能早已获得一个指向敏感目录的文件描述符,或者通过其他已开放接口访问资源。
二、Linux Capabilities:把 root 权限拆成多个能力
传统 Unix 权限主要用 UID 进行判断。UID 为 0 的 root 可以绕过大量普通检查,但这种“全有或全无”的模型不适合最小权限原则。
Linux Capabilities 将部分特权拆成命名能力,例如:
CAP_NET_BIND_SERVICE:绑定低于 1024 的 TCP/UDP 端口;CAP_NET_RAW:使用原始套接字等网络能力;CAP_CHOWN:绕过部分文件属主修改限制;CAP_DAC_OVERRIDE:绕过部分文件读写和搜索权限;CAP_SYS_PTRACE:影响其他进程的调试和跟踪;CAP_SYS_ADMIN:包含大量系统管理操作,范围非常宽,通常不能视为“单一小权限”。
能力名称由内核定义,具体语义必须以目标内核版本的 capabilities(7) 和内核实现为准。某项能力可以覆盖多个看似无关的操作,尤其是 CAP_SYS_ADMIN,不能因为名称中有 SYS 就把它理解为普通的“系统调用管理员权限”。
1. 能力是线程属性,不是简单的用户属性
Linux 为线程维护多组能力集合。常用集合如下:
| 集合 | 含义 |
|---|---|
| Permitted | 线程允许放入 Effective 的能力上限 |
| Effective | 当前内核权限检查实际使用的能力 |
| Inheritable | execve() 时可参与能力继承的集合 |
| Ambient | 非特权程序执行普通文件时可保留并进入新程序的集合 |
| Bounding | 通过 execve() 获得文件能力时的全局上限之一 |
可以把它们理解为:
Permitted是“可以使用的能力库存”;Effective是“当前已经启用的能力”;Inheritable是“允许跨程序映像传递的候选集合”;Ambient是“希望普通execve()后仍保留的能力”;Bounding是“不能通过后续execve()获得的能力上限”。
查看当前进程状态:
grep -E '^(Cap|NoNewPrivs|Seccomp):' /proc/self/status
可能得到类似结果:
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
NoNewPrivs: 0
Seccomp: 0
这些 Cap* 是十六进制位图,不应直接按字符串比较。可以使用 libcap 工具解释:
capsh --print
不同发行版可能默认安装 libcap、libcap2-bin 或单独的软件包;capsh 不属于所有最小系统镜像的默认组件。
2. Effective 决定当前大多数能力检查
当内核代码执行类似如下检查时:
capable(CAP_NET_BIND_SERVICE)
通常会检查当前线程的 Effective 集合,而不是只看 UID 是否为 0。
因此,一个非 root 进程可以仅携带:
CAP_NET_BIND_SERVICE
然后监听 80 端口,却没有修改任意文件或管理网络设备的能力。
相反,进程即使拥有 CAP_NET_BIND_SERVICE,也不能因此:
- 读取其他用户无权读取的文件;
- 修改路由;
- 加载内核模块;
- ptrace 任意进程。
3. execve() 如何改变能力
进程执行新程序时,能力集合可能根据进程自身集合和文件能力重新计算。简化表示如下:
其中:
- 表示执行
execve()前的进程集合; - 表示可执行文件的文件能力;
- 撇号表示执行新程序后的集合。
Effective 集合还受到可执行文件 Effective 标志和 Ambient 能力影响。若执行的是特权文件,例如带 set-user-ID 或文件能力的程序,Ambient 能力会被清除;具体转换规则还涉及文件系统是否支持安全存储 xattr、挂载的 nosuid 属性以及内核版本。
查看文件能力:
getcap /path/to/program
为一个程序赋予绑定低端口所需的能力:
sudo setcap 'cap_net_bind_service=ep' /path/to/server
getcap /path/to/server
这里的 ep 表示:
e:文件能力中的 Effective 标志;p:文件 Permitted 能力。
这个操作的实际效果是:任意能够执行该文件的用户,都可能获得该文件声明的能力。它不是“只给当前服务用户临时授权”。因此必须同时检查:
- 文件是否可被其他用户替换;
- 所有父目录是否可写;
- 文件是否位于可信文件系统;
- 部署系统是否会覆盖或丢失
security.capabilityxattr; - 文件系统或挂载选项是否启用了
nosuid,导致文件能力不生效。
生产环境中,如果服务管理器支持直接配置能力,通常应优先让服务管理器在启动时设置并丢弃能力,而不是把能力永久写入全局可执行文件。
4. Bounding 集合是不可逆的收缩操作
线程可以主动从 Bounding 集合中删除能力:
prctl(PR_CAPBSET_DROP, CAP_SYS_ADMIN, 0, 0, 0);
删除后,当前进程及其后代不能再通过执行带文件能力的程序获得该能力。该操作通常不可逆,除非重新创建具有更高权限的进程上下文。
这说明“启动前后顺序”很重要:
- 先完成确实需要特权的初始化;
- 删除不再需要的 Bounding 能力;
- 清空 Effective 和 Permitted 中不必要的能力;
- 再启动不可信代码。
如果一开始就删除了服务仍需要的能力,常见失败表现是 EPERM;恢复通常需要重新启动一个未收缩的服务实例,而不是在当前进程中“重新申请”能力。
5. User namespace 改变能力的作用范围
User namespace 允许进程在一个新的用户命名空间中成为该命名空间的 root,并获得该命名空间内的部分能力。但这不等于获得宿主机 root。
例如,进程在 User namespace 中可能具有 CAP_SYS_ADMIN,它可以管理该命名空间允许管理的 Mount namespace;但它不能因此管理宿主机的挂载,也不能任意修改宿主机上的 root-owned 文件。
因此,讨论 Capabilities 时必须同时问:
这个能力属于哪个 user namespace?它操作的对象属于哪个 namespace?
容器运行时通常会组合:
- User namespace:重映射 UID/GID;
- Mount namespace:隔离根文件系统和挂载;
- PID namespace:隔离进程视图;
- Network namespace:隔离接口、路由和端口空间;
- IPC、UTS、Cgroup namespace:隔离其他内核资源。
Capabilities 只回答“是否具有某类特权”,命名空间才回答“该特权作用在哪个世界”。
三、Seccomp:在系统调用入口处设置策略
Seccomp 是 Secure Computing 的缩写,Linux 通过它限制进程能够执行的系统调用。
现代 Seccomp 的核心接口是:
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
或者使用:
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, flags, &prog);
过滤器通常由经典 BPF 指令组成。它不是 eBPF 程序,也不能任意访问内核内存;它接收一个 seccomp_data 上下文,其中包括:
- 系统调用号;
- 系统调用架构标识;
- 指令指针;
- 最多六个系统调用参数。
1. 两种模式
Strict mode
SECCOMP_MODE_STRICT 只允许极少数传统系统调用,主要是:
readwrite_exitsigreturn
它适合非常特殊的、已经知道全部执行路径的程序。普通动态链接程序通常无法在启动后正常运行,因为动态加载、线程库、内存分配和信号处理会使用大量其他系统调用。
Filter mode
SECCOMP_MODE_FILTER 使用 BPF 自定义策略,可以按系统调用号、架构和部分参数作出决定。实际工程中几乎总是使用 Filter mode。
安装 Filter mode 通常需要满足以下条件之一:
- 线程具有
CAP_SYS_ADMIN; - 或线程先设置
no_new_privs。
no_new_privs 是一个单调的进程属性:一旦设置为 1,当前进程及其后代不能通过 execve() 获得新的特权,例如通过 set-user-ID 或文件能力获得额外权限。它不会自动删除当前已有能力,也不会自动隔离文件系统。
2. 过滤器返回动作
常见动作包括:
| 动作 | 效果 |
|---|---|
SECCOMP_RET_ALLOW |
放行 |
SECCOMP_RET_ERRNO |
不进入系统调用实现,直接返回指定 errno |
SECCOMP_RET_KILL_PROCESS |
终止整个进程 |
SECCOMP_RET_KILL_THREAD |
终止当前线程 |
SECCOMP_RET_TRAP |
触发 SIGSYS |
SECCOMP_RET_LOG |
允许调用,同时请求内核记录日志 |
SECCOMP_RET_USER_NOTIF |
将请求交给用户态监听器处理 |
SECCOMP_RET_TRACE |
与 ptrace 协作 |
多个过滤器可以叠加。过滤器不能撤销之前已经获得的权限,但更严格的动作优先级会生效。通常的优先级从强到弱为:
KILL_PROCESS
KILL_THREAD
TRAP
ERRNO
USER_NOTIF
TRACE
LOG
ALLOW
USER_NOTIF 不是“把安全判断交给一个可信内核组件”。它把请求交给用户态进程,监听器本身若被攻破、判断错误或存在竞态,就可能成为新的攻击面。
四、一个可运行的 Seccomp 示例
下面程序面向 x86-64 Linux,完成以下操作:
- 设置
no_new_privs; - 安装 Seccomp BPF;
- 只允许
write、exit和exit_group; - 其他系统调用返回
EPERM; - 使用原始
write输出,避免printf等库函数在过滤器安装后引入额外系统调用。
// seccomp_min.c
#define _GNU_SOURCE
#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
#define SC_ALLOW(syscall_nr) \
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, syscall_nr, 0, 1), \
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)
static int install_filter(void)
{
struct sock_filter filter[] = {
/* 只接受 x86-64 ABI;其他架构直接终止 */
BPF_JUMP(
BPF_JMP | BPF_JEQ | BPF_K,
AUDIT_ARCH_X86_64,
1,
0
),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
SC_ALLOW(SYS_write),
SC_ALLOW(SYS_exit),
SC_ALLOW(SYS_exit_group),
/* 其他系统调用返回 EPERM */
BPF_STMT(
BPF_RET | BPF_K,
SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)
)
};
struct sock_fprog program = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
/*
* 没有 no_new_privs 时,普通非特权进程通常不能安装过滤器。
* 设置后,后续 execve() 不能通过 setuid 或文件能力获得新特权。
*/
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
perror("PR_SET_NO_NEW_PRIVS");
return -1;
}
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) == -1) {
perror("PR_SET_SECCOMP");
return -1;
}
return 0;
}
int main(void)
{
static const char message[] = "seccomp: write is allowed\n";
if (install_filter() == -1)
return EXIT_FAILURE;
/*
* write 被允许,可以成功。
*/
if (syscall(SYS_write, STDOUT_FILENO, message,
sizeof(message) - 1) == -1) {
/*
* 这里的 perror 可能调用被禁止的系统调用,因此不能依赖。
*/
_exit(2);
}
/*
* getpid 没有加入白名单,返回 EPERM。
*/
long pid = syscall(SYS_getpid);
if (pid != -1 || errno != EPERM)
_exit(3);
syscall(SYS_write, STDOUT_FILENO,
"seccomp: getpid was denied\n", 28);
syscall(SYS_exit, 0);
__builtin_unreachable();
}
编译和运行:
gcc -Wall -Wextra -O2 seccomp_min.c -o seccomp_min
./seccomp_min
预期输出:
seccomp: write is allowed
seccomp: getpid was denied
这个示例有几个容易被忽略的前提。
1. 架构检查不可省略
系统调用号不是跨架构稳定的。例如 x86-64、i386、AArch64 的编号不同。若只检查一个数字而不检查 seccomp_data.arch,同一过滤器在其他 ABI 上可能把另一个系统调用误判为被允许的调用。
此外,某些架构存在兼容 ABI;生产策略必须明确程序支持的架构和 ABI,而不能假设“系统调用号在所有 Linux 上相同”。
2. 白名单必须覆盖完整生命周期
示例在安装过滤器前已经完成了 C 运行时启动。若过滤器安装得更早,动态链接器、线程库、内存分配器和日志组件可能需要:
mmap、mprotect、brk;rt_sigaction、rt_sigprocmask;futex;openat、newfstatat;clock_gettime;exit_group。
缺少任意一个,程序都可能在安装过滤器后失败。白名单不是按“业务名称”写出来的,而是按实际执行路径验证出来的。
3. ERRNO 和杀死进程是不同的故障语义
使用:
SECCOMP_RET_ERRNO | EPERM
时,应用可以捕获错误并继续运行。这便于兼容,但如果应用在权限错误后采取了不安全的降级路径,安全边界可能被绕过。
使用:
SECCOMP_RET_KILL_PROCESS
时,违规行为会终止进程,故障更明显,但可能造成服务中断。对不可信代码,杀死进程通常比伪造普通权限错误更容易审计;对复杂生产服务,则必须先确认依赖和恢复策略。
五、Seccomp 的数据流和线程行为
一次被过滤的系统调用大致经过如下路径:
flowchart TD
A[用户线程执行 syscall 指令] --> B[进入内核系统调用入口]
B --> C[Seccomp BPF 读取 syscall_nr、arch、参数]
C -->|拒绝| D[返回 errno、SIGSYS 或终止进程]
C -->|允许| E[系统调用实现]
E --> F[UID/GID、Capabilities、LSM、namespace 检查]
F -->|失败| G[返回 EACCES、EPERM 等]
F -->|成功| H[修改内核对象或返回结果]
关键点是:Seccomp 通常发生在具体系统调用实现之前,而 Capabilities、LSM 和对象状态检查发生在后续路径中。因此:
- Seccomp 允许不代表操作成功;
- Seccomp 拒绝时,后续能力检查根本不会发生;
- 一个被允许的通用系统调用仍可能包含非常大的攻击面。
1. Fork、clone 和 execve 的继承
Seccomp 过滤器会随线程的进程执行上下文继承:
fork()或clone()创建的子进程通常继承过滤器;execve()不会自动清除过滤器;- 子进程不能通过普通
execve()放宽已安装的过滤器。
多线程程序有特殊问题:过滤器首先作用于安装它的线程。如果希望同步到同一线程组中的其他线程,需要使用:
seccomp(SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_TSYNC,
&program);
TSYNC 可能失败,例如其他线程已有无法合并的 Seccomp 状态。若忽略返回值,就可能出现“主线程已过滤、工作线程未过滤”的错误安全假设。
另一个常见竞态是:
- 创建工作线程;
- 只在主线程安装过滤器;
- 误以为整个服务都已沙箱化。
正确做法要么在创建线程前安装过滤器,要么明确使用并验证 TSYNC。
2. Seccomp 过滤的是线程,不是容器或用户
Seccomp 的主体是进程线程组中的线程执行上下文。它不会自动限制:
- 同一用户启动的其他进程;
- 共享同一容器的其他服务;
- 通过 Unix socket 与其通信的对端;
- 宿主机上的其他任务。
所以 Seccomp 不能替代容器边界,也不能替代服务间认证。
3. 文件描述符是已经获得的能力
Seccomp 过滤器安装后,进程仍可使用安装前获得的文件描述符。例如:
- 父进程以较高权限打开
/etc/shadow; - 将该 FD 传给子进程;
- 子进程安装 Seccomp;
- 子进程不需要再次调用
openat(),仍可读取该 FD。
这不是 Seccomp 失效,而是过滤器只限制后续系统调用。沙箱启动时必须审查:
- 标准输入、输出、错误;
- 继承的目录、文件和 socket FD;
- Unix socket 的对端;
SCM_RIGHTS传递的 FD;/proc、/sys、设备节点和匿名文件描述符。
六、Seccomp 的参数过滤:能力有限且容易误判
只按系统调用号过滤,粒度较粗。例如允许 ioctl(),实际上等于允许大量不同的设备和子命令;允许 socket(),还需要考虑:
- address family;
- socket type;
- protocol;
- 后续
bind、connect、setsockopt的参数。
BPF 可以比较系统调用参数的整数值,但它不是通用的指针安全解析器。对于指向用户空间内存的参数,过滤器不能安全地遍历任意长度的数据结构,也不应假设字符串内容可直接在 BPF 中验证。
因此下面这种策略通常不可靠:
允许 ioctl,因为应用需要一个 ioctl
允许 openat,因为应用需要读一个配置文件
允许 socket,因为应用需要网络
更稳妥的方向是:
- 通过 Mount namespace 让应用只能看到需要的配置目录;
- 在启动阶段打开配置文件,之后删除
openat; - 使用专用 Unix socket 或代理替代任意
ioctl; - 使用 Network namespace 和防火墙限制网络对象;
- 让程序在过滤器安装后进入明确、稳定的执行阶段。
openat() 的路径检查不能代替文件系统隔离
即使尝试用 BPF 比较 openat() 的标志参数,也很难根据 pathname 指针判断真实路径。路径解析还涉及:
- 符号链接;
..;- Bind mount;
- Mount namespace;
- 当前工作目录;
/proc/self/fd;- 魔术链接和文件描述符路径。
这也是为什么“Seccomp 只允许某个路径”通常不是可靠的路径沙箱。路径边界应主要交给 Mount namespace、openat2() 的解析约束、文件权限和 LSM 组合完成。
七、Capabilities 与 Seccomp 的组合推导
假设一个 Web 服务需要监听 443 端口,但不需要修改网络设备、不需要加载模块,也不需要管理挂载点。
可以把权限收缩分成三步。
第一步:只保留必要 Capability
服务启动时暂时拥有:
CAP_NET_BIND_SERVICE
CAP_NET_ADMIN
CAP_SYS_ADMIN
它绑定 443 后,若业务不再需要低端口绑定,可继续清空 CAP_NET_BIND_SERVICE。更重要的是删除:
CAP_NET_ADMIN
CAP_SYS_ADMIN
如果只是希望删除当前 Effective 能力,而保留 Permitted 以便后续重新启用,保护强度较弱;若确定不再需要,应同时从 Permitted 和 Bounding 中删除。
第二步:安装 Seccomp
服务启动阶段可能需要:
socket, bind, listen, accept4, epoll_wait, mmap, futex, clock_gettime
初始化完成后,可以安装更严格的运行期过滤器,禁止:
mount, ptrace, kexec_load, init_module, finit_module
但必须注意:禁止某个系统调用并不一定等于禁止对应能力。例如限制网络设备管理,不能只依赖禁止 ioctl,还应删除 CAP_NET_ADMIN,并隔离 Network namespace。
第三步:隔离对象空间
即使服务没有 CAP_SYS_ADMIN,如果它仍能看到宿主机完整的:
/proc
/sys
/dev
或者能访问宿主机 Docker socket,它仍可能获得非常大的控制能力。
因此典型组合是:
最小 UID/GID
+ 最小 Capabilities
+ no_new_privs
+ Seccomp allowlist
+ 只读或最小化 Mount namespace
+ 必要时 User/PID/Network namespace
+ SELinux/AppArmor
+ 清理继承 FD
这不是把多个“安全开关”简单叠加,而是让每个层次弥补其他层次的盲点。
八、常见失败表现与诊断方法
1. EPERM:可能来自很多层
出现:
Operation not permitted
不能立即断定是 Seccomp。可能原因包括:
- 缺少对应 Capability;
- User namespace 不允许该特权;
- LSM 拒绝;
- 文件系统或挂载选项限制;
- Seccomp 返回
ERRNO(EPERM); - 内核对象状态不允许操作。
应结合以下信息:
grep -E '^(Cap|NoNewPrivs|Seccomp):' /proc/$PID/status
capsh --print
如果程序在 Seccomp 过滤后调用被拒绝的系统调用,strace 通常能看到类似:
getpid() = -1 EPERM (Operation not permitted)
但 strace 只展示系统调用层面的结果,不能单独证明拒绝来自 Seccomp;需要结合过滤器、内核审计日志和 LSM 日志判断。
2. SIGSYS:通常是 TRAP 或架构问题
如果过滤器使用 SECCOMP_RET_TRAP,违规调用可能使进程收到 SIGSYS。常见现象是:
Bad system call (core dumped)
也可能因为架构判断错误而在程序启动后立即终止。排查时优先确认:
seccomp_data.arch
系统调用 ABI
是否运行了 32 位兼容代码
3. NoNewPrivs: 1 不等于没有能力
检查结果:
NoNewPrivs: 1
只说明后续执行程序不能获得新的特权。当前已有的 CapEff、文件描述符、网络连接和可见文件不会因此自动消失。
4. Seccomp: 2 不等于策略正确
/proc/$PID/status 中常见:
Seccomp: 0:未使用 Seccomp;Seccomp: 1:strict mode;Seccomp: 2:filter mode。
它只能说明模式,不能说明过滤器是否足够严格。一个只拒绝 reboot 的 Filter mode 和一个严格系统调用白名单,显示的模式相同。
5. 使用日志动作时不要假设一定有日志
SECCOMP_RET_LOG 的记录受内核配置、日志级别和审计系统影响。过滤器动作、内核版本、发行版日志规则都可能影响可见性。生产排查时应同时检查:
journalctl -k
dmesg
ausearch -m SECCOMP
其中审计工具是否安装、内核是否启用相关审计能力,取决于发行版和部署环境。
九、设计 allowlist 时的实际流程
一个可验证的 Seccomp 策略通常按以下顺序形成,而不是先从网上复制一张系统调用列表。
1. 固定运行阶段
把程序拆成:
- 启动和加载配置;
- 绑定端口、打开日志;
- 创建线程;
- 稳定服务循环;
- 关闭和恢复。
启动阶段和运行阶段需要的系统调用通常不同。若一开始就安装运行期白名单,初始化容易失败;若永远保留启动阶段权限,攻击面又会扩大。
2. 记录真实系统调用
在受控环境中使用:
strace -f -o trace.log ./server --test-config
然后按实际执行路径整理系统调用。strace 观测的是测试覆盖到的路径,不能证明所有路径都已经覆盖,尤其不能覆盖:
- 异常处理;
- 超时;
- 磁盘错误;
- TLS 重协商;
- 配置热加载;
- 特定架构;
- 特定内核版本。
3. 先按系统调用族理解,再逐项收缩
不要把 strace 输出直接变成白名单。应问:
- 为什么需要这个系统调用?
- 是否只在初始化阶段需要?
- 是否能通过修改程序消除它?
- 是否能用更窄的参数约束替代?
- 是否允许了一个过大的接口,例如
ioctl、bpf、perf_event_open、userfaultfd?
4. 对拒绝动作做故障测试
至少测试:
- 正常启动;
- 正常请求;
- 无效请求;
- 子进程创建;
- 信号;
- 日志轮转;
- 配置重新加载;
- 连接关闭;
- 磁盘和网络异常;
- 服务重启。
过滤器的安全性来自“拒绝了不需要的路径”,可用性则来自“保留了所有必要路径”。两者必须在相同内核、相同架构和相同启动方式下验证。
十、与容器和命名空间的边界
Seccomp 和 Capabilities 经常被容器运行时一起配置,但容器并不是单一安全机制。
一个典型容器进程可能同时具有:
Mount namespace:看到精简后的根文件系统
PID namespace:看不到宿主机普通进程
Network namespace:拥有独立网络栈
User namespace:容器 root 映射为宿主机非 root
Capabilities:删除大部分特权
Seccomp:拒绝高风险系统调用
LSM:限制具体文件和进程操作
Cgroup:限制资源使用
去掉某一个组件并不会由其他组件自动补偿。例如:
- 只有 Network namespace,没有 Seccomp:仍可能调用危险内核接口;
- 只有 Seccomp,没有 Mount namespace:仍可能读取可见文件;
- 只有 Capabilities drop,没有 User namespace:进程仍可能拥有宿主机 UID;
- 只有 User namespace,没有资源控制:仍可能消耗大量 CPU、内存或文件描述符。
尤其要谨慎处理以下接口:
mount、umount2;unshare、setns;bpf;perf_event_open;userfaultfd;io_uring_setup;keyctl;ptrace;- 设备相关
ioctl。
它们的风险与内核版本、配置、命名空间和 Capability 状态有关,不能仅凭系统调用名称做绝对判断。生产策略应以目标内核的安全公告、发行版补丁和运行时默认策略为依据。
十一、SSH 场景中的组合使用
OpenSSH 本身提供了若干会话级限制,但这些配置与 Seccomp、Capabilities 处于不同层次。
例如,对专用账号可以考虑:
Match User restricted
ForceCommand internal-sftp
DisableForwarding yes
ChrootDirectory /srv/sftp/%u
这些指令的可用性和精确行为随 OpenSSH 版本变化,应以目标版本的 sshd_config(5) 为准。
它们解决的是:
- 登录后执行什么命令;
- 是否允许端口转发、代理转发和其他转发能力;
- 会话看到的根目录是什么。
它们不自动保证:
- 进程使用了最小 Capabilities;
- 所有系统调用都被过滤;
- 继承的 FD 已经清理;
- Chroot 后没有危险设备节点;
- 内核漏洞无法被利用。
如果需要为 SSH 启动的特定命令安装 Seccomp,通常应通过受控 wrapper 或服务管理器完成,并明确 wrapper 自身的 execve()、日志、环境变量和 FD 处理。不能只在交互式 shell 中手工执行一次 prctl,然后假设后续 SSH 会话都继承该状态。
十二、真实边界与生产取舍
1. Denylist 通常只能阻挡已知问题
“禁止几个危险系统调用、其余全部允许”维护成本低,但无法证明最小权限,也可能遗漏后来发现的攻击面。
Allowlist 更强,但需要持续维护。内核升级、glibc 升级、启用 DNS、线程模型变化,都可能引入新的系统调用需求。
2. 过滤器不能防止内核漏洞本身
Seccomp 可以减少可达内核代码路径,但无法保证允许的系统调用没有漏洞。Capabilities 和命名空间也不能修复内核缺陷。主机仍需要:
- 及时安装内核和发行版安全更新;
- 使用 SELinux/AppArmor;
- 记录和审计异常行为;
- 约束容器运行时 socket 和设备;
- 控制服务账号、文件权限和密钥暴露。
3. 性能不是唯一代价
Seccomp BPF 本身通常不是设计中的主要瓶颈,但复杂过滤器会增加维护和验证成本。真正明显的代价往往来自:
- 误拒绝导致服务不可用;
- 为兼容性保留过多系统调用;
- User namespace、Mount namespace 和 LSM 配置复杂;
- 调试时错误表现被包装成
EPERM; - 多线程安装过滤器时产生竞态或同步失败。
4. 能力名称小,不代表风险小
CAP_SYS_ADMIN、CAP_SYS_PTRACE、CAP_NET_ADMIN 等能力可能影响非常大的对象范围。最小权限不是“能力数量越少越安全”这一条简单计数规则,而是要结合:
- 能力具体语义;
- 所属 User namespace;
- 可见对象;
- 可调用的系统调用;
- 可访问的文件描述符;
- LSM 策略。
结语:把“调用权”“特权”和“对象范围”分开收缩
Seccomp、Capabilities 和沙箱的正确关系可以概括为:
Seccomp 限制系统调用入口
Capabilities 限制部分特权操作
Namespaces 限制资源和对象的可见范围
LSM 限制主体对具体对象的操作
FD 管理 限制已经持有和可传递的资源
一个更可靠的沙箱设计,需要沿着完整执行路径推导:
- 进程启动前拥有哪些 UID、GID、Capabilities 和文件描述符;
execve()后哪些能力会被继承或获得;- 程序初始化和稳定运行分别需要哪些系统调用;
- 每个允许的系统调用会接触哪些文件、socket、进程和设备;
- 这些对象位于哪个 namespace,受到哪些 LSM 规则保护;
- 拒绝后是返回错误、触发
SIGSYS,还是终止整个进程; - 多线程、子进程、重启和异常路径是否仍处于同一安全状态。
只有同时收缩“能调用什么”“调用后能做什么”和“能接触什么”,最小权限和沙箱才不是配置文件中的几个开关,而是一个可以验证的内核安全边界。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux namespaces 深入:PID、Mount、Network、User 和隔离组合
- 下一篇:Linux NUMA 与硬件拓扑:节点、内存亲和、跨节点访问和诊断
- 延伸:Linux 主机安全加固:最小化、SELinux/AppArmor、审计与漏洞治理
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论