如何在香港服务器的 CentOS 环境中通过内核级 Seccomp 规则限制系统调用,提升容器安全性?

我盯着香港机房一台业务节点的 load 曲线发愣,一个新上线的容器开始出现奇怪的事情:网络连接抖动、fork 数激增、还试图“摸”内核的命名空间。直觉告诉我——这不是普通的 Bug。
我没选择第一时间“暴力”停机,而是决定趁热把这台机器的容器运行面加强固一遍:从内核层面收紧系统调用。那一夜,我在机柜前写下了这份 Seccomp 硬化手册。
场景与环境
业务场景:互联网边缘网关 + 静态内容服务,容器化部署,香港机房直连海外回源。
目标:在不显著影响性能的前提下,通过 Seccomp(Secure Computing Mode)限制容器可用的系统调用(syscall),降低逃逸、提权与内核攻击面的风险。
硬件 & 软件清单(实机)
| 项 | 规格 |
|---|---|
| 机房 | HK(荃湾) 同城双线机柜,温控 24℃ |
| 服务器 | 2U 自研装机 |
| CPU | 2 × Intel Xeon Silver 4210R(10C/20T) |
| 内存 | 128 GB DDR4 ECC |
| 系统盘 | 2 × 960 GB SATA SSD(RAID1) |
| 数据盘 | 2 × 3.84 TB NVMe U.2(RAID1, mdadm) |
| 网卡 | Intel X710 双口 10 GbE |
| OS | CentOS 7.9 (2009) |
| 内核 | 3.10.0-1160 系列(RHEL7 回溯补丁,支持 seccomp-bpf) |
| 容器运行时 | Docker 20.10.x(rootless/off,默认 Cgroups v1) |
| 关键库 | libseccomp 2.3.x(CentOS7 官方源),auditd |
小贴士:CentOS 7 的 3.10 内核已回溯(backport)了 seccomp 过滤能力;但 libseccomp 相对较老,部分新 syscall 名称解析不全——后文会说如何规避。
为什么选 Seccomp?
容器不是沙箱,真正的边界在内核。
Seccomp 通过 BPF 过滤器在进程级精确限制可调用的 syscalls 及其参数。当容器进程触发被禁止的调用时,内核会按策略返回 EPERM、EACCES、触发 SIGSYS,或直接 KILL。
两个工作模式:
- Strict:只允许 read/write/_exit/sigreturn,几乎不可用;
- Filter:BPF 过滤(我们用它),可以实现白名单或黑名单,并按参数做精细匹配。
总体实施路径(路线图)
- 确认内核与工具支持
- 采集应用基线 syscalls(先看后收)
- 选择策略:在 Docker 默认 seccomp 基础上收紧,或为特定业务做白名单
- 分环境上线:先“观测/记录”,再“阻断/报错”,最后“杀死/隔离”
- 持续审计与回滚预案
第一步:确认内核支持与安装工具
# 1) 确认内核配置包含 seccomp
grep -E 'SECCOMP|SECCOMP_FILTER' /boot/config-$(uname -r)
# 期望:CONFIG_SECCOMP=y 和 CONFIG_SECCOMP_FILTER=y
# 2) 当前进程的 seccomp 状态
cat /proc/self/status | grep Seccomp
# 0=未启用, 1=strict, 2=filter
# 3) 工具准备
yum install -y libseccomp libseccomp-devel audit strace
systemctl enable --now auditd
如果 libseccomp 太旧,遇到“未知 syscall 名称”的提示,可优先用黑名单或基于 Docker 的 RuntimeDefault,并避免写入当前内核不存在的新 syscall 名称。
第二步:采集应用基线 syscalls(最重要的“看”)
我不建议直接“拍脑袋”上白名单。先用 strace 把关键容器的 syscall 画像抓出来,几分钟内就能得到稳定分布。
在容器内审计(只读观测,低风险)
# 举例:对 nginx 容器主进程采样 60 秒
docker exec -it web-nginx bash -lc 'strace -f -c -p 1 -o /tmp/strace.stat & sleep 60; kill %1; cat /tmp/strace.stat'
某次采样结果片段(示例)
| 调用 | 次数占比 | 备注 |
|---|---|---|
futex |
34.2% | 线程/锁,千万别封 |
epoll_wait |
22.7% | I/O 多路复用 |
recvfrom/sendto |
17.8% | UDP/管理通道 |
accept4 |
8.1% | 新连接 |
openat |
6.3% | 打开配置/日志 |
clock_gettime |
4.4% | 计时 |
getsockopt/setsockopt |
3.1% | 调整套接字 |
uname |
1.2% | 读取内核信息 |
| 其他 | 2.2% | … |
这个表能立刻告诉你:哪些调用是“生命线”,绝对不能封。
例如一度我想封 clone 来阻止 fork bomb,结果线程模型直接崩了(pthread 需要 clone)。
第三步:策略选择——三种常用姿势
A. 基于 Docker 默认 seccomp 再收紧(推荐起步)
Docker 自带的 RuntimeDefault 会禁用如 keyctl/bpf/kexec_load/ptrace 等高危调用。我们在此基础上再加黑名单(如 unshare/mount/reboot 等),风险小、见效快。
B. 为特定无状态服务写白名单(最安全,投入也最大)
如 Nginx/静态 Go 二进制,syscall 稳定且少,用严格白名单可把面收得非常窄。
C. 应用内嵌 libseccomp(适合自研程序)
在进程 main() 最早期加载过滤器,哪怕进了 root 容器,也很难逃出你定义的边界(见后文 C 代码)。
第四步:动手——Docker 自定义 Seccomp Profile
4.1 基于默认策略收紧(黑名单法)
创建 /etc/docker/seccomp-tight.json:
{
"defaultAction": "SCMP_ACT_ALLOW",
"archMap": [
{ "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"] }
],
"syscalls": [
{ "names": ["unshare", "mount", "umount2", "reboot", "kexec_load", "open_by_handle_at"], "action": "SCMP_ACT_ERRNO" },
{ "names": ["ptrace", "bpf", "perf_event_open"], "action": "SCMP_ACT_ERRNO" },
{ "names": ["keyctl", "add_key", "request_key"], "action": "SCMP_ACT_ERRNO" },
{
"names": ["clone"],
"action": "SCMP_ACT_ERRNO",
"args": [
{ "index": 0, "value": 0x00020000, "valueTwo": 0, "op": "SCMP_CMP_MASKED_EQ" }
]
}
]
}
解释:
以 允许为默认动作,只对高危 syscall 做 ERRNO(返回 EPERM)
clone 仅在尝试带特定 flags(例如用户命名空间等)时阻断(示例位掩码写法示意,具体 flag 请结合你的内核头文件确认)
这些项大多已被 Docker 默认 profile 限制,我们是在其基础上进一步“保险加一层”(对内核版本老的环境尤其有用)
应用到容器:
docker run --rm \
--name web-nginx \
--security-opt seccomp=/etc/docker/seccomp-tight.json \
-p 80:80 nginx:1.25-alpine
4.2 针对特定服务的“白名单”(以 Nginx 为例)
/etc/docker/seccomp-nginx-allow.json(节选):
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{ "names": ["read", "write", "exit", "sigreturn", "rt_sigaction", "rt_sigprocmask", "close", "fstat", "lseek", "mmap", "mprotect", "munmap", "brk", "futex", "nanosleep", "clock_gettime", "getpid", "getppid", "geteuid", "getuid", "getgid", "getegid", "arch_prctl", "set_tid_address", "set_robust_list"], "action": "SCMP_ACT_ALLOW" },
{ "names": ["openat", "close", "stat", "fstat", "lstat"], "action": "SCMP_ACT_ALLOW" },
{ "names": ["socket", "bind", "listen", "accept4", "connect", "recvfrom", "sendto", "getsockname", "getsockopt", "setsockopt", "shutdown"], "action": "SCMP_ACT_ALLOW" },
{ "names": ["epoll_create1", "epoll_ctl", "epoll_wait"], "action": "SCMP_ACT_ALLOW" },
{ "names": ["getrandom", "uname"], "action": "SCMP_ACT_ALLOW" }
]
}
这一版更“狠”:默认拒绝,仅放行 Nginx 运行所需的调用。
强烈建议:先在灰度机器上跑 strace -c,把缺的调用补齐,再切生产。
第五步:Kubernetes 中的落地(RuntimeDefault 与自定义)
K8s 1.25+ 原生字段:
apiVersion: v1
kind: Pod
metadata:
name: nginx-seccomp
spec:
securityContext:
seccompProfile:
type: RuntimeDefault # 使用运行时默认(Docker 的内置默认)
containers:
- name: nginx
image: nginx:1.25-alpine
securityContext:
allowPrivilegeEscalation: false
部署自定义 Profile 的做法(常用两种)
节点本地文件:把 seccomp-nginx-allow.json 放到每个节点的 /var/lib/kubelet/seccomp/,然后:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: seccomp/seccomp-nginx-allow.json
DaemonSet/CSI 配置分发:通过守护进程将 profile 下发到节点本地同一目录,适合大规模。
第六步:观测、告警与取证
6.1 先“看”再“打”(审计模式思路)
CentOS 7 的 libseccomp 对 SCMP_ACT_LOG 的支持有限,审计建议用 auditd + 规则:
# 对关键高危 syscall 加审计规则(64 位架构示例)
auditctl -a always,exit -F arch=b64 -S mount -S umount2 -S ptrace -S kexec_load -S unshare -S bpf -k seccomp_watch
# 查看日志
ausearch -k seccomp_watch -ts recent | aureport -x --summary
当 Seccomp 命中拒绝时,/var/log/audit/audit.log 会出现 type=SECCOMP 或 type=SYSCALL 记录,包含进程、容器 cgroup、命中调用等。
6.2 “打击面”验证(阻断是否生效)
用一个特意触发 unshare 的测试容器:
docker run --rm -it \
--security-opt seccomp=/etc/docker/seccomp-tight.json \
alpine sh -c 'unshare -U -r whoami || echo "unshare blocked: $?"'
# 期望输出:unshare blocked: 1(或 EPERM)
第七步:应用内嵌 libseccomp(自研程序)
如果你能改源码,这是最“黏”的方案:进程自己把门。下面是一个最小可用的 C 例子:
// secwrap.c
#include <seccomp.h>
#include <stdio.h>
#include <unistd.h>
int main() {
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ERRNO(1)); // 默认拒绝,返回 EPERM
if (!ctx) return 1;
// 常见必要调用白名单(按你的程序补齐)
int allow[] = { SCMP_SYS(read), SCMP_SYS(write), SCMP_SYS(close), SCMP_SYS(exit),
SCMP_SYS(futex), SCMP_SYS(clock_gettime), SCMP_SYS(epoll_wait),
SCMP_SYS(epoll_ctl), SCMP_SYS(openat), SCMP_SYS(mmap),
SCMP_SYS(munmap), SCMP_SYS(mprotect), SCMP_SYS(brk),
SCMP_SYS(rt_sigaction), SCMP_SYS(rt_sigprocmask), SCMP_SYS(getpid),
SCMP_SYS(getppid), SCMP_SYS(uname), SCMP_SYS(accept4),
SCMP_SYS(socket), SCMP_SYS(bind), SCMP_SYS(listen),
SCMP_SYS(recvfrom), SCMP_SYS(sendto), SCMP_SYS(setsockopt),
SCMP_SYS(getsockopt) };
for (size_t i = 0; i < sizeof(allow)/sizeof(int); i++) {
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, allow[i], 0) < 0) return 1;
}
// 显式封禁高危
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(unshare), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(mount), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(umount2), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(ptrace), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(bpf), 0);
seccomp_rule_add(ctx, SCMP_ACT_ERRNO(1), SCMP_SYS(kexec_load), 0);
if (seccomp_load(ctx) < 0) return 1;
seccomp_release(ctx);
// 你的业务逻辑...
write(1, "Hello with seccomp!\n", 20);
// 尝试被封的调用(演示)
syscall(SCMP_SYS(unshare), 0);
return 0;
}
编译与运行:
gcc secwrap.c -o secwrap -lseccomp
./secwrap
# 期望:打印 "Hello with seccomp!",随后 unshare 返回 EPERM
第八步:性能与回归评估(真实数据才有底)
我做了两组对比(Nginx 静态文件 1 KB、10 并发、60s):
| 场景 | QPS | P95 延迟 | CPU 使用 | 备注 |
|---|---|---|---|---|
| 无 Seccomp | 38,500 | 3.5 ms | 62% | 基线 |
| Docker 默认 RuntimeDefault | 38,100 | 3.6 ms | 63% | 约 1% 波动 |
| 自定义黑名单(tight) | 38,000 | 3.6 ms | 63% | ≈ 1.3% |
| 自定义白名单(nginx-allow) | 37,800 | 3.7 ms | 64% | ≈ 1.8% |
结论:在 CentOS 7 + 3.10 环境下,seccomp 的 BPF 过滤开销很低,<2% 是常态。
大头性能仍由 I/O、网络、TLS 决定。大胆用,但上线前做你自己的基线测试。
第九步:那些年我踩过的坑(以及怎么填)
把 clone 一刀切封了,线程全挂
症状:多线程程序直接崩溃或卡死在启动阶段。
解决:允许 clone,只按 flag 有选择性限制(例如用户命名空间)。并配合 sysctl kernel.unprivileged_userns_clone=0(若内核支持)。
封了 futex,CPU 飙满
症状:系统负载升高、上下文切换暴涨。
解决:futex 是线程同步基石,永远放行。
clock_gettime 被拒后延迟抖动
症状:P95 增加、日志时间戳异常。
解决:放行时间相关调用(clock_gettime, gettimeofday, nanosleep)。
CentOS 7 的 libseccomp 不认新 syscall 名称
症状:加载 profile 报未知 syscall。
解决:
- 使用黑名单策略,避开“新”调用;
- 或者在 JSON 里不写那些本内核并不存在的 syscall;
- 必要时从源码编译较新 libseccomp(权衡变更风险)。
日志抓不到?以为没生效
症状:你觉得封了,但没有任何审计记录。
解决:
- 审计日志看 type=SECCOMP/SYSCALL;
- 容器运行时是否真的加载了你指定的 seccomp 文件?可用 docker inspect 校验;
- 结合 strace 在容器内复现被拒的 syscall。
K8s 自定义 profile 找不到
症状:Failed to load seccomp profile
解决:
- 路径要相对于 kubelet 配置的 seccomp 目录(常见 /var/lib/kubelet/seccomp/);
- 每个节点都要有这个文件;
- 权限建议 0644,属主 root:root。
实战清单(可直接照着做)
启用/确认:CONFIG_SECCOMP{,_FILTER}=y,安装 libseccomp、auditd。
采样:用 strace -f -c 对关键容器做 1–5 分钟采样,记录 Top10 调用。
先用 RuntimeDefault:K8s seccompProfile: RuntimeDefault 或 Docker 默认。
黑名单加固:拦 unshare/mount/umount2/ptrace/bpf/kexec_load/keyctl/*。
灰度白名单(可选):为 Nginx / Go 静态服务写白名单 profile。
审计与回滚:auditd 开启、可一键切回 RuntimeDefault;变更前后跑同样的压测对比。
搭配其它安全措施:
- 严格 capabilities(CAP_SYS_ADMIN 禁止给);
- 只读根文件系统(readOnlyRootFilesystem: true);
- 限制设备与挂载点;
- 资源配额与 OOM 策略。
附:常见高危 syscall 封禁建议(黑名单表)
| Syscall | 建议 | 原因 |
|---|---|---|
unshare |
禁 | 创建新命名空间,常用于逃逸链 |
mount/umount2 |
禁 | 文件系统挂载,配合 CAP_SYS_ADMIN 风险极高 |
ptrace |
禁 | 跟踪/注入其他进程 |
bpf |
禁 | eBPF 加载器,老内核易被打 |
kexec_load |
禁 | 加载新内核镜像 |
keyctl/add_key/request_key |
禁 | 内核 keyring 操作,易被滥用 |
open_by_handle_at |
禁 | 绕过路径权限访问 inode |
reboot |
禁 | 顾名思义 |
perf_event_open |
视情况禁 | 性能计数器,信息泄露面 |
clone(带敏感 flags) |
按 flags 禁 | 用户命名空间 / 特权组合 |
回到那个雨夜,我把黑名单版 profile 挂到那台可疑容器上。不到一分钟,audit.log 里跳出一串 SECCOMP 记录——有人在容器里反复试 unshare。这一次,它只能得到 EPERM。
第二天早上,团队在白板上把 syscall 画像和策略画了一遍,按服务分层执行。后来我们又补了两台新节点,照着清单做,一次成功。
这份笔记并不炫技,它只是我在机房里“靠近内核半步”的经验复盘:先看清,再下手;先默认安全,再为性能让路;把复杂留给自己,把安全还给系统。
如果你也正守着一排风扇呼呼作响的服务器,愿它能帮你在关键时刻少走弯路。
完整变更清单(TL;DR)
- 验证内核 seccomp 支持,安装 libseccomp/auditd/strace
- 采集容器 syscall 基线(strace -f -c)
- 启用 RuntimeDefault,观察业务与日志
- 应用 黑名单 profile(unshare/mount/ptrace/bpf/kexec_load/keyctl/*)
- 为 Nginx/Go 等写 白名单 profile(灰度)
- auditd 规则 + 监控告警
- 压测对比(QPS/P95/CPU),评估回归
- 文档化回滚:一键切回默认 profil