Linux 基础体系 · 第 37/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux Seccomp 与 Capabilities:系统调用过滤、最小权限和沙箱

在 Linux 中,“限制一个进程”至少涉及三个不同层次的问题:

  1. 它能调用哪些内核入口:由 Seccomp 过滤系统调用。
  2. 这些系统调用调用成功后能做什么:由 Linux Capabilities、文件权限、用户命名空间和 LSM 等共同决定。
  3. 它能看到和接触哪些对象:由 Mount、PID、Network、User 等命名空间,以及文件描述符、路径和设备权限决定。

Seccomp 和 Capabilities 经常被放在同一个安全配置中,但它们解决的不是同一个问题:

  • Seccomp 约束“能调用什么”;
  • Capabilities 约束“调用后是否有某种特权”;
  • 命名空间约束“特权作用于哪个隔离范围”;
  • SELinux/AppArmor 等 LSM 进一步约束“主体对具体对象执行什么操作”。

因此,下面这个推理通常是错误的:

进程没有 root,或者已经安装了 Seccomp,就已经被沙箱保护。

一个没有 root 的进程仍可能读取它有权限读取的敏感文件、使用继承来的网络连接,或者利用某个普通权限足够的内核接口;一个只过滤了少数危险系统调用的 Seccomp 配置,也不能替代文件系统和网络隔离。


一、先建立统一模型:进程究竟受哪些条件约束

一次系统调用是否最终成功,可以抽象成多个条件同时满足:

success(c,o,s)=seccomp(c,s)kernel_semantics(c,o)capability(c,o)LSM(c,o)namespace(c,o)\mathrm{success}(c,o,s) = \mathrm{seccomp}(c,s) \land \mathrm{kernel\_semantics}(c,o) \land \mathrm{capability}(c,o) \land \mathrm{LSM}(c,o) \land \mathrm{namespace}(c,o)

其中:

  • cc 是调用线程;
  • oo 是被操作的对象,例如文件、socket、进程或挂载点;
  • ss 是系统调用及其参数;
  • 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 中不可见;

而得到 EACCESENOENT 或其他错误。

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

不同发行版可能默认安装 libcaplibcap2-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() 如何改变能力

进程执行新程序时,能力集合可能根据进程自身集合和文件能力重新计算。简化表示如下:

Ppermitted=(PinheritableFinheritable)(FpermittedPbounding)PambientP'_{\text{permitted}} = (P_{\text{inheritable}} \cap F_{\text{inheritable}}) \cup (F_{\text{permitted}} \cap P_{\text{bounding}}) \cup P'_{\text{ambient}}

其中:

  • PP 表示执行 execve() 前的进程集合;
  • FF 表示可执行文件的文件能力;
  • 撇号表示执行新程序后的集合。

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.capability xattr;
  • 文件系统或挂载选项是否启用了 nosuid,导致文件能力不生效。

生产环境中,如果服务管理器支持直接配置能力,通常应优先让服务管理器在启动时设置并丢弃能力,而不是把能力永久写入全局可执行文件。

4. Bounding 集合是不可逆的收缩操作

线程可以主动从 Bounding 集合中删除能力:

prctl(PR_CAPBSET_DROP, CAP_SYS_ADMIN, 0, 0, 0);

删除后,当前进程及其后代不能再通过执行带文件能力的程序获得该能力。该操作通常不可逆,除非重新创建具有更高权限的进程上下文。

这说明“启动前后顺序”很重要:

  1. 先完成确实需要特权的初始化;
  2. 删除不再需要的 Bounding 能力;
  3. 清空 Effective 和 Permitted 中不必要的能力;
  4. 再启动不可信代码。

如果一开始就删除了服务仍需要的能力,常见失败表现是 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 只允许极少数传统系统调用,主要是:

  • read
  • write
  • _exit
  • sigreturn

