seccomp 系统调用过滤加固实战:容器防护

seccomp 系统调用过滤是 Linux 上压缩内核攻击面的最直接手段。它把防护位置下沉到系统调用层:进程即使被注入恶意代码,只要调用了白名单之外的 syscall,就会被内核直接拒绝或杀死,攻击者拿不到 ptrace、bpf、kexec_load 这类提权与逃逸必需的能力。本文给出 seccomp 系统调用过滤加固的完整配置流程,覆盖 Docker 自定义 profile、Kubernetes 安全上下文与 systemd 系统调用白名单三种落地方式。

适用场景

  • 容器化业务需要降低内核漏洞被利用后的逃逸成功率。
  • 运行外部不可信代码,例如在线代码执行、构建服务、漏洞靶场。
  • 第三方向业务系统交付 Docker 镜像,镜像内进程不需要完整系统调用能力。
  • 系统服务(Web、数据库、消息队列)长期运行且功能稳定,可以固化其系统调用集合。
  • 等保或安全基线要求「对重要进程实施最小权限与系统调用限制」。

前置条件

  • Linux 内核 4.8 及以上(支持 seccomp 过滤器与 Seccomp 状态位),推荐 5.10 以上。
  • libseccomp 库版本 2.5.0 及以上,用于支持容器运行时解析 profile。
  • Docker 20.10 及以上或 containerd 1.6 及以上,Kubernetes 集群版本 1.19 以上。
  • 具备修改容器启动参数与编排清单的权限,并能重启目标服务。
  • 已部署集中日志采集,能够分别采集审计日志与应用标准错误输出。
  • 准备一套完整的功能回归用例,覆盖启动、运行、优雅退出三条路径。

原理说明

seccomp 在防护链条中的位置

应用层防护(输入校验、WAF)拦截的是已知的攻击输入;容器防护(Capability 裁剪、只读根文件系统)限制的是进程可以触达的资源;seccomp 限制的是进程能够请求的内核功能。当前者失守、攻击者已在进程内执行代码时,seccomp 是最后一层:一个被劫持的 Nginx 进程若无法调用 ptrace、process_vm_writev、open_by_handle_at,就很难完成跨容器窥探或宿主机提权。

Docker 与 Kubernetes 默认已经加载了一份默认 profile(Docker 的默认 profile 基于 docker-default),它禁用了约 40 个高风险 syscall。问题在于多数团队从未确认过这份默认 profile 是否真的生效,也没有针对业务做进一步收紧,导致实际的攻击面远大于预期。

三种动作与选择原则

seccomp profile 的默认动作有三种常用取值:SCMP_ACT_ERRNO 返回错误号(进程收到 EPERM,通常是崩溃或报错)、SCMP_ACT_KILL_PROCESS 直接杀死进程、SCMP_ACT_LOG 仅记录不拦截(需内核支持 /proc/sys/kernel/seccomp/actions_logged)。落地时的通用原则是:先以 LOG 模式运行观察,确认无正常调用被标记后,再切换为 ERRNO,稳定一段时间后考虑对攻击性强的高危 syscall 使用 KILL。

操作步骤

步骤一:确认当前 seccomp 生效状态

# 1) 查看宿主机内核是否支持 seccomp 及支持的 action
grep -E "CONFIG_SECCOMP|CONFIG_SECCOMP_FILTER" /boot/config-$(uname -r)
cat /proc/sys/kernel/seccomp/actions_avail
# 预期输出包含:kill_process kill_thread trap errno user_notif trace log allow

# 2) 查看容器内进程的 seccomp 状态,2 表示已启用过滤器
docker run --rm alpine sh -c 'grep Seccomp /proc/self/status'

# 3) 查看当前容器使用了哪个 profile(Nginx 容器为例)
docker inspect nginx --format '{{.Id}} {{.HostConfig.SecurityOpt}}'
# 输出为空或 seccomp=unconfined 表示未启用或已放开,需要立即整改

# 4) 列出容器内可用的 Capability,作为 seccomp 白名单规划的依据
docker run --rm alpine sh -c 'apk add -q libcap && capsh --print'

