Linux Seccomp Notify 与用户态回调扩展安全沙箱:从容器隔离到高级系统调用虚拟化

在现代 Linux 安全架构中,seccomp(Secure Computing mode)是容器运行时(Docker、Podman、Kubernetes)、沙箱(Firecracker、gVisor)和浏览器沙箱(Chromium、Firefox)的核心防线。传统的 SECCOMP_MODE_STRICT 和 SECCOMP_MODE_FILTER 采用白名单策略——不被允许的系统调用直接导致进程被 SIGSYS 终止。这种"非黑即白"的模型在大多数场景下足够安全,但也暴露出一个根本性缺陷:无法支持那些"有时需要、有时危险"的系统调用。

Linux 5.0 引入的 SECCOMP_RET_USER_NOTIF(Seccomp Notify)开创了全新的范式——将"信任决策"从内核转移到用户态 supervisor 进程。本文将深入剖析 Notify 机制的内核实现、API 编程模型、在容器安全中的实战应用(特别是根容器内运行需要 mount/chmod 的场景),以及如何基于此构建一个完整的系统调用虚拟化层。

一、Seccomp 的演进:从白名单到可审计的拦截

1.1 传统 Seccomp Filter 的局限

经典的 BPF seccomp 过滤器在系统调用入口执行,对每个 syscall 只能返回以下 verdict 之一:

SECCOMP_RET_ALLOW    // 放行
SECCOMP_RET_KILL     // 杀死进程
SECCOMP_RET_TRAP     // 发送 SIGSYS
SECCOMP_RET_ERRNO    // 返回错误码
SECCOMP_RET_TRACE    // 通知 ptrace tracer
SECCOMP_RET_LOG      // 记录后放行

当一个容器需要运行 strace、gdb、fakeroot 或需要文件系统操作的工具时,这种二元模型直接撞上矛盾:如果允许相关 syscall(如 ptrace、mount、chmod),则容器逃逸风险陡增;如果禁止,则正常功能无法使用。

1.2 为什么 Ptrace 不够用

SECCOMP_RET_TRACE 系统调用通知 ptrace tracer 进程,但基于 ptrace 的方案有几个难以克服的缺陷:

  • 语义鸿沟:tracer 需要理解每个 syscall 的 ABI(参数结构、指针解引用等),实现一个通用的 syscall 转发器极其困难
  • 性能开销:每次 syscall 触发两次上下文切换(被 trace 进程 ↔ tracer),对于 syscall 密集负载开销极大
  • 语义保持:ptrace 修改的是被 trace 进程的寄存器,无法自然地在另一个进程中"模拟" syscall 并回写结果
  • 安全边界模糊:ptrace tracer 拥有对被 trace 进程的完全控制权,打破了最小特权原则

Seccomp Notify 的设计目标是:让一个监督进程可以检查、模拟或拒绝系统调用,同时保持被监管进程的安全边界。

二、Seccomp Notify 内核架构与工作原理

2.1 核心数据结构

Seccomp Notify 的核心是 struct seccomp_filter 配合 SECCOMP_RET_USER_NOTIF 返回值,以及通过 seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog) 创建的文件描述符:

// 用户态 API(通过 libseccomp 或 raw syscall 调用)
struct seccomp_notif_sizes sizes = {0};
syscall(__NR_seccomp, SECCOMP_GET_NOTIF_SIZES, 0, &sizes);

// 分配缓冲区
struct seccomp_notif *req = malloc(sizes.seccomp_notif);
struct seccomp_notif_resp *resp = malloc(sizes.seccomp_notif_resp);
struct seccomp_notif_addrs *addrs = NULL;  // 6.0+ 可用

当被监管进程执行了一个被 Notify 标记的系统调用时,内核执行以下流程:

  1. BPF 过滤器返回 SECCOMP_RET_USER_NOTIF
  2. 内核冻结被监管进程(处于 TASK_INTERRUPTIBLE 状态)
  3. 构建 seccomp_notif 结构并写入 listener fd
  4. Supervisor 进程通过 ioctl(SECCOMP_IOCTL_NOTIF_RECV) 读取通知
  5. Supervisor 决定:允许、拒绝、模拟或注入自定义错误码
  6. 通过 ioctl(SECCOMP_IOCTL_NOTIF_SEND) 返回结果
  7. 内核恢复被监管进程的执行

