容器里的进程想创建设备节点,内核只检查初始 user namespace 的权限,回一个 EPERM;想挂载一块磁盘,同样没门。这些请求经常是安全的,容器管理器也乐于放行,问题是它没有观察点:进程发起的系统调用要么通过、要么失败,运行时全程不知情。
seccomp 过滤器能看到所有系统调用,但它落在内核的 BPF 虚拟机里,不能解引用指针,看不到路径字符串,也查不了文件状态。“这个调用号禁止出现"这类静态规则是它的主场;“先读参数再决定"它做不到。内核 5.0 补上了这一块:新的返回动作 SECCOMP_RET_USER_NOTIF,命中时系统调用不执行,触发线程挂起,一条通知送到用户态监督进程(supervisor)手里。
一条通知的完整流程#
链路只有四个动作。第一,目标进程装载过滤器时带上 SECCOMP_FILTER_FLAG_NEW_LISTENER,seccomp() 返回一个 listener fd。这个 fd 属于过滤器本身:之后 fork 出的进程同样受它管辖,通知都排到同一个 fd 上;内核文档还提到它的读写做过同步,多个读者并发取通知是安全的。
第二,listener fd 得交给 supervisor。走 UNIX socket 的 SCM_RIGHTS 是标准做法,man page 里也列了 pidfd_getfd 这条捷径。crun 把前者固化成约定:容器配置里写注解 run.oci.seccomp.receiver=PATH(或环境变量 RUN_OCI_SECCOMP_RECEIVER),运行时就把 listener fd 发到指定 socket 上,conmon 或外部程序接住它。
第三,supervisor 用 SECCOMP_IOCTL_NOTIF_RECV 阻塞读一条 struct seccomp_notif:系统调用号、六个参数寄存器、目标线程 pid,以及这场通知的 cookie(id 字段)。指针参数内核不会替你读,supervisor 要自己打开 /proc/<pid>/mem,按参数里的地址 pread 出来。
第四,处理完写 struct seccomp_notif_resp 回应答,三种走向。填 val 和 error 就是伪造返回值,内核不执行这次调用;置上 SECCOMP_USER_NOTIF_FLAG_CONTINUE,表示看过了,交回内核照常执行;需要产生文件描述符的调用(open、socket、accept),用 SECCOMP_IOCTL_NOTIF_ADDFD 把 supervisor 的 fd 直接装进目标的 fd 表。
时间线:通知机制进内核是 5.0(2019),CONTINUE 是 5.5,ADDFD 是 5.9,让"注入 fd 和发应答"一步原子完成的 SECCOMP_ADDFD_FLAG_SEND 是 5.14,5.19 又加了 WAIT_KILLABLE_RECV,让目标在被通知期间忽略非致命信号,直到 supervisor 发出应答。libseccomp 从 2.5.0 起提供 seccomp_notify_alloc/receive/respond 这组封装。
谁在用:从 LXD 到 crun#
最早规模化使用它的是 LXD 团队,2017 年提出想法,Tycho Andersen 完成内核侧实现,Christian Brauner 补上 CONTINUE,Sargun Dhillon 加上 ADDFD。LXD 拿它模拟两类系统调用:mknod 创建设备节点、mount 挂载文件系统。容器里发起 mknod,判断和创建都在 supervisor 侧完成;mount 请求则可以由管理员指定一个 FUSE 程序代劳,用非特权的方式把文件系统带进容器。
crun(Podman 使用的 OCI 运行时)把处置逻辑做成了插件:seccomp_notify.c 只负责收通知,交给插件决定。附带的 mknod 插件展示了典型写法:进入目标的 user namespace 和 mount namespace,在目标路径创建占位文件,再把宿主的真实设备 bind mount 上去。可注入的设备写死五个:/dev/null、/dev/zero、/dev/full、/dev/random、/dev/urandom,其余一律回 EPERM。
2024 年的 bypass4netns 是另一个方向。rootless 容器网络要经过 slirp4netns 之类的用户态中继,吞吐上不去;它拦截容器的 connect/bind 等系统调用,用 ADDFD 把宿主网络命名空间里建好的 socket 换进容器进程的 fd 表。论文报告吞吐比原始 rootless 方案快 30 倍以上,而且不依赖 LD_PRELOAD,静态链接的程序也能用。
写一个最小 supervisor#
文档看一圈不如自己跑一遍。下面的程序 fork 出目标进程;目标装好过滤器后把 listener fd 的编号通过 socketpair 告诉父进程,父进程用 pidfd_getfd 取一份副本,然后处理全部通知。目标进程做五次尝试,supervisor 给出四种处置:替它创建目录、CONTINUE 放行、伪造 EPERM、注入 fd。
| |
编译运行(x86-64,内核 5.14 以上):
| |
6.8 内核上的真实输出(T:/S: 分别来自目标和 supervisor,交错顺序视调度而定):
| |
对着输出看三个细节。目标请求的 mode 是 0777,最后 stat 出来的是 0700:目录不是它建的,supervisor 按自己的意愿用 0700 创建。"/snp-magic/hello” 在磁盘上不存在,目标却拿到了一个可读的 fd,内容来自 supervisor 打开的文件,这也是 bypass4netns 换 socket 用的同一招。CONTINUE 那条最不起眼,却是误拦的解药:过滤器按系统调用号粗筛,必然会拦到一些无需代劳的调用,CONTINUE 把它们交回内核。
细节:id 校验、信号打断与 CONTINUE 的边界#
第一件容易忽略的是竞态。目标发起系统调用后挂在内核里可中断睡眠:信号一来,处理函数跑完,这次调用可能就被放弃了。supervisor 拿着刚才读到的 pid 和地址继续操作,可能踩进别的进程的内存,pid 还有被复用的风险。
防护手段是 SECCOMP_IOCTL_NOTIF_ID_VALID:读目标内存前后各校验一次 cookie,确认目标还挂在这条调用上。man page 里有两句话最好记牢:读内存必须夹在两次 id 校验之间;而 supervisor 往目标内存里写,“永远不能被当作安全操作”。demo 里的 read_target_path() 就是这个模式,读出来的字节按不可信输入处理,还要自己确认字符串完整。
第二件是重复通知。目标如果有 SA_RESTART 的信号处理函数,被打断的调用会被内核重启,supervisor 会为同一次调用收到第二条通知,第一条的应答以 ENOENT 失败。ENOENT 不代表出错,它属于正常流程。还有一个边角:supervisor 自己退出后,目标再触发通知模式的系统调用会拿到 ENOSYS,而不是一直挂着。
CONTINUE 单独说。它看起来像"放行开关”,但不能用来拼安全策略:从 supervisor 决定放行到内核真正执行之间有时间窗,目标可以重写参数指向的内容,检查时看到的是 /etc/passwd,执行时未必还是。man page 的措辞更重:这个机制"绝对不能用来实现安全策略";它只适合"特权进程替低权限目标消除内核误伤"这个定位。
反向用法:不用 ptrace 的进程注入#
同一套机制反过来用,就是一个新的攻击面。2025 年 12 月 Outflank 公开了一个 PoC:注入器扮演父进程,子进程装好针对 openat 的过滤器后 execve 目标程序;目标动态链接器加载每个共享库的 openat 都会被父进程截住,父进程用 ADDFD 把 memfd 里的恶意 .so 冒充成它要找的库。库里的 IFUNC resolver 在符号解析前执行,和 2024 年 XZ 后门是同一类手法。
它不需要 LD_PRELOAD,不碰 procfs,也不用 process_vm_writev,而且无视 ptrace_scope:内核视角里这就是"监督进程替子进程模拟系统调用"的标准场景。作者注明了边界:注入代码与目标同 uid、同命名空间、同 LSM 标签,不构成提权;目标必须是动态链接的。方向性(父对子、特权对非特权)是这个接口的设计前提,但用法一旦被接到非预期的方向上,它照样成立。
参考#
- man page seccomp_unotify(2): https://man7.org/linux/man-pages/man2/seccomp_unotify.2.html
- 内核文档 Seccomp BPF: https://docs.kernel.org/userspace-api/seccomp_filter.html
- Christian Brauner: Seccomp Notify - New Frontiers in Unprivileged Container Development: https://people.kernel.org/brauner/the-seccomp-notifier-new-frontiers-in-unprivileged-container-development
- Giuseppe Scrivano: Playing with seccomp notifications in the OCI runtime: https://scrivano.org/posts/2020-08-10-seccomp-notifications/
- crun seccomp_notify.c: https://github.com/containers/crun/blob/main/src/libcrun/seccomp_notify.c
- crun mknod 插件(Rust): https://github.com/containers/crun/blob/main/contrib/seccomp-notify-plugin-rust/src/mknod.rs
- bypass4netns: Accelerating TCP/IP Communications in Rootless Containers: https://arxiv.org/abs/2402.00365
- Outflank: Linux Process Injection via Seccomp Notify: https://www.outflank.nl/blog/2025/12/09/seccomp-notify-injection/(镜像 https://kyleavery.com/posts/seccomp-notify-injection/)
- libseccomp v2.5.0 发布说明: https://github.com/seccomp/libseccomp/releases/tag/v2.5.0