第 3 步是最容易发现问题的检查点:很多生产容器的启动参数里带着 --privileged,或 seccomp=unconfined,看似方便排障,实际把内核接口完全暴露给了容器。整改时先把这类容器找出来,按 docker ps --format '{{.Names}}' | xargs -I{} docker inspect {} --format '{{.Name}} {{.HostConfig.Privileged}} {{.HostConfig.SecurityOpt}}' 批量输出。

步骤二:生成业务专属白名单

不要从零手写 profile,而是借助工具根据实际调用生成。下面的方式使用审计日志采集容器内的真实 syscall 集合:

# 方式一:ocs seccomp-bpf-honney(需 Go 环境)根据审计日志生成白名单
go install github.com/elastic/oci-seccomp-bpf-honney@latest
sudo oci-seccomp-bpf-honney --log /var/log/audit/audit.log --out my-profile.json

# 方式二:手工采集。先让容器以 unconfined 运行并打开审计
docker run -d --name probe --security-opt seccomp=unconfined nginx:1.25
auditctl -a always,exit -F arch=b64 -S all -F pid=$(docker inspect -f '{{.State.Pid}}' probe) -k probe_sc
sleep 300            # 让进程跑完启动、业务请求、日志切割等全部路径
ausearch -k probe_sc | aureport -s -i | sort -k4 -nr | head -60

# 方式三:strace 快速摸底(仅适用于短生命周期进程)
strace -f -c -o /tmp/ngx.stat docker run --rm --security-opt seccomp=unconfined nginx -t

采集窗口必须覆盖完整的运行周期。只观察启动阶段会漏掉日志轮转、热更新、健康检查触发的 syscall,写成 profile 后会在凌晨定时任务执行时突然失败。建议采集时长不少于 24 小时,或完整跑一遍功能回归用例。

步骤三:编写自定义 seccomp profile

以下 profile 以 Nginx 为例,采用白名单模式:默认拒绝全部 syscall,仅放行业务必需项,并显式禁止高风险调用。文件保存为 /etc/docker/seccomp-nginx.json:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "archMap": [
    { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"] },
    { "architecture": "SCMP_ARCH_AARCH64", "subArchitectures": ["SCMP_ARCH_ARM"] }
  ],
  "syscalls": [
    {
      "names": [
        "accept4","access","arch_prctl","bind","brk","capget","capset","chdir",
        "clock_gettime","clone","clone3","close","connect","dup","dup2","epoll_create1",
        "epoll_ctl","epoll_pwait","epoll_wait","eventfd2","execve","exit","exit_group",
        "faccessat","fcntl","fstat","fstatfs","futex","getcwd","getdents64","getegid",
        "geteuid","getgid","getpgrp","getpid","getppid","getrandom","getrlimit",
        "getsockname","getsockopt","gettid","gettimeofday","getuid","ioctl","listen",
        "lseek","madvise","mmap","mprotect","mremap","munmap","nanosleep","newfstatat",
        "openat","pipe2","poll","prctl","pread64","prlimit64","pwrite64","read",
        "readlink","recvfrom","recvmsg","restart_syscall","rt_sigaction","rt_sigprocmask",
        "rt_sigreturn","sched_getaffinity","sched_yield","sendfile","sendmsg","sendto",
        "set_robust_list","set_tid_address","setsockopt","shutdown","sigaltstack",
        "socket","socketpair","statfs","statx","sysinfo","umask","uname","unlink",
        "wait4","write","writev","setitimer","setuid","setgid"
      ],
      "action": "SCMP_ACT_ALLOW"
    },
    {
      "names": ["ptrace","bpf","kexec_load","kexec_file_load","init_module",
                "finit_module","delete_module","process_vm_readv","process_vm_writev",
                "open_by_handle_at","name_to_handle_at","add_key","keyctl","request_key",
                "userfaultfd","perf_event_open","mount","umount2","pivot_root",
                "setns","unshare","reboot","swapon","swapoff","quotactl","nfsservctl"],
      "action": "SCMP_ACT_ERRNO",
      "errnoRet": 1
    }
  ]
}