2.2 通知结构详解

struct seccomp_notif 包含了系统调用的完整上下文:

struct seccomp_notif {
    __u64 id;            // 唯一通知 ID(用于后续响应匹配)
    __u32 pid;           // 触发 syscall 的进程 PID
    __u32 flags;         // 标志位(SECCOMP_NOTIF_FLAG)
    struct seccomp_data data;  // syscall 信息
};

struct seccomp_data {
    int nr;                      // 系统调用号
    __u32 arch;                  // 架构标识(AUDIT_ARCH_X86_64 等)
    __u64 instruction_pointer;   // 触发时的 RIP
    __u64 args[6];               // 六个系统调用参数
};

Linux 6.0 引入的 struct seccomp_notif_addrs 进一步暴露了每个参数对应的地址空间信息,使得 supervisor 可以更精确地判断参数的可信度。

2.3 响应选项与语义

Supervisor 需填充 struct seccomp_notif_resp 来回应:

struct seccomp_notif_resp {
    __u64 id;            // 必须与请求的 id 匹配
    __s64 val;           // 返回值(模拟结果或 -errno)
    __s32 error;         // 错误码(0 表示允许,负 errno 表示拒绝)
    __u32 flags;         // SECCOMP_USER_NOTIF_FLAG 标志组合
};

关键标志位:

标志 语义
SECCOMP_USER_NOTIF_FLAG_CONTINUE 忽略 val/error,重新执行原始 syscall(绕过 notify)
无标志 + error=0, val=X 模拟 syscall 返回 X
无标志 + error=-EPERM 阻止 syscall,进程收到 EPERM

2.4 Seccomp Notify 的关键安全约束

为防止 supervisor 进程滥用权限,内核实施了多项重要约束:

  1. Supervisor 必须在同一 seccomp 命名空间中:不能跨命名空间代理
  2. Respons 伪造屏障:supervisor 返回的 val 必须在合理范围内(如 open() 返回的 fd 必须指向被监管进程已经打开的真实 fd)
  3. Madvise 毒化:被监管进程调用 madvise(MADV_DONTNEED) 可以用于将 supervisor 传递的数据从页面缓存清除,避免数据残留
  4. ID 一致性:每个 notify 的六字节 ID 保证唯一性,防止响应被注入到错误的监听器

三、基于 Seccomp Notify 的系统调用虚拟化

Seccomp Notify 最强大的应用是系统调用虚拟化——在用户态完整模拟一个被拦截的系统调用,使得被监管进程感知不到自己被限制。

3.1 Supervisor 进程设计

一个完整的 Notify-based supervisor 需要实现以下核心循环:

#include <linux/seccomp.h>
#include <linux/audit.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define WAIT_FOR_CHILD    0x1
#define INOTIFY_WATCH     0x2

// 从被监管进程的用户空间读取内存
static long get_message(struct seccomp_notif *req, void *dst,
                        size_t dst_size, off_t src_addr,
                        int listener) {
    struct seccomp_notif_addrs addrs = {};
    struct iovec iov = {
        .iov_base = dst,
        .iov_len  = dst_size,
    };
    struct msghdr msg = {
        .msg_iov    = &iov,
        .msg_iovlen = 1,
    };
    // SECCOMP_IOCTL_NOTIF_ID_VALID 检查 ID 是否仍然有效
    // SECCOMP_IOCTL_NOTIF_ID_VALID_WRONG_DIRCT (6.0+)
    return ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, req);
}

int main(int argc, char *argv[]) {
    struct seccomp_notif_sizes sizes;
    struct seccomp_notif *req;
    struct seccomp_notif_resp *resp;
    int listener;

    // 获取通知结构体大小
    if (seccomp(SECCOMP_GET_NOTIF_SIZES, 0, &sizes) < 0) {
        perror("seccomp(GET_NOTIF_SIZES)");
        return 1;
    }

    req = malloc(sizes.seccomp_notif);
    resp = malloc(sizes.seccomp_notif_resp);

    // 主监听循环
    while (1) {
        memset(req, 0, sizes.seccomp_notif);
        memset(resp, 0, sizes.seccomp_notif_resp);

        // 阻塞等待通知
        if (ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, req) < 0) {
            if (errno == EINTR) continue;
            perror("ioctl(RECV)");
            break;
        }

        resp->id = req->id;
        resp->flags = 0;

        // 根据 syscall 号分发处理
        switch (req->data.nr) {
        case __NR_openat:
            handle_openat(req, resp, listener);
            break;
        case __NR_mount:
            handle_mount(req, resp, listener);
            break;
        case __NR_chmod:
        case __NR_fchmod:
            handle_chmod(req, resp, listener);
            break;
        default:
            // 不允许的 syscall
            resp->error = -EPERM;
            resp->val = -1;
            break;
        }

        // 发送响应
        if (ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, resp) < 0) {
            perror("ioctl(SEND)");
        }
    }

    free(req);
    free(resp);
    return 0;
}

