ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Seccomp深度优化指南:从默认配置到业务系统调用白名单收敛

Seccomp深度优化指南:从默认配置到业务系统调用白名单收敛

Seccomp,全称Secure Computing Mode,是Linux内核从2.6.12版本开始引入的机制,也是我接手容器安全相关工作后,第一时间向团队所有人强调必须掌握的一层防线。它拦截的不是文件权限、不是网络流量,而是进程向内核发的每一条系统调用请求。容器能跑起来,靠的就是系统调用;容器能逃逸出去,往往也是靠系统调用。所以说,Seccomp profile不是可选项,而是容器安全的标配。

不过,默认的Seccomp配置能用,但不等于够用。默认配置更像是一把按大众习惯打的“通用锁”,它能挡住明显恶意的行为,却不会为你的业务专门做取舍。真正把Seccomp玩明白,往往意味着你要从一份宽松的默认策略开始,逐步收敛成一套只放行业务所需系统调用的定制配置。

这篇文章面向的读者,是那些不想再把安全停留在“开了Seccomp”这种层面的人。我会把这套机制从原理到实操拆开揉碎,聊一聊系统调用审计、配置结构、用户态工具,以及我在多个项目里踩过的坑和沉淀下来的优化方法。

1. 先搞清楚Seccomp到底拦的是什么

1.1 系统调用是容器安全的“第一道闸门”

很多刚上手容器的人会有个误区,觉得容器安全的重心是镜像漏洞扫描、依赖版本检查,或者网络策略。这些当然重要,但它们都属于“静态防御”。真正的运行时攻击,往往发生在程序执行的那一刻。攻击者利用某个Web漏洞拿到容器内代码执行权限后,下一步动作是什么?读取敏感文件、建立外连、写入内核模块、挂载新文件系统——每一项操作都要经过系统调用。

系统调用(syscall)是用户态程序请求内核服务的唯一合法通道。比如你想读取文件,就得调用open、read、close;你想创建进程,就得调用fork、execve;你想开网络连接,就得调用socket、connect。内核本来对进程是“有求必应”的,一旦进程被攻破,它能调用的系统调用越多,攻击者能做的事就越离谱。Seccomp就是在用户态和内核态之间加一道“门禁”,没被放行的系统调用直接返回错误,进程根本执行不到那一步。

打个比方,镜像扫描解决的是“嫌疑人身上有没有带刀”的问题,Seccomp解决的是“即使嫌疑人进了大楼,哪些门坚决不能让他打开”的问题。前者是被动排查,后者是主动设限。对一个高安全要求的容器环境来说,两者缺一不可。

1.2 默认配置为什么总是“不够用”

主流的容器运行时默认都会给容器挂一份Seccomp配置,这份配置拦截了部分高危系统调用,比如那些与内核模块加载、关键系统状态改写相关的调用。这份默认配置的出发点是安全基线,它主要针对的是通用攻击面,而不是你的业务面。

问题就出在这里:默认配置是“宽”的。为了不让正常应用被误伤,它放行了几乎所有常规操作。哪怕你的容器本质上只需要读写少量文件、对外提供HTTP服务、做几次网络请求,默认配置照样把所有普通文件的系统调用全部放行。安全原则里有个概念叫“最小权限”,默认配置恰恰违背了这条原则,它给的是“最大便利”。

我在实际项目中见过不少案例:某个团队把业务容器运行起来后,感觉“反正已经开了Seccomp”,就再没管过。结果一次安全演练里,攻击者从一个图片上传漏洞打进了容器,利用默认配置未拦截的mount调用,挂载宿主机目录,直接突破了隔离边界。问题不在默认配置“有没有”,而在默认配置“有多宽”。

另外一个容易被忽略的点是,默认配置通常不会针对应用的运行时特征做任何定制。同一个配置用在Nginx、数据库、消息队列上,拦截效果几乎一样。但每个应用对系统调用的依赖差异非常大,只有基于真实运行行为收敛出来的配置,才能既保证业务稳定,又做到真正收紧。

2. Profile的字段语义与设计取舍

2.1 JSON骨架逐字段解读

Seccomp配置文件本质上是一个结构化的JSON文本,里面描述了“哪些系统调用允许”“哪些要被拦截”“遇到拦截时如何响应”。第一次打开这类文件的人,往往会被一堆字段弄得眼花缭乱。我拆开来看,其实核心就四块:架构声明、默认动作、系统调用表、参数过滤器。