两点必须说明:白名单中的 clone 与 clone3 无法通过 profile 参数限制标志位,如需限制命名空间创建,应改用 SCMP_ACT_ERRNO 配合运行时的 --cap-drop 与 userns-remap。SCMP_ACT_ERRNO 返回 EPERM 而非杀死进程,是刻意的选择:便于应用打印错误日志、便于灰度期定位问题,也避免直接 KILL 造成难以复现的崩溃。

步骤四:在 Docker 中加载 profile

# 前台验证:先加载 profile 观察启动是否正常
docker run --rm -it \
  --security-opt seccomp=/etc/docker/seccomp-nginx.json \
  --cap-drop ALL --cap-add NET_BIND_SERVICE --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
  --read-only --tmpfs /var/cache/nginx --tmpfs /var/run \
  nginx:1.25 nginx -t

# 确认生效:容器内 Seccomp 字段应为 2
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json alpine \
  sh -c 'grep Seccomp /proc/self/status'

# 正式启动并保留回滚能力
docker run -d --name nginx-sec \
  --security-opt seccomp=/etc/docker/seccomp-nginx.json \
  --security-opt no-new-privileges:true \
  --cap-drop ALL --cap-add NET_BIND_SERVICE --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
  nginx:1.25

–cap-drop ALL 与 seccomp 必须同时使用。Capability 决定进程是否拥有某项权限,seccomp 决定进程能否发出对应系统调用,两者是互补关系:只裁 Capability 不裁 syscall,进程仍可能通过其他接口触达内核功能;只裁 syscall 不裁 Capability,进程仍持有可被滥用的权限位。

步骤五:在 Kubernetes 中启用

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels: { app: web }
  template:
    metadata:
      labels: { app: web }
    spec:
      securityContext:
        seccompProfile:
          type: RuntimeDefault        # 集群级默认 profile,1.19+ 可用
        runAsNonRoot: true
        runAsUser: 101
      containers:
        - name: web
          image: nginx:1.25
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"], add: ["NET_BIND_SERVICE"] }
          volumeMounts:
            - { name: cache, mountPath: /var/cache/nginx }
            - { name: run,   mountPath: /var/run }
      volumes:
        - { name: cache, emptyDir: {} }
        - { name: run,   emptyDir: {} }

如需使用自定义 profile,把 seccompProfile.type 改为 Localhost,并通过 localhostProfile: profiles/seccomp-nginx.json 指定节点上的文件路径。Kubernetes 1.25 起已移除对未设置 seccompProfile 的容忍,未显式声明的 Pod 会继承 RuntimeDefault;若集群版本较旧,需要手动为每个工作负载加上该字段。

步骤六:在 systemd 服务上启用

宿主机上的常驻服务同样适用,而且配置成本更低。以 Nginx 为例:

mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/hardening.conf <<'EOF'
[Service]
# 系统调用白名单:先只写 @system-service,再用 ~ 排除高危组
SystemCallFilter=@system-service
SystemCallFilter=~@clock @cpu-emulation @debug @keyring @module @mount @obsolete @privileged @raw-io @reboot @resources @swap
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# 配套的文件与权限收敛
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/cache/nginx /var/log/nginx
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE
EOF

systemctl daemon-reload
systemd-analyze security nginx.service --no-pager | tail -5
systemctl restart nginx

SystemCallFilter=@system-service 是一份内置的系统服务白名单,配合 ~ 前缀的排除列表可以快速获得合理的收紧程度,比手写 300 条 syscall 更可靠。注意 MemoryDenyWriteExecute=yes 会阻止 JIT,Java、Node.js、PHP OPcache 等运行时需要评估后再启用。

配置验证

验证要同时确认三件事:profile 已加载、高危 syscall 被拒绝、业务功能未受影响。

# 1) 状态位:容器内 Seccomp 应为 2,NoNewPrivs 应为 1
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json --security-opt no-new-privileges:true alpine \
  sh -c 'grep -E "Seccomp|NoNewPrivs" /proc/self/status'

# 2) 逐项验证高危调用被拦截,四条命令都应返回 Operation not permitted
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json alpine \
  sh -c 'apk add -q strace 2>/dev/null; strace -e trace=ptrace true 2>&1 | tail -2'
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json alpine \
  sh -c 'mount -t tmpfs none /mnt 2>&1'
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json alpine \
  sh -c 'unshare -m true 2>&1'