3.2 虚拟化 openat 系统调用

openat 是文件操作的基础 syscall,虚拟化它需要处理路径解析、fd 管理和权限检查:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 虚拟化 openat: 在 supervisor 进程中以自己的身份执行
void handle_openat(struct seccomp_notif *req, 
                   struct seccomp_notif_resp *resp,
                   int listener) {
    int dirfd = (int)req->data.args[0];
    const char *pathname = (const char *)(uintptr_t)req->data.args[1];
    int flags = (int)req->data.args[2];
    mode_t mode = (mode_t)req->data.args[3];

    // 安全: 读取被监管进程用户空间中的路径字符串
    char path[PATH_MAX] = {0};

    // 方法一:通过 /proc/self/mem 跨进程读取
    // 方法二:使用 seccomp 的 process_vm_readv
    // 方法三:使用 pidfd_getfd + readlink

    // 最简单的方案:通过 /proc/<pid>/root/pathname 在 supervisor 
    // 命名空间中解析
    char proc_path[PATH_MAX];
    snprintf(proc_path, sizeof(proc_path), 
             "/proc/%d/root%s", req->pid, pathname);

    // 在 supervisor 进程执行真实的 open
    int fd = open(proc_path, flags, mode);

    if (fd < 0) {
        resp->error = -errno;
        resp->val = -1;
    } else {
        // 将 fd 转移到被监管进程
        // 方法:使用 pidfd_getfd()(Linux 5.6+)
        resp->error = 0;
        resp->val = fd;  // supervisor 拥有的 fd
        resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
        // 接下来需要将 fd 交付给被监管进程
        deliver_fd_to_child(req->pid, fd, listener);
        close(fd);
    }
}

3.3 FD 交付机制

Seccomp Notify 的一个核心挑战是:supervisor 返回的 fd 必须是被监管进程能操作的。Linux 提供了两种机制:

方案 A:pidfd_getfd(Linux 5.6+)

#include <sys/syscall.h>

int pidfd_getfd(int pidfd, int fd, unsigned flags) {
    return syscall(__NR_pidfd_getfd, pidfd, fd, flags);
}

// 在 supervisor 中的交付逻辑
void deliver_fd_to_child(pid_t child_pid, int fd, int listener) {
    // 首先获取被监管进程的 pidfd
    int pidfd = pidfd_open(child_pid, 0);

    // 按 ID 验证的 ioctl 之后发送 SECCOMP_IOCTL_NOTIF_ADDFD
    // Linux 5.14+ 提供了原生的 fd 注入
    struct seccomp_notif_addrs addrs = {};

    // 使用 SECCOMP_IOCTL_NOTIF_ADDFD 原子操作
    int flags = SECCOMP_ADDFD_FLAG_SEND |
                 SECCOMP_ADDFD_FLAG_SETFD;
    ioctl(listener, SECCOMP_IOCTL_NOTIF_ADDFD, &addrs);

    close(pidfd);
}

方案 B:SCM_RIGHTS 通过 Unix Domain Socket 传递

由于原始 fd 在不同进程间不直接兼容的传统方式是通过 Unix socket 发送:

// 通过 Unix Domain Socket 发送 fd
int send_fd(int sock, int fd_to_send) {
    struct msghdr msg = {0};
    struct iovec iov;
    char buf[1];
    buf[0] = 'f';

    iov.iov_base = buf;
    iov.iov_len = 1;

    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;

    union {
        struct cmsghdr cm;
        char control[CMSG_SPACE(sizeof(int))];
    } control_un;

    msg.msg_control = control_un.control;
    msg.msg_controllen = sizeof(control_un.control);

    struct cmsghdr *cmptr = CMSG_FIRSTHDR(&msg);
    cmptr->cmsg_len = CMSG_LEN(sizeof(int));
    cmptr->cmsg_level = SOL_SOCKET;
    cmptr->cmsg_type = SCM_RIGHTS;
    *(int *)CMSG_DATA(cmptr) = fd_to_send;

    return sendmsg(sock, &msg, 0);
}