{ "defaultAction": "SCMP_ACT_ERRNO", "defaultErrnoRet": 1, "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32" ], "syscalls": [ { "names": [ "read", "write", "openat", "close", "fstat", "mmap", "munmap", "brk", "execve", "exit_group", "rt_sigreturn" ], "action": "SCMP_ACT_ALLOW", "comment": "基础IO与进程生命周期调用" }, { "names": [ "name_to_handle_at", "open_by_handle_at" ], "action": "SCMP_ACT_ERRNO", "errnoRet": 1, "comment": "通过文件句柄绕过路径权限检查的高危调用" } ] }

defaultAction决定了一个系统调用在配置表中“查无此人”时的处理方式。最常见的两种是SCMP_ACT_ALLOW和SCMP_ACT_ERRNO。前者表示默认放行,后者表示默认拒绝并返回错误码。真正的白名单策略,会把defaultAction设为SCMP_ACT_ERRNO,然后只把业务真正需要的调用列进syscalls并标记为ALLOW。

defaultErrnoRet配合SCMP_ACT_ERRNO使用,指定默认拒绝时返回错误码,常用的有EPERM(操作不允许)和EACCES(权限不足)。设置一个合理的错误码能帮助上层应用迅速识别“这不是普通故障,是安全策略拦截”。

architectures声明这份Profile针对的CPU架构。这里有一个特别容易踩的坑,容器镜像里跑的是64位应用,但某次启动时内核走的是32位兼容路径,直接导致原本应该放行的调用走了默认拒绝分支,程序崩溃。所以配置里最好把目标架构和兼容架构都显式声明出来。

2.2 动作机制:ALLOW、ERRNO、TRACE、KILL怎么选

Seccomp提供了几类动作,选择不同动作决定了命中策略后系统调用是“被放行”“被拒绝”还是“被杀死”。

动作行为适用场景
SCMP_ACT_ALLOW放行系统调用白名单中的常规调用
SCMP_ACT_ERRNO拒绝并返回错误码不想彻底切断程序,让程序走错误处理逻辑
SCMP_ACT_TRACE交由追踪器处理调试阶段观察调用行为,或在用户态实现精细过滤
SCMP_ACT_KILL_PROCESS直接杀死进程明确的高危调用,一旦触发立即终止容器进程

实际优化过程中,我很少把高危调用设置成ERRNO就完事。比如mount这类调用,如果攻击者已经拿到代码执行权限,返回一个EPERM给他,他可能会想尽办法绕过;而直接KILL_PROCESS就能把整个容器进程终止掉,把损失控制在一个容器实例内。反过来,对于一些业务上可能用到但不想完全放行的调用,先用ERRNO让程序报错,观察一段时间,确认业务无损后再决定是否彻底禁用,这是比较稳妥的做法。

还有TRACE动作值得单独说一句。它可以让系统调用的触发事件发送给用户态追踪器,相当于给Seccomp装了一台“监控摄像头”。我在初期生成业务Profile时,会临时把部分不确定的调用设置为TRACE,配合追踪工具观察触发频率,再决定最终动作。这个技巧在线上灰度阶段尤其好用。

2.3 参数过滤器:精细到“带什么参数才拦截”

只按系统调用名字做拦截,粒度其实还比较粗。同一个系统调用,参数不同,风险完全不同。拿write来说,向终端写日志和向内核提供的伪文件写入内容,风险差异是巨大的。Seccomp支持按系统调用参数组合做二次过滤,比如检查参数的值、按位与运算结果。

我在一个安全要求很高的项目中,对write调用加了参数过滤:凡是目标文件描述符指向某些特殊内核接口的写入请求,一律拦截;其他普通文件描述符的写入正常放行。用到的就是args字段里的索引、比较运算和值掩码。

这种精细过滤的学习曲线比较陡,因为一不小心写错条件,就可能让程序陷入循环报错。我的建议是:先把参数过滤器应用在“少数几个高危调用”上,比如openat、mount、ptrace、process_vm_writev,不要一开始就大规模使用,否则排查问题的成本会很高。参数过滤器的可读性也较差,务必在注释里写明每个条件的业务背景,不然两个月后回头看,你根本想不起来当初为什么这么写。

3. 从业务到Profile:深度优化的完整路线

3.1 第一步:让应用在“监控模式”下真实跑起来

想要生成一份贴合业务的Seccomp配置,第一步不是写JSON,而是先搞清楚应用到底用了哪些系统调用。这里的标准做法是用审计工具录制一段时间的系统调用轨迹。我通常会在预发布环境里,把目标应用的请求流量完整跑一遍,包括常规业务、健康检查、定时任务、异常分支处理等。