docker run --rm --security-opt seccomp=/etc/docker/seccomp-nginx.json alpine \
  sh -c 'cat /proc/kallsyms > /dev/null 2>&1; echo exit=$?'

# 3) 业务回归:以真实请求验证,而不是只看容器是否 Running
curl -s -o /dev/null -w "http=%{http_code} time=%{time_total}\n" http://127.0.0.1/healthz
curl -s -o /dev/null -w "static=%{http_code}\n" http://127.0.0.1/index.html
docker logs nginx-sec 2>&1 | grep -iE "operation not permitted|seccomp|denied" | tail -20

# 4) 记录被拒绝的调用(需内核支持 LOG 动作)
sysctl -w kernel.seccomp.actions_logged="log kill_process"
dmesg | grep -i seccomp | tail -20

灰度观察期内,每天执行一次日志检索,确认没有出现 Operation not permitted 的隐藏错误。很多故障不会让进程退出,而是让某个低频功能静默失败,例如证书自动续期、日志切割、健康检查探针,因此必须依赖日志检索而不是依赖监控的存活状态。

常见问题

问题一:加载自定义 profile 后应用启动即崩溃,如何快速定位缺哪个 syscall

把 defaultAction 临时改为 SCMP_ACT_LOG,保留原有的白名单与拒绝列表,重新启动应用并跑一遍完整回归,然后用 dmesg | grep -i seccomp 或审计日志查看被记录但未拦截的调用。在日志中筛出被拒绝且带 EPERM 的项目,逐条补进白名单。切勿直接把 defaultAction 改成 SCMP_ACT_ALLOW 排障,那等于放弃默认拒绝,profile 形同虚设。

问题二:为什么 profile 写对了,容器里仍然能调用 ptrace

常见原因是容器以 --privileged 启动,或者被 --security-opt seccomp=unconfined 覆盖。privileged 模式会同时放开 Capability、设备访问与 seccomp,优先级高于 profile。用 docker inspect <容器> --format '{{.HostConfig.Privileged}} {{.HostConfig.SecurityOpt}}' 确认。另一个原因是进程设置了 no_new_privs 之外的豁免,或宿主机 /proc/sys/kernel/seccomp/actions_avail 中 errno 被禁用导致动作被降级为 log。整改顺序是:先去 privileged,再确认 SecurityOpt 中 profile 路径正确且文件存在。

问题三:Java、Node.js 等运行时能被 seccomp 收紧到什么程度

可以收紧,但受限明显。JVM 需要 mprotect、mmap 且 JIT 依赖可写可执行内存,MemoryDenyWriteExecute 会直接导致 JVM 启动失败,须保持关闭或改用 AOT。Node.js 与 V8 情况类似。可行的做法是不追求极小白名单,而是以 @system-service 为基线、排除 @debug、@mount、@module、@privileged、@raw-io 这几组,把 ptrace、bpf、模块加载、原始设备访问这类真正危险的能力切掉,收敛收益已经很可观。

问题四:seccomp 能否替代容器逃逸防护的其他措施

不能,它是纵深防御中的一层。seccomp 拦截的是系统调用,无法阻止利用合法调用组合形成的漏洞利用链,例如通过 ioctl 触发的驱动缺陷、通过 openat 的路径穿越缺陷。因此必须与 Capability 裁剪、no-new-privileges、只读根文件系统、User Namespace 隔离、镜像最小化、运行时行为检测配合使用。seccomp 的价值在于把攻击者需要的能力从「几十个」压缩到「个位数」,显著抬高利用成本,而不是提供绝对阻断。

总结

seccomp 系统调用过滤加固的落地路径是:先用 /proc/self/status 与 docker inspect 摸清现状,剔除 privileged 与 unconfined;再用审计日志采集 24 小时以上的真实调用集合;然后以「默认拒绝 + 白名单 + 高危显式拒绝」的方式编写 profile,动作先用 ERRNO;接着在 Docker、Kubernetes 与 systemd 三处分别加载,并始终与 Capability 裁剪、no-new-privileges、只读文件系统配套;最后靠日志检索而不是存活状态完成验证。整个过程保留回滚点,灰度推进,避免为了收紧系统调用而牺牲业务可用性。