然而,更优雅的方式是使用 SECCOMP_IOCTL_NOTIF_ADDFD(Linux 5.14+),它在一次 ioctl 调用中完成 fd 注入到目标 notify 响应:

// Linux 5.14+ 的原生 fd 注入
struct seccomp_notif_addrs notif_addrs = {};
struct seccomp_notif_resp resp = {
    .id = req->id,
    .val = target_fd,
    .error = 0,
    .flags = 0,
};

// SECCOMP_IOCTL_NOTIF_ADDFD 将 fd 转移到被监管进程
struct seccomp_notif_addrs addrs = {};
// 将 supervisor 注入的 fd 进入监听流程
// 返回的新 fd 在被监管进程中生效

// 注意:SECCOMP_ADDFD_FLAG_SEND 标志会自动将附带新 fd 的响应
// 发送回内核,无需额外调用 NOTIF_SEND
int new_fd = ioctl(listener, SECCOMP_IOCTL_NOTIF_ADDFD, &addrs);

3.4 指针参数的安全解引用

Seccomp Notify 事件传递的是被监管进程用户空间的 64 位参数值,其中指针类参数(如 openat 中的 pathname)指向被监管进程自己的地址空间。supervisor 不能直接解引用这些指针——必须通过安全的跨进程内存访问机制:

方案一:/proc/pid/mem

int read_process_mem(pid_t pid, void *dst, size_t len, 
                     uintptr_t remote_addr) {
    char path[64];
    snprintf(path, sizeof(path), "/proc/%d/mem", pid);

    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    if (lseek(fd, (off_t)remote_addr, SEEK_SET) < 0) {
        close(fd);
        return -1;
    }

    ssize_t ret = read(fd, dst, len);
    close(fd);
    return (ret == (ssize_t)len) ? 0 : -1;
}

// 注意:需要 PTRACE_ATTACH 权限才能读取 /proc/pid/mem

方案二:process_vm_readv (更高效)

#include <sys/uio.h>

ssize_t process_vm_readv(pid_t pid,
                         const struct iovec *local_iov,
                         unsigned long liovcnt,
                         const struct iovec *remote_iov,
                         unsigned long riovcnt,
                         unsigned long flags);

ssize_t safe_read_child(pid_t pid, void *dst, size_t len,
                        uintptr_t src) {
    struct iovec local = { .iov_base = dst, .iov_len = len };
    struct iovec remote = { .iov_base = (void *)src, 
                            .iov_len = len };
    return process_vm_readv(pid, &local, 1, &remote, 1, 0);
}

注意:上述两种方法都需要 CAP_SYS_PTRACE 能力或等价权限,这意味着 supervisor 本身需要一定特权。这也是为什么 notify 架构中通常由一个特权 supervisor 服务多个被监管进程。

四、容器安全场景实战

4.1 根容器(Rootless Container)中的 FUSE 挂载

根容器不使用宿主 root 权限运行容器引擎,但某些场景(rootless Docker + --privileged 缺少某些操作)需要 mount() syscall。传统 rootless 方案使用 fakeroot + subuid mapping,但无法处理真实的 mount 操作。

基于 Seccomp Notify 的方案:

