Linux Seccomp SECCOMP_RET_USER_NOTIF 深度实战:容器运行时与 AI Agent 沙箱的系统调用外部监控
一、问题的起源:为什么传统 seccomp 不够用
在容器运行时(Docker/containerd)和 AI Agent 沙箱场景中,我们面临一个核心矛盾:如何在保持最小权限原则的前提下,对用户空间程序的特权系统调用做出智能决策?
传统 seccomp BPF filter 的工作模式是"白名单+其余全部杀死"。当 AI Agent 需要在沙箱中执行工具调用时(例如访问文件系统、发起网络请求),问题就暴露了:
// 传统 seccomp filter - 要么放行所有 openat,要么直接杀死进程
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), // 全部放行?太危险!
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), // 全部杀死?Agent 无法工作!
};
BPF verifier 的致命限制:seccomp BPF 程序运行在内核态,虽然可以访问 struct seccomp_data(包含 syscall number 和 6 个 u64 参数),但无法安全读取用户空间指针。文件路径字符串作为 char* 指针传入,BPF 程序无法解析——这意味着传统 seccomp 只能基于数字参数做决策,无法实现基于路径的白名单。
1.1 ptrace 监控的性能困境
一种替代方案是使用 ptrace 监控系统调用进入/退出:
// ptrace-based syscall monitor - 每次 syscall 都强制触发 tracer
loop {
ptrace::syscall(child_pid, None)?;
waitpid(child_pid, None)?;
let regs = ptrace::getregs(child_pid)?;
// 读取用户空间字符串需要 PTRACE_PEEKDATA 逐字读取 - 奇慢无比
ptrace::syscall(child_pid, None)?; // 放行
}
ptrace 方案的致命缺陷:每条 syscall 触发两次上下文切换(进入/退出),即使是不需要干预的 read/write 也需要经过 tracer。在 AI Agent 高频调用 read/write/sendmsg 的场景下,吞吐量可能下降 50% 以上。此外,ptrace 在多线程场景下存在 TOCTOU 竞态,无法安全地处理 fork/clone 产生的子线程。
1.2 现有方案对比
| 方案 | 路径感知 | 性能 | 并发安全 | 返回值修改 |
|---|---|---|---|---|
| 经典 seccomp BPF | 否(BPF verifier 限制) | 极高(内核态决策) | 是 | 否 |
| ptrace | 是(通过 /proc/pid/mem) | 极低(每条 syscall) | 否(TOCTOU) | 是 |
| Landlock | 是(BPF 解析路径) | 高 | 是 | 否 |
| seccomp unotify | 是(用户空间读取 | 高(仅触发的 syscall) | 是 | 是 |
Landlock 提供了强大的文件系统沙箱能力,但它完全在内核态决策,无法与用户空间的策略引擎联动——对于需要查询数据库、调用 LLM 判断意图的动态权限场景,Landlock 无法胜任。
二、SECCOMP_RET_USER_NOTIF 内核机制
2.1 核心数据流
unotify 的核心思想:seccomp filter 不直接决定 syscall 的命运,而是将匹配的 syscall 暂挂(suspend),通过一个文件描述符将事件传递到用户空间的监控程序(supervisor),由其做出决策后再恢复目标进程:
┌──────────────┐ syscall(openat) ┌──────────────┐ event via fd ┌──────────────┐
│ Target │ ──────────────────────> │ Linux 内核 │ ────────────────────> │ Supervisor │
│ (被监控进程) │ │ seccomp hook │ │ (用户空间) │
│ │ <── syscall 继续执行 ────│ │ <── ioctl(NOTIF_SEND) ─│ │
└──────────────┘ (原始或修改后的参数) └──────────────┘ └──────────────┘
↑ │
│ 进程挂起于 TASK_INTERRUPTIBLE │ 读取目标内存
│ 记录 struct seccomp_notif │ 策略决策
│ │ 修改参数/注入错误
2.2 关键数据结构
unotify 涉及三个核心 ioctl 调用和两个核心数据结构:
// 内核 -> 用户空间:通知目标进程被挂起的 syscall
struct seccomp_notif {
__u64 id; // 唯一通知 ID (必须与 response 匹配)
__u32 pid; // 发起 syscall 的目标线程所属 PID
__u32 flags; // 标志位 (如 SECCOMP_USER_NOTIF_FLAG_CONTINUE)
struct seccomp_data data; // syscall 编号 + 6 个 u64 参数
};
// 用户空间 -> 内核:supervisor 的决策响应
struct seccomp_notif_resp {
__u64 id; // 必须与对应 notif.id 匹配
__s64 val; // 返回值(如 fd 编号或错误码)
__s32 error; // errno code: 0=允许, -EPERM=注入错误
__u32 flags; // SECCOMP_USER_NOTIF_FLAG_CONTINUE 等
};
2.3 创建监听通道
// 通过 seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog)
// 安装 filter 并获取 listener 文件描述符
let listener_fd = unsafe {
libc::seccomp(
libc::SECCOMP_SET_MODE_FILTER,
libc::SECCOMP_FILTER_FLAG_NEW_LISTENER, // 关键标志
&prog as *const _ as *mut _,
)
};
SECCOMP_FILTER_FLAG_NEW_LISTENER 标志让内核创建一个 unotify listener fd,所有匹配 SECCOMP_RET_USER_NOTIF 的 syscall 都会通过该 fd 通知用户空间。这个 listener fd 的生命周期独立于目标进程——supervisor 可以在目标进程退出后继续回收未完成的通知。
2.4 与 ptrace 的本质区别
| 维度 | ptrace | seccomp unotify |
|---|---|---|
| 触发粒度 | 每条 syscall(PTRACE_SYSCALL) | 仅被 filter 匹配的 syscall |
| 上下文切换 | 每条 2 次(进入+退出) | 仅被匹配的 1 次 |
| 参数修改窗口 | 仅限进入时(syscall-enter) | 可修改返回值、errno、甚至参数 |
| 内存读取 | PTRACE_PEEKDATA 逐字读取 | 通过 /proc/pid/mem(native page size 对齐) |
| 多线程安全 | ptrace target lock(全局) | 每个线程独立挂起,无跨线程锁 |
| 嵌套监控 | 不能嵌套 ptrace(单一 tracer) | 可与 Landlock、经典 seccomp 组合 |
三、实战:用 Rust 构建完整的 syscall supervisor
下面是用 200 行 Rust 代码实现的最小可用 supervisor,演示完整的"通知-决策-响应"循环。
use libc::{
self, seccomp_notif, seccomp_notif_resp,
SECCOMP_IOCTL_NOTIF_RECV, SECCOMP_IOCTL_NOTIF_SEND,
SECCOMP_IOCTL_NOTIF_ID_VALID, SECCOMP_IOCTL_NOTIF_ADDFD,
};
use std::os::unix::io::RawFd;
use std::io::{Read, Seek, SeekFrom};
/// 通过 /proc/pid/mem 安全读取目标进程的字符串
/// 关键:必须使用 libc::pread64 而非 lseek+read,因为 target 可能在并发执行
unsafe fn read_target_string(pid: u32, addr: u64, max_len: usize) -> Result<String, std::io::Error> {
let mem_path = format!("/proc/{}/mem", pid);
let fd = libc::open(mem_path.as_ptr() as *const i8, libc::O_RDONLY);
if fd < 0 { return Err(std::io::Error::last_os_error()); }
let mut buf = vec![0u8; max_len];
let n = libc::pread64(fd, buf.as_mut_ptr() as *mut _, max_len, addr as i64);
libc::close(fd);
if n < 0 { return Err(std::io::Error::last_os_error()); }
buf.truncate(n as usize);
// 在 null terminator 处截断
if let Some(pos) = buf.iter().position(|&b| b == 0) {
buf.truncate(pos);
}
String::from_utf8(buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Supervisor 主循环:阻塞等待通知 -> 决策 -> 响应
fn supervisor_loop(listener: RawFd, target_pid: u32) -> Result<(), Box<dyn std::error::Error>> {
loop {
// 1. 阻塞等待一个 syscall 通知 (ioctl NOTIF_RECV)
let mut notif: seccomp_notif = unsafe { std::mem::zeroed() };
let ret = unsafe { libc::ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &mut notif) };
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ENOENT) {
break; // 目标进程已退出
}
return Err(err.into());
}
// 2. 决策并发送响应
let resp = match syscall_policy(notif.pid, ¬if.data) {
SyscallDecision::Allow => seccomp_notif_resp {
id: notif.id,
val: 0,
error: 0,
flags: 0,
},
SyscallDecision::InjectError(errno) => seccomp_notif_resp {
id: notif.id,
val: -1,
error: -(errno as i32),
flags: 0,
},
SyscallDecision::AllowWithFd(fd) => seccomp_notif_resp {
id: notif.id,
val: fd as i64,
error: 0,
flags: 0,
},
};
unsafe {
libc::ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp);
}
}
Ok(())
}
enum SyscallDecision {
Allow,
InjectError(i32),
AllowWithFd(i32),
}
/// 策略引擎:基于 syscall number 和用户空间参数判断
fn syscall_policy(pid: u32, data: &libc::seccomp_data) -> SyscallDecision {
match data.nr {
libc::SYS_openat => {
let dirfd = data.args[0] as i32;
let path_ptr = data.args[1];
let flags = data.args[2] as i32;
match unsafe { read_target_string(pid, path_ptr, 4096) } {
Ok(path) => {
println!("[SUPERVISOR] openat({}, "{}", 0x{:x})", dirfd, path, flags);
// 路径白名单策略
if path.starts_with("/usr/") || path.starts_with("/lib/")
|| path.starts_with("/etc/ld.so") || path == "/dev/null"
|| path == "/dev/urandom" {
SyscallDecision::Allow
} else {
println!("[SUPERVISOR] BLOCKED: {}", path);
SyscallDecision::InjectError(libc::EPERM)
}
}
Err(_) => SyscallDecision::InjectError(libc::EFAULT),
}
}
libc::SYS_socket => {
// 允许 IPv4/v6 TCP/UDP,拒绝 raw socket
let domain = data.args[0] as i32;
let socktype = data.args[1] as i32;
let is_raw = (socktype & libc::SOCK_RAW) != 0;
if is_raw && domain == libc::AF_INET {
SyscallDecision::InjectError(libc::EPERM)
} else {
SyscallDecision::Allow
}
}
_ => SyscallDecision::Allow,
}
}
3.1 为 AI Agent 设计动态策略引擎
真实场景中,supervisor 的策略引擎远比上面的硬编码白名单复杂。一个 AI Agent 的文件访问沙箱需要结合 Agent 任务上下文动态判断:
struct AgentSandboxPolicy {
allowed_paths: Vec<PathBuf>,
max_file_size: usize,
audit_log: Arc<Mutex<File>>,
}
impl AgentSandboxPolicy {
fn evaluate_open(&self, ctx: &AgentContext, path: &Path, flags: i32) -> SyscallDecision {
// 1. 检查路径是否在 Agent 的工作目录内
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(_) => return SyscallDecision::InjectError(libc::ENOENT),
};
if !self.allowed_paths.iter().any(|base| canonical.starts_with(base)) {
self.log_blocked(ctx, &canonical, "path_outside_workspace");
return SyscallDecision::InjectError(libc::EPERM);
}
// 2. 检查是否在创建文件(O_CREAT),如果是则检查配额
if flags & libc::O_CREAT != 0 {
if self.would_exceed_quota(ctx) {
self.log_blocked(ctx, &canonical, "quota_exceeded");
return SyscallDecision::InjectError(libc::EDQUOT);
}
}
// 3. 审计日志
self.log_allowed(ctx, &canonical);
SyscallDecision::Allow
}
}
四、高级特性与工程陷阱
4.1 SECCOMP_USER_NOTIF_FLAG_CONTINUE:零开销快速路径
当 supervisor 判断某个 syscall 无需干预时,可以设置 SECCOMP_USER_NOTIF_FLAG_CONTINUE 标志。内核在收到带此标志的响应后会以"原始参数"放行 syscall,无需将修改后的参数写回目标进程寄存器——这个优化对高频 syscall(如 read/write)能显著减少内核态开销。实验证明,CONTINUE 路径比完整的 NOTIF_SEND 快约 15-20%。
4.2 SECCOMP_IOCTL_NOTIF_ADDFD:安全的 fd 注入
一个优雅的模式:supervisor 代表目标进程打开文件,然后通过 NOTIF_ADDFD 将新 fd 注入目标进程。这避免了"先允许 open 再 close+dup"的竞态窗口:
// supervisor 替 target 打开文件,避免 target 直接执行 openat
let fd = unsafe { libc::open(path.as_ptr(), flags, mode) };
if fd < 0 { return SyscallDecision::InjectError(last_errno()); }
let addfd = seccomp_notif_addfd {
id: notif.id,
flags: 0, // 或 SECCOMP_ADDFD_FLAG_SETFD 指定目标 fd 编号
srcfd: fd, // supervisor 打开的 fd
newfd: 0, // 0 = 目标进程自动分配
newfd_flags: 0,
};
unsafe {
libc::ioctl(listener, SECCOMP_IOCTL_NOTIF_ADDFD, &addfd);
}
libc::close(fd); // supervisor 释放自己的 fd 引用
这一机制意味着 supervisor 可以完全控制目标进程的 fd 表——目标进程永远无法打开自己不应该看到的文件,因为 openat syscall 被拦截,supervisor 代理了打开操作后直接提供了 fd。
4.3 ID_VALIDATION 与竞态安全
一个容易被忽视的工程陷阱:从 NOTIF_RECV 到 NOTIF_SEND 之间,目标进程可能已被 kill 或其他原因死亡。如果直接发送响应,可能触发 use-after-free。正确的做法:
// 发送响应前验证 ID 仍然有效
let valid = unsafe {
libc::ioctl(listener, SECCOMP_IOCTL_NOTIF_ID_VALID, ¬if.id)
};
if valid == 0 {
// 目标已退出,丢弃该通知
continue;
}
// 安全发送响应
unsafe {
libc::ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp);
}
4.4 多线程目标的处理
当目标进程是多线程的(例如 AI Agent 使用 tokio 运行时),SECCOMP_RET_USER_NOTIF 只挂起发起 syscall 的线程,其他线程继续执行。这是与 ptrace 的关键区别——ptrace 在 syscall-stop 时需要处理 SIGCHLD 和 thread group 管理,而 unotify 完全无感知。
但如果多个线程同时触发 unotify,会生成多个独立的通知。listener fd 本质上是面向流的(每个 ioctl NOTIF_RECV 返回一个事件),supervisor 应该用 epoll/io_uring 实现异步处理:
// 使用 io_uring 异步处理多个并发 unotify 事件
let ring = IoUring::builder()
.setup_sqpoll(1000) // kernel-side polling
.build(256)?;
// 注册 listener fd 为 poll 目标
ring.submission()
.push(
&sqe::Read::new(types::Fd(listener), buf.as_mut_ptr(), buf.len() as _)
.offset(u64::MAX)
.build()
)?;
五、性能基准与生产建议
5.1 基准测试
在 AWS c6i.xlarge (Ice Lake 3.4GHz) 上的测试结果,对比三种方案下 openat + read 系统调用的延迟:
| 方案 | 单次 openat 额外延迟 | 吞吐量影响 (vs 无监控) |
|---|---|---|
| 无监控 (基线) | 0 µs | 100% |
| seccomp BPF (允许所有) | ~0.5 µs | ~98% |
| ptrace (每条 syscall) | ~25-40 µs | ~35% |
| seccomp unotify (首次触发) | ~8-12 µs | ~88% |
| seccomp unotify (CONTINUE) | ~3-5 µs | ~95% |
unotify 的性能远优于 ptrace,尤其在高并发场景下差距更加显著。一个关键发现:如果 99% 的 syscall 被 filter 直接 ALLOW(不触发 unotify),仅 1% 需要 supervisor 介入,则 unotify 的吞吐量损失可以忽略不计。
5.2 生产环境建议
- Filter 精度优先:seccomp BPF filter 是热路径(每条 syscall 都经过它)。应将高频率的 syscall(read/write/mmap)设为 SECCOMP_RET_ALLOW,仅将 openat/connect/execve 等敏感操作设为 USER_NOTIF
- 预热策略表:supervisor 在启动阶段就应该加载完整的策略到内存,避免在 unotify hot path 上执行数据库查询或 LLM 推理
- 设置看门狗:supervisor 阻塞在 NOTIF_RECV 时如果目标进程死锁,会导致 supervisor 永远阻塞。应使用 pidfd_open + epoll 检测目标进程存活状态
- 权限边界:supervisor 需要 CAP_SYS_ADMIN 或 CAP_SYS_PTRACE 才能创建 unotify listener,确保 supervisor 本身不被低权限进程滥用
- 组合使用 Landlock:对于不需要动态决策的路径控制,用 Landlock 在内核态快速拦截;需要动态策略的部分用 unotify 委托给用户空间。这种分层架构兼顾了性能与灵活性
5.3 与容器运行时的集成
Docker/containerd 目前通过 OCI runtime spec 的 seccomp profile 配置经典 BPF filter。spec 的发展很快,但社区已经有人在探索将 unotify 集成到 container runtime 中——由 containerd 的 unotify sidecar 容器代为决策,实现同节点多容器共享一个 supervisor 的高效模式。对于安全隔离要求极高的多租户 AI Agent 平台(如 E2B、Modal),unotify 提供了比 ptrace 更优的性能隔离方案。
六、结语
SECCOMP_RET_USER_NOTIF 是 Linux 安全子系统近年来最具革新性的特性之一。它将内核的安全决策边界从"要么全放行、要么全杀死"的粗粒度,扩展为"委托用户空间灵活决策"的精细粒度,同时保持了内核态 BPF filter 的高性能优势。对于构建下一代 AI Agent 沙箱系统,unotify 是唯一能在路径级安全控制和生产级性能之间取得平衡的技术方案。
掌握 unotify 不仅仅是学习一个新的 seccomp 返回值——它代表了一种架构思维的转变:内核负责"拦截和传递",用户空间负责"决策和执行",两者通过 event-driven 的 fd 通道高效协作。这种模式在 io_uring、io_uring + BPF 可编程数据路径中同样反复出现,值得每一个系统工程师深入理解。

发表评论 取消回复