关键是要把应用的所有入口都覆盖到。很多人只在“功能正常”状态下采集,结果上线后某个管理后台功能一调用就崩溃,原因就是那条路径上有一个系统调用没被采集到。我会把采集窗口拉长,覆盖日常高峰、低峰、甚至故障演练时的一些操作,保证系统调用清单足够完整。

采集工具方面,常见的有strace、auditd、bpftrace这类底层观测手段。它们各有侧重。strace适合在测试环境里直观看到每个进程的系统调用序列,缺点是对性能影响大,不能直接用在生产环境;auditd是内核级审计,性能开销相对小,可以部署在生产观察节点上。我在正式项目里通常先跑一层strace拿到完整清单,再在生产节点靠审计规则做长期观察,两边数据交叉验证。

3.2 第二步:从调用轨迹收敛成白名单

拿到一堆系统调用记录之后,面对的往往是一个“杂而全”的列表,里面充斥着大量只出现过一两次的调用。这个阶段的核心工作,是逐条判断“这个调用是否有业务意义”。

首先,把列表按调用频率排序。高频调用通常是主流程依赖的,比如read、write、mmap、futex、epoll_wait,这些基本都要放行。其次,查找那些明显属于“旁门左道”的调用,比如ptrace、process_vm_readv、name_to_handle_at,这些在绝大多数业务容器里都不该出现,直接丢弃。最后是中间地带的调用,比如setuid、setgid,如果应用确实需要降权运行,就要保留,否则建议拦截。

收敛时我习惯遵守一条原则:宁可让程序报错并暴露问题,也不要为了省事把调用全放行。一个调用被拦会导致崩溃,这本身说明应用可能存在一些不够可控的行为,值得排查,而不是因为“它被调用了”就网开一面。

收敛完后,把剩下的调用整理成JSON配置,defaultAction设置为拒绝,然后将确认放行的调用逐一填入ALLOW列表。这份配置在测试环境里先试用,记录所有被拦截的调用、触发场景、调用栈。这是一轮非常必要的“排雷”过程。

3.3 第三步:把Profile挂到容器运行时上并灰度验证

配置写好后,接下来就是怎么让容器运行时加载它。不同的容器运行时加载方式略有差异,但底层逻辑一致:要么通过运行时的命令行参数指定Profile文件路径,要么通过运行时接口在创建容器时注入。

我推荐的做法是为每个服务维护独立的Profile文件,命名规则用“业务模块-环境-版本号”,比如“订单服务-生产-v2.json”。这样当配置更新时,可以独立回滚指定服务的Profile,不会影响其他模块。

在灰度验证阶段,有一个特别实用的小技巧:先在低流量节点挂新Profile,观察一段时间,重点看四条指标:容器崩溃率、系统调用拒绝日志、业务请求错误率、关键告警事件。这里要区分“预期内拒绝”和“异常拒绝”。预期内拒绝是安全策略生效的表现,比如某些探针行为被拦截;异常拒绝则是业务受影响的前兆,需要及时回滚配置。

当灰度验证通过后,再逐步推送到全部节点。整个过程一定要配合完善的日志采集,如果生产环境没有保存被拒调用的日志,后续定位问题会非常艰难。

4. 实战中绕不开的坑,以及对应排查方法

4.1 应用启动阶段就崩溃,先别急着怀疑配置写错

我在多个项目里都遇到过一个非常经典的现象:应用在本地跑得好好的,一旦挂上自定义Seccomp Profile,启动阶段直接报错退出。很多人第一反应是“配置拦掉了关键调用”,其实不一定。启动阶段的崩溃往往是因为容器里还有一个隐藏的启动流程,比如入口脚本、初始化进程、健康检查插件,它们同样会发起系统调用,却被默认配置天然放行了。

排查这类问题时,我是按这个顺序操作的:先看启动日志和错误码,然后临时把Profile切换回默认配置;如果应用恢复正常,再开启审计模式把启动阶段的所有系统调用打出来,逐条和自定义配置对比。多数情况下都能发现,少的是一个不起眼的调用,比如读取主机标识、设置信号处理方式之类的启动初始化调用。

这里有一个经验值:给应用做白名单时,不要先把启动阶段排除在外。相反,要从“空配置打死所有调用”开始,一条一条放行,直到应用能完整启动。这个过程虽然繁琐,但能保证你放行的每一个调用都是真的被需要。

4.2 拒绝日志太少或太多,都不正常

这段内容很重要的一段话。

4.2 拒绝日志太少或太多,都不正常

Seccomp生效后,如果配置很严格,理论上会出现不少被拦截调用的审计记录。我见过两种情况都不对劲。一种是从头到尾几乎没有拒绝日志,这通常说明配置写得过宽,等于白装;另一种是所有调用都在疯狂报错,业务直接瘫痪,这往往是采集阶段没覆盖关键路径。