它适合非常特殊的、已经知道全部执行路径的程序。普通动态链接程序通常无法在启动后正常运行,因为动态加载、线程库、内存分配和信号处理会使用大量其他系统调用。

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,完成以下操作:

  1. 设置 no_new_privs
  2. 安装 Seccomp BPF;
  3. 只允许 writeexitexit_group
  4. 其他系统调用返回 EPERM
  5. 使用原始 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 运行时启动。若过滤器安装得更早,动态链接器、线程库、内存分配器和日志组件可能需要:

  • mmapmprotectbrk
  • rt_sigactionrt_sigprocmask
  • futex
  • openatnewfstatat
  • 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 状态。若忽略返回值,就可能出现“主线程已过滤、工作线程未过滤”的错误安全假设。

另一个常见竞态是:

  1. 创建工作线程;
  2. 只在主线程安装过滤器;
  3. 误以为整个服务都已沙箱化。

正确做法要么在创建线程前安装过滤器,要么明确使用并验证 TSYNC

2. Seccomp 过滤的是线程,不是容器或用户

Seccomp 的主体是进程线程组中的线程执行上下文。它不会自动限制:

  • 同一用户启动的其他进程;
  • 共享同一容器的其他服务;
  • 通过 Unix socket 与其通信的对端;
  • 宿主机上的其他任务。

所以 Seccomp 不能替代容器边界,也不能替代服务间认证。

3. 文件描述符是已经获得的能力

Seccomp 过滤器安装后,进程仍可使用安装前获得的文件描述符。例如:

  1. 父进程以较高权限打开 /etc/shadow
  2. 将该 FD 传给子进程;
  3. 子进程安装 Seccomp;
  4. 子进程不需要再次调用 openat(),仍可读取该 FD。

这不是 Seccomp 失效,而是过滤器只限制后续系统调用。沙箱启动时必须审查:

  • 标准输入、输出、错误;
  • 继承的目录、文件和 socket FD;
  • Unix socket 的对端;
  • SCM_RIGHTS 传递的 FD;
  • /proc/sys、设备节点和匿名文件描述符。

六、Seccomp 的参数过滤:能力有限且容易误判

只按系统调用号过滤,粒度较粗。例如允许 ioctl(),实际上等于允许大量不同的设备和子命令;允许 socket(),还需要考虑:

  • address family;
  • socket type;
  • protocol;
  • 后续 bindconnectsetsockopt 的参数。

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 输出直接变成白名单。应问:

  • 为什么需要这个系统调用?
  • 是否只在初始化阶段需要?
  • 是否能通过修改程序消除它?
  • 是否能用更窄的参数约束替代?
  • 是否允许了一个过大的接口,例如 ioctlbpfperf_event_openuserfaultfd

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、内存或文件描述符。

尤其要谨慎处理以下接口:

  • mountumount2
  • unsharesetns
  • 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_ADMINCAP_SYS_PTRACECAP_NET_ADMIN 等能力可能影响非常大的对象范围。最小权限不是“能力数量越少越安全”这一条简单计数规则,而是要结合:

  • 能力具体语义;
  • 所属 User namespace;
  • 可见对象;
  • 可调用的系统调用;
  • 可访问的文件描述符;
  • LSM 策略。

结语:把“调用权”“特权”和“对象范围”分开收缩

Seccomp、Capabilities 和沙箱的正确关系可以概括为:

Seccomp       限制系统调用入口
Capabilities  限制部分特权操作
Namespaces    限制资源和对象的可见范围
LSM           限制主体对具体对象的操作
FD 管理       限制已经持有和可传递的资源

一个更可靠的沙箱设计,需要沿着完整执行路径推导:

  1. 进程启动前拥有哪些 UID、GID、Capabilities 和文件描述符;
  2. execve() 后哪些能力会被继承或获得;
  3. 程序初始化和稳定运行分别需要哪些系统调用;
  4. 每个允许的系统调用会接触哪些文件、socket、进程和设备;
  5. 这些对象位于哪个 namespace,受到哪些 LSM 规则保护;
  6. 拒绝后是返回错误、触发 SIGSYS,还是终止整个进程;
  7. 多线程、子进程、重启和异常路径是否仍处于同一安全状态。

只有同时收缩“能调用什么”“调用后能做什么”和“能接触什么”,最小权限和沙箱才不是配置文件中的几个开关,而是一个可以验证的内核安全边界。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。