// 特权 supervisor 运行在容器外部,代表容器发起 mount
void handle_mount(struct seccomp_notif *req,
                  struct seccomp_notif_resp *resp,
                  int listener) {
    const char *source = (const char *)(uintptr_t)req->data.args[0];
    const char *target = (const char *)(uintptr_t)req->data.args[1];
    const char *fstype = (const char *)(uintptr_t)req->data.args[2];
    unsigned long mountflags = (unsigned long)req->data.args[3];
    const void *data = (const void *)(uintptr_t)req->data.args[4];

    // 在容器命名空间中执行 mount(通过 setns)
    int target_fd = pidfd_open(req->pid, 0);
    setns(target_fd, CLONE_NEWNS);

    // 安全检查:不允许挂载危险文件系统
    static const char *allowed_fs[] = {
        "proc", "sysfs", "tmpfs", "cgroup", "cgroup2",
        "fuse", "overlay", "ext4", "xfs", NULL
    };
    bool allowed = false;
    for (int i = 0; allowed_fs[i]; i++) {
        if (strcmp(fstype, allowed_fs[i]) == 0) {
            allowed = true;
            break;
        }
    }

    if (!allowed) {
        resp->error = -EPERM;
        resp->val = -1;
        return;
    }

    // 限制挂载点必须在容器内
    char resolved_target[PATH_MAX];
    if (!is_inside_container(target, req->pid)) {
        resp->error = -EINVAL;
        resp->val = -1;
        return;
    }

    // 执行 mount(supervisor 具有所需权限)
    int ret = mount(source, target, fstype, mountflags, data);

    if (ret < 0) {
        resp->error = -errno;
        resp->val = -1;
    } else {
        resp->error = 0;
        resp->val = 0;
    }
}

4.2 安全的 Chroot/Pivot Root 代理

容器初始化时通常需要 chroot 或 pivot_root,但 CAP_SYS_CHROOT 本身允许逃逸。Seccomp Notify 可以 validates 目标路径:

void handle_pivot_root(struct seccomp_notif *req,
                       struct seccomp_notif_resp *resp,
                       int listener) {
    const char *new_root = (const char *)(uintptr_t)req->data.args[0];
    const char *put_old  = (const char *)(uintptr_t)req->data.args[1];

    char new_root_path[PATH_MAX];
    safe_read_child(req->pid, new_root_path, PATH_MAX, 
                    (uintptr_t)new_root);

    // 安全检查:必须指向容器 preconfigured 的 rootfs
    static const char *prefix = "/var/lib/containers/storage/overlay";
    if (strncmp(new_root_path, prefix, strlen(prefix)) != 0) {
        resp->error = -EPERM;
        return;
    }

    // 放行:内核确认权限后执行
    resp->error = 0;
    resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
    // 内核将实际执行 pivot_root
}

4.3 Docker × Seccomp Notify 的集成模式

Docker 当前使用的 runtime/default seccomp profile 一旦返回 SECCOMP_RET_ERRNO,syscall 被阻止。如果希望在某些容器中使用 Notify:

{
    "defaultAction": "SCMP_ACT_ERRNO",
    "listenerPath": "/run/seccomp-supervisor.sock",
    "listenerMetadata": "container_id=abc123",
    "syscalls": [
        {
            "names": ["mount", "umount2",
                "pivot_root", "chroot"],
            "action": "SCMP_ACT_NOTIFY"
        },
        {
            "names": ["openat", "openat2"],
            "action": "SCMP_ACT_NOTIFY"
        },
        {
            "names": ["clone", "unshare",
                "setns"],
            "action": "SCMP_ACT_NOTIFY"
        }
    ]
}

在 Kubernetes 中通过 SecurityContext 注入:

apiVersion: v1
kind: Pod
metadata:
  name: sandbox-with-supervisor
  annotations:
    container.apparmor.security.beta.kubernetes.io/main: "runtime/default"
    # Seccomp Notify profile path annotation
spec:
  containers:
  - name: main
    image: internal-app:latest
    securityContext:
      seccompProfile:
        type: Localhost
        localhostProfile: notify-supervisor.json
  - name: supervisor
    image: seccomp-supervisor:latest
    securityContext:
      capabilities:
        add: ["SYS_ADMIN", "SYS_PTRACE", "SYS_CHROOT"]
    volumeMounts:
    - name: listener
      mountPath: /run/seccomp-supervisor.sock
  volumes:
  - name: listener
    emptyDir: {}

五、高级性能优化与并发架构

5.1 _NOTIFY ioctl 的性能特征

Seccomp Notify 监听器的自然 API 是阻塞式的 ioctl,每个 RECV 会阻塞直到有新的 notify 到来。在高并发场景下,必须采用多线程或 epoll 驱动的事件循环。

关键限制:在 Linux 6.0 之前,SECCOMP_IOCTL_NOTIF_RECV 不支持 O_NONBLOCK。从 Linux 6.0 开始,可以将监听 fd 设置为非阻塞模式:

// 设置监听 fd 为非阻塞
int flags = fcntl(listener, F_GETFL, 0);
fcntl(listener, F_SETFL, flags | O_NONBLOCK);

// 现在 RECV 在无通知时返回 -EAGAIN

5.2 多线程 Supervisor 架构

#include <pthread.h>

#define MAX_WORKERS 16
#define NOTIF_CACHE_SIZE 4096

// 工作线程池结构
typedef struct {
    int listener;  // 共享的监听 fd
    pthread_t workers[MAX_WORKERS];
    _Atomic int active_count;
} supervisor_pool;

// 线程参数
typedef struct {
    supervisor_pool *pool;
    int worker_id;
} worker_arg;

void *supervisor_worker(void *arg) {
    worker_arg *warg = (worker_arg *)arg;
    supervisor_pool *pool = warg->pool;

    struct seccomp_notif_sizes sizes;
    seccomp(SECCOMP_GET_NOTIF_SIZES, 0, &sizes);

    struct seccomp_notif *req = malloc(sizes.seccomp_notif);
    struct seccomp_notif_resp *resp = malloc(sizes.seccomp_notif_resp);

    while (pool->running) {
        // 关键:多个线程共享同一个 listener fd
        // 内核保证每个 notify 只被一个线程获得(竞争消费模式)
        int ret = ioctl(pool->listener, 
                        SECCOMP_IOCTL_NOTIF_RECV, req);
        if (ret < 0) {
            if (errno == EINTR) continue;
            break;
        }

        // 处理通知并发送响应
        handle_syscall(req, resp, pool->listener);

        ioctl(pool->listener, SECCOMP_IOCTL_NOTIF_SEND, resp);
    }

    free(req);
    free(resp);
    return NULL;
}

重要:多个线程同时调用 NOTIF_RECV 是安全的,内核确保每个 notify 仅被一个线程领取,形成天然的工作窃取(work stealing)模型。

5.3 BPF Map 加速:绕过 Notify 的纯内核处理

对于高频且决策逻辑简单的 syscall(如 getpid、time、clock_gettime),通知用户态的开销过高。可以结合 BPF Map 实现"内核内快速路径":

// BPF 程序中的逻辑
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1024);
} fast_cache SEC(".maps");

// XDP/socket filter 类型的 BPF
SEC("tp/raw_syscalls/sys_enter")
int trace_syscall(struct trace_event_raw_sys_enter *ctx) {
    u32 nr = ctx->id;

    // 对高频 syscalls,直接从 BPF Map 读取结果
    // 避免了用户态 notify 的上下文切换开销
    if (nr == __NR_getpid || 
        nr == __NR_gettimeofday) {
        // 缓存命中则直接返回
    } 

    // 复杂的 syscall 上升至用户态 notify
    return 0;
}

六、Seccomp Notify 的安全边界与潜在风险

6.1 TOCTOU 攻击窗口

由于 Notify 是异步的(supervisor 检查时 syscall 已中断),存在检查时间点与执行时间点的差异窗口。内核通过以下机制缓解:

  1. id 唯一性:每个 notify 有唯一的 64 位 ID
  2. id 存活期验证:supervisor 可以调用 SECCOMP_IOCTL_NOTIF_ID_VALID 检查 ID 仍然有效(被监管进程仍然在该 syscall 上等待)
  3. madvise DONTNEED 屏障:被监管进程在调用 notify 点的 mmap 区域的任何修改都会使 supervisor 返回的响应无效

6.2 Supervisor DoS

如果 supervisor 处理缓慢或崩溃,被监管进程将持续被阻塞。Linux 6.3+ 引入了 SECCOMP_IOCTL_NOTIF_SET_FLAGS 配合超时机制来处理这种情况。

6.3 权限泄漏路径

必须确保 supervisor 本身的权限边界: - 仅暴露 SECCOMP_RET_USER_NOTIF 标记的 target syscall - supervisor 对 syscalls 的模拟结果必须经过内核合理性检查(如 fd 验证) - 不应当在 supervisor 中存储被监管进程的敏感信息

七、生产级 Supvervisor 完整实现

以下是一个可以部署于容器的 full-featured supervisor(基于 libseccomp 封装的高层 API):

#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>