拒绝日志太少的场景,我会回头检查采集阶段的流量覆盖是否充分,还可以故意加入一些“恶意探测”动作去验证拦截是否生效。拒绝日志太猛的时候,我会立刻把ERRNO动作临时改成TRACE,通过追踪器输出完整调用栈,找到触发源头。很多情况下,触发者不是业务代码本身,而是某个底层依赖库或运行时代理组件。

如果生产环境不方便直接改Profile,可以用日志服务把节点上的审计事件单独聚合,形成一张“拒绝调用热力图”。这张图能快速暴露哪些调用被触发得最频繁、来自哪些进程、什么时间点出现高峰,对后续优化极有帮助。

4.3 Profile跟着业务一起漂移,维护需要纳入版本管理

业务一迭代,Profile就容易失效。这是Seccomp深度优化最容易被忽视的一块。比如你的应用从单进程升级成多进程架构,新进程需要访问一类之前没用到过的设备,或者一次依赖升级让应用改用异步IO,系统调用清单就会发生变化。

我的做法是,把Profile像代码一样管起来,纳入版本管理系统,并且和业务版本号绑定。每次发版前都要跑一次系统调用回归测试,用新版本应用在测试环境里录制调用轨迹,和当前Profile做差值分析。如果出现新增调用,一律先做技术评审,再决定是否放行。

有些团队会把Profile直接固化在镜像里,我建议不要这样。Profile应该和容器运行时的安全策略层绑定,而不是和业务镜像绑定。镜像负责提供运行负载,策略负责约束运行行为,两者解耦后,在安全事件应急处置时你可以只改策略不重建镜像,回滚和验证都更轻量。

4.4 与编排平台功能的配合问题

在编排环境里,Seccomp配置还有一个麻烦事:容器编排平台本身的功能有时会依赖一些特殊系统调用。比如,动态调整容器资源、在线日志采集、故障注入等能力,都会要求容器具备一些额外的系统调用权限。如果Profile管得过死,平台上某些管理功能就会失灵。

我在排障时遇到过一种场景:业务容器的Profile里没有放行setrlimit,结果资源隔离组件想要动态调低容器内存限制时,调用被拦截导致操作失败。从业务角度看,这个调用可能永远用不上;但从平台管理角度看,它又是必要的。这种矛盾不能靠“一刀切放行”解决,而是要在平台管理通道和业务通道之间做好区分,尽量保留平台组件的独立权限通道。

所以,Profile的设计不应该只由业务方单方面决定,还需要和容器平台方、运维方协同确认。哪类调用是平台强依赖,哪类调用是业务强依赖,要用一张权限矩阵全部列清楚,避免每个人都按自己的理解去放宽配置。

4.5 优化没有终点:定期重审与分层策略

最后想聊一个容易被忽略的点:Seccomp优化不是一个“写完就结束”的动作。业务在变,攻击技术在变,内核在变,框架在变,任何一方的变化都可能让之前的配置失去平衡。每隔一段时间做一次系统性重审,比每次都等到出事故再排查要划算得多。

我习惯的节奏是:每季度做一次Profile全量复核,重点检查四类内容:新增系统调用是否评估过、高危调用是否有新用法、默认动作是否还有放宽空间、参数过滤器能否覆盖新出现的攻击场景。每个季度还会选择一两个服务做“再收敛”,尝试把一些之前因为各种原因放行的调用重新收回来。

分层策略也是深度优化的一个重要方向。生产环境可以使用最严格的白名单,预发环境稍微宽松一点以便暴露问题,开发环境甚至可以完全关闭Seccomp来保证开发效率。不同环境的安全水位本来就该不同,强行统一配置只会让生产环境的配置为了迁就开发环境而变得越来越宽。

对Seccomp profiles的深度优化,说到底是把安全工作从“配置项”推到“策略运营”的过程。我认为真正有效的落地方式就是这三板斧:先让业务在监控下跑起来,用真实数据生成配置;再用版本管理和灰度发布保证配置变更可控;最后靠持续重审让配置和业务保持同步。这条路走下来,每个容器能调用的系统调用会大幅收窄,攻击者即使突破应用层,可供利用的武器也少了很多。这套方法在我接触过的多个项目里都扛住了实战检验,也大大减少了半夜被安全告警叫醒的次数。如果你正准备动手优化自己的容器环境,不妨就从一小块业务开始,先跑一轮系统调用采集,再逐步把默认配置收紧,你会发现这比想象中简单,也比想象中有效。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进