static int install_notify_filter(int *listener_fd) {
    scmp_filter_ctx ctx;

    // 不允许被监管进程禁用 seccomp
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
        perror("prctl(NO_NEW_PRIVS)");
        return -1;
    }

    // 放行大部分 syscall,仅虚拟化关键 syscall
    ctx = seccomp_init(SCMP_ACT_ALLOW);
    if (ctx == NULL) return -1;

    // 将需要虚拟化的 syscall 标记为 NOTIFY
    int targets[] = {
        SCMP_SYS(mount),
        SCMP_SYS(umount2),
        SCMP_SYS(pivot_root),
        SCMP_SYS(chroot),
        SCMP_SYS(clone),
        SCMP_SYS(unshare),
        SCMP_SYS(setns),
        SCMP_SYS(openat),
        SCMP_SYS(openat2),
    };

    for (int i = 0; i < sizeof(targets)/sizeof(targets[0]); i++) {
        if (seccomp_rule_add(ctx, SCMP_ACT_NOTIFY, 
                             targets[i], 0) < 0) {
            fprintf(stderr, "Failed to add %d: %s\n",
                    targets[i], strerror(errno));
        }
    }

    // 创建监听 fd
    *listener_fd = seccomp_listener_fd(ctx);

    // 加载过滤器(fork 后 child 执行此过滤器)
    seccomp_export_bpf(ctx, STDOUT_FILENO);  // 调试
    if (seccomp_load(ctx) < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        return -1;
    }

    seccomp_release(ctx);
    return 0;
}

int main(int argc, char *argv[]) {
    int listener = -1;

    if (install_notify_filter(&listener) < 0) {
        return 1;
    }

    printf("Seccomp Notify supervisor listening on fd %d\n", listener);

    // 主循环:接受通知并处理
    struct seccomp_notif_sizes sizes;
    seccomp(SECCOMP_GET_NOTIF_SIZES, 0, &sizes);

    struct seccomp_notif *req = malloc(sizes.seccomp_notif);
    struct seccomp_notif_resp *resp = malloc(sizes.seccomp_notif_resp);

    while (1) {
        memset(req, 0, sizes.seccomp_notif);
        memset(resp, 0, sizes.seccomp_notif_resp);

        if (ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, req) < 0) {
            if (errno == EINTR) continue;
            break;
        }

        resp->id = req->id;

        // 根据 syscall 号分发
        int nr = req->data.nr;
        int should_supervise = 1;

        switch (nr) {
        case SCMP_SYS(openat):
            handle_openat(req, resp, listener);
            break;
        case SCMP_SYS(mount):
            handle_mount_supervisor(req, resp, listener);
            break;
        default:
            // 默认: 拒绝未预期 syscall
            resp->error = -EPERM;
            resp->val = -1;
            fprintf(stderr, "Unexpected notify nr=%d\n", nr);
        }

        // 发送响应
        if (ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, resp) < 0) {
            perror("NOTIF_SEND");
        }
    }

    free(req);
    free(resp);
    close(listener);
    return 0;
}

八、总结与展望

Seccomp Notify 代表了 Linux 安全模型从"内核强制"到"用户态可编程"的关键范式转变:

核心价值: - 将不可解除的"允许/拒绝"升级为策略化的"决策代理" - 使容器引擎能在 rootless 模式下安全地虚拟化特权 syscall - 为无特权 FUSE、overlay mount、用户命名空间嵌套提供通用的安全基础

设计权衡: - 每个通知 2 次上下文切换 + supervisor 处理延迟 ≈ 5-50μs(对比直接 syscall 的 50-200ns) - 仅适合决策频率受限的操作(mount、clone、open 等),不适合 read/write - Supervisor 本身成为系统中的特权组件,需要内部加固

演进方向: - Linux 6.x 持续优化 id 验证和 fd 注入的性能 - 与 Landlock 的联合使用(文件访问控制由 Landland 完成,syscall 控制由 Notify 完成) - eBPF 扩展:内核内缓存决策路径避免用户态往返(community RFC 进行中)

在实际生产中,gVisor 的 Gofer 进程、Firecracker 的 seccomp 过滤层,以及 containerd 的 CRI 沙箱实现中,都能看到 Seccomp Notify 核心思想的落地。理解这一机制,不仅是解锁高级容器安全能力的关键,更是深入 Linux 安全子系统设计的必经之路。


参考文档:Linux 6.6 kernel source(kernel/seccomp.c、include/uapi/linux/seccomp.org)、libseccomp 官方文档、Docker security 文档

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部