Linux沙盒技术落地:命名空间、cgroup与seccomp隔离实践
沙盒技术这个词这几年被提得很多,但真正在工程里落地的时候,绝大多数团队踩的坑都不是“不知道怎么隔离”,而是“隔离得不对”。我印象很深的一次,某团队为了跑一批第三方提交的自动化脚本,图省事直接在业务机上开了个低权限账号就跑了,结果一个死循环脚本把 CPU 吃满,连带同机的服务全部超时,排查了两个小时才定位到根因。从那之后我对沙盒技术的理解就变了:它不是一道保险丝,而是一整套关于“边界怎么划、代价付多少、失效了怎么办”的工程取舍。
这篇内容想聊的就是这个:沙盒技术到底是什么、它在内核层靠什么撑着、从零怎么搭一个够用的最小实现、不同场景下该选哪一档强度、以及真正上线后会在哪些地方翻车。适合三类人看——做后端或平台工程、需要跑不可信代码的同学;做客户端、要处理外部文档和插件的同学;还有单纯想把“隔离”这件事的原理搞明白的读者。不需要你已经是内核专家,但至少要能看懂基本的 Linux 命令和进程概念。下面我尽量把每个“为什么这么设计”都讲透,而不是甩一堆配置让你照抄。
1. 沙盒技术的本质:它到底在隔离什么
1.1 把“不可信”关进一个可控的房间
剥掉所有实现细节,沙盒技术干的事只有一件:让一段代码在运行时,它对系统资源的所有访问都必须经过我设定的关卡。这里的“资源”包括 CPU 时间、内存、磁盘、网络、设备、以及其他进程。关卡的设计决定了沙盒的强度,关卡漏了哪里,沙盒就在哪里破。
我习惯用租房来类比。你租出去一间房,房里有床有水电,但你不希望租客去动你主卧的抽屉、不希望他把整栋楼的总闸拉了、也不希望他半夜开派对吵到邻居。对应到技术层面对应四件事:文件系统要能看到但改不了关键部分(抽屉)、资源要有上限(总闸)、影响范围要收敛(邻居)、身份要降级(不能拿着房东的钥匙)。这四件事分别对应挂载隔离、cgroup 限额、命名空间、权限降权,缺一个都不算合格的沙盒。
很多人第一次接触沙盒会觉得它很神秘,其实你每天在用。浏览器打开一个网页,那个页面的 JavaScript 跑在渲染进程里,拿不到你的本地文件列表,也读不了另一个标签页的内存,这就是沙盒。手机上的应用装完弹出一堆权限申请,本质也是沙盒边界在向你暴露“我需要越界”。理解了这四个维度,后面所有的技术选型都只是在这四个维度上做加减法。
1.2 四种主流沙盒形态的横向对照
在实际工程里能碰到的沙盒,大致可以归成四类。它们的强度、开销、易用性差异非常大,选错了要么性能崩,要么安全感是假的。
| 形态 | 隔离粒度 | 启动开销 | 隔离强度 | 典型适用场景 |
|---|---|---|---|---|
| 语言级沙盒 | 同一进程内 | 极低,微秒级 | 弱,依赖解释器自身无缺陷 | 模板渲染、表达式求值、配置脚本 |
| 系统调用过滤 | 进程级 | 低,一次装载常驻 | 中,可精细到单个调用 | 长期驻留的解析服务 |
| 命名空间 + 挂载隔离 | 进程组级 | 中,毫秒到几十毫秒 | 较强,文件与网络边界清晰 | 批量任务、代码评测、插件运行 |
| 硬件虚拟化 | 整机级 | 高,百毫秒到秒级 | 最强,内核也被隔离 | 高价值不可信负载、多租户 |
这张表最容易被误读的地方是“强度”这一列。强度不是越高越好,因为强度直接换算成钱和延迟。我见过有团队为了跑几个简单表达式,硬上微虚拟机,结果单次请求 P99 从 8ms 涨到 400ms,用户直接投诉。也见过反过来的,把用户上传的脚本放在语言级沙盒里跑,问题是那个解释器本身就带文件读写能力,等于门锁装了但钥匙插在门上。
选型的判断标准我一般问三个问题:这段代码是谁写的、它出错时最坏能造成什么后果、我能接受多少额外延迟。三个答案基本就把方案框死了。第三方开发者提交的代码、后果可能是数据泄露、延迟敏感度低,那就往命名空间或虚拟化走;自己内部写的规则表达式、后果最多是算错、延迟敏感,语言级沙盒完全够用。
1.3 一个常见误解:沙盒不等于虚拟机
这两个概念在讨论里经常被混着用,但它们解决的问题不一样。虚拟机关心的是“模拟一台完整的机器”,沙盒关心的是“限制一个程序能碰到什么”。虚拟机天然包含沙盒能力,因为里面有独立内核,但代价是你要为每个实例付出一整套操作系统启动的成本。
反过来,沙盒做得好不好,跟有没有虚拟机没关系。一个只用命名空间隔离的容器,如果配置得当,对绝大多数不可信代码的防护已经足够;而一个配置不当的虚拟机,比如把宿主机目录直接共享进去、又给了 root 权限,照样能被穿透。我个人的经验是:先想清楚威胁模型,再决定要不要付虚拟化的钱。威胁来自“写错代码导致资源耗尽”,命名空间加限额就够了;威胁来自“有人刻意构造攻击载荷”,才需要考虑内核层面的隔离。
还有一个更隐蔽的误解:很多人以为沙盒是“一次性”的,跑完就销毁,所以不用管内部状态。实际上如果你的沙盒复用,上一个任务留下的临时文件、环境变量、缓存目录都可能成为下一个任务的攻击面。这个坑我在第 6 节会详细展开。
2. 底层机制拆解:权限、文件系统与系统调用
2.1 最小特权:先把钥匙交出去,再谈隔离
所有隔离手段里,降权是最便宜也最容易被跳过的一步。逻辑很简单:如果进程本身是 root,那它绕过大部分用户态限制只是时间问题;如果它是普通用户,很多破坏性操作在内核第一道检查就被拦下了。
我的固定做法是:沙盒进程必须以专用低权限账号运行,这个账号不做任何其他事,家目录是空的或只读的,不在 sudo 组里,也没有任何能力位。在 Linux 上可以用setpriv或capsh把能力集清空,只保留真正需要的那几个。绝大多数计算型任务,一个能力位都不需要。
# 以专用低权限用户启动,清空所有能力位,禁止新增特权 setpriv --reuid=61000 --regid=61000 --clear-groups \ --inh-caps=-all --ambient-caps=-all --bounding-set=-all \ --no-new-privs \ /opt/sandbox/runner--no-new-privs这个参数值得单独说。它防止进程通过 setuid 程序重新获得特权,是阻断“降权后又被提权”这条路径的关键开关。我见过有团队做了降权,但漏了这个标志,结果沙盒里的代码执行了一个带 setuid 位的二进制,权限又回去了,前面所有隔离白做。
注意:降权和隔离是两件事,不能互相替代。只降权不隔离,进程仍然能看到整台机器的文件;只隔离不降权,进程在隔离环境里还是老大。两个都得做。
2.2 文件系统隔离的三层做法
文件系统是最直观的攻击面。不可信代码最常干的事就是到处翻文件、往临时目录写东西、读取配置里的密钥。隔离手段从弱到强大致三层。
第一层是chroot,把进程的根目录换成一个子目录。它很老、很简单,但不够安全,因为如果你在 chroot 环境里保留了 root 权限,是有办法跳出去的。所以 chroot 必须配合降权使用,降权之后它才真正有意义。
第二层是挂载命名空间配合只读绑定挂载。这是我现在最常用的方式:给沙盒准备一个最小根目录,把需要的系统库以只读方式绑进去,工作目录单独挂一个可写的小分区,其他全部不可见。好处是权限边界非常清晰,坏处是要手工整理依赖。
# 在挂载命名空间内构造最小根 mount --bind -o ro /usr/lib "$ROOT/usr/lib" mount --bind -o ro /usr/bin "$ROOT/usr/bin" mount --bind -o ro /etc/ld.so.cache "$ROOT/etc/ld.so.cache" mount -t tmpfs -o size=64m,mode=1777 tmpfs "$ROOT/tmp" mount -t tmpfs -o size=16m,mode=0700 tmpfs "$ROOT/work"这里有个细节:/etc不要整个绑进去。配置文件里可能有凭据、可能有主机信息,只绑真正需要的单个文件。我一般只放ld.so.cache、nsswitch.conf、resolv.conf这三个,其他一律不给。
第三层是pivot_root彻底切换根,配合卸载旧根。它比 chroot 干净,因为旧根被真正移除了,进程连“跳回去”的路径都没有。容器运行时基本都用这个。手写的话稍微麻烦一点,需要先把新根挂成挂载点,再调用pivot_root,然后umount旧根。如果你的场景要求比较高,值得花这个功夫。
2.3 系统调用过滤:白名单比黑名单靠谱
命名空间解决了“看得见什么”,但没解决“能做什么”。进程仍然可以调用几千个系统调用,其中不少带着风险,比如创建新进程、加载内核模块、修改系统时间、建立原始套接字。
这一层靠 seccomp 做过滤。它的工作方式是在进程上挂一个 BPF 程序,每次系统调用进来先过一遍过滤器,命中规则就返回错误或者直接杀掉进程。关键的设计选择是用白名单还是黑名单。
我的建议是能白名单就白名单。黑名单的问题在于你永远想不到漏了哪个,而且内核新增调用时你的列表就过期了。白名单虽然前期麻烦,需要一个个试出程序真正用到哪些调用,但边界是闭合的。实际操作中可以用strace -c跑一遍正常流程,把调用列表收集出来,再去掉明显危险的那些,剩下的就是初始白名单。
{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": ["read", "write", "exit", "exit_group", "futex", "mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", "clock_gettime", "getpid", "gettid"], "action": "SCMP_ACT_ALLOW" }, { "names": ["clone", "clone3"], "action": "SCMP_ACT_ALLOW", "args": [{ "index": 0, "value": 65536, "op": "SCMP_CMP_MASKED_EQ" }] } ] }上面这段是 OCI 风格的 seccomp 配置片段,defaultAction设成SCMP_ACT_ERRNO意味着没列出来的一律返回错误。clone那条用掩码限制了只能创建线程(CLONE_THREAD),不能创建新进程。这个细节很实用,很多计算型任务需要多线程但不需要 fork,限制住之后能挡掉一大类 fork 炸弹。
提示:白名单调试期会很痛苦,程序动不动就 EPERM。建议先在审计模式下跑(
SCMP_ACT_LOG),把被拦的调用记录下来,观察一段时间再收紧成拒绝模式。上线直接开拒绝模式,十有八九会挂。
2.4 命名空间与资源限额:看不见和用不完
命名空间负责“看不见”,cgroup 负责“用不完”,两者配合才算完整。
命名空间现在有好几种,常用的几个是:pid让沙盒内的进程看不到外面的进程(同时保证沙盒内第一个进程是 PID 1,退出时整个沙盒跟着退),net给它一张独立的网卡甚至完全没网,mnt隔离挂载视图,user做用户 ID 映射,ipc隔断共享内存。组合起来用才有效,单开一个基本没用。
cgroup 是限额的核心。v2 统一层级之后配置清爽很多,主要管四个维度:内存、CPU、进程数、IO。
# 内存硬上限 512MB echo 536870912 > /sys/fs/cgroup/sandbox/memory.max # 不做 swap 兜底,超了直接 OOM echo 0 > /sys/fs/cgroup/sandbox/memory.swap.max # CPU 配额:周期 100ms,配额 50ms,等于 0.5 核 echo "50000 100000" > /sys/fs/cgroup/sandbox/cpu.max # 进程数上限,防 fork 炸弹 echo 256 > /sys/fs/cgroup/sandbox/pids.max这几个数字怎么定,第 3.4 节会给具体的计算方法。这里只说一个最容易忽略的点:memory.max触发的是 OOM kill,不是“拒绝分配”。也就是说内存打满时,内核会直接杀掉沙盒里的进程。对批量任务来说这是可接受的,但如果你在跑的是有状态的长任务,需要考虑把上限设得宽一些,或者在应用层做优雅退出。我曾经因为把上限卡得太紧,导致一个正常的大文件处理任务在中途被 OOM,反复重试,看起来像是“随机失败”,查了很久。
3. 从零搭一个最小可用沙盒
3.1 环境准备与选型思路
这一节我把整个搭建过程走一遍,目标是:能跑一段不可信脚本,跑完自动销毁,跑的过程中即使脚本乱来也影响不到宿主机。用到的都是内核原生能力,不依赖任何特定商业产品,你在自己的测试机上就能复现。
前提条件很简单:Linux 内核版本不要太老(cgroup v2 和较新的命名空间支持需要 5.x 以上),有 root 权限用于一次性配置,util-linux和libseccomp装好。生产环境里这些通常都有,本地用一台虚拟机或者一台闲置机器练手即可。
选型上我不打算用现成的容器引擎,原因是它把很多细节封装掉了,你学不到东西。我用手工拼装的方式,每一步都能看清在干什么。理解之后再去看任何封装好的工具,都能一眼看出它替你做哪几件事。
整个流程分五步:圈地(命名空间)、换根(挂载与 pivot)、限额(cgroup)、限权(能力位与 seccomp)、验收。下面一步步来。
3.2 第一步:用命名空间圈地
最省事的做法是用unshare把所有命名空间一次性开出来。这条命令会创建一个全新的进程,它拥有独立的 PID、网络、挂载、IPC、UTS 和用户视图。
unshare --user --map-root-user \ --mount --pid --net --ipc --uts --fork --mount-proc \ bash -c 'echo "inside pid: $$"; ls /proc | head -5'--map-root-user的作用是把当前用户映射成命名空间内的 root。这一步很关键,因为它让你在沙盒内部拥有足够的权限去做挂载等操作,但这些“权限”只在命名空间内有效,出了这个边界什么都不是。这是 user namespace 最漂亮的地方:不需要宿主机 root,也能搭出一套隔离环境。
--mount-proc会重新挂载/proc,让沙盒内的进程看到的是自己的进程列表,而不是宿主机的。如果不加这个,ps会看到外面一堆进程,隔离就不彻底了。
跑完之后你可以验证一下:在沙盒里echo $$得到的 PID 应该是 1 或者很小的数,ip addr应该只有一张lo,ls /proc里看不到宿主机的进程。这三条都对了,圈地就算成功。
注意:
--net会断掉网络。如果你的任务需要联网,得另外配置网络命名空间内的虚拟网卡和转发,这部分比较绕,我一般建议先用“完全断网 + 通过挂载目录交换数据”的方式,安全性高很多,实现也简单。
3.3 第二步:挂载只读根与临时可写层
有了命名空间,接下来要构造文件系统视图。思路是:准备一个空目录当新根,把宿主机的必要部分以只读方式绑进去,把可写部分换成 tmpfs,最后pivot_root。
NEWROOT=/run/sandbox/root mkdir -p "$NEWROOT"/{usr,lib,lib64,etc,tmp,work,proc,dev} # 只读绑定系统目录 for d in usr lib lib64; do mount --bind -o ro "/$d" "$NEWROOT/$d" 2>/dev/null || true done # 只放必要的配置文件,不要整个 /etc for f in ld.so.cache nsswitch.conf; do [ -f "/etc/$f" ] && mount --bind -o ro "/etc/$f" "$NEWROOT/etc/$f" done # 可写层用 tmpfs,进程退出自动回收 mount -t tmpfs -o size=64m,mode=1777 tmpfs "$NEWROOT/tmp" mount -t tmpfs -o size=32m,mode=0700 tmpfs "$NEWROOT/work" # 最小化的 /dev 与 /proc mount -t proc proc "$NEWROOT/proc" mount --bind -o ro /dev/null "$NEWROOT/dev/null" mount --bind -o ro /dev/zero "$NEWROOT/dev/zero" mount --bind -o ro /dev/urandom "$NEWROOT/dev/urandom"几个设计取舍值得说明。为什么可写层用 tmpfs 而不是普通目录?因为 tmpfs 只占内存,进程退出后内容自动消失,天然满足“用完即焚”,也不需要你写清理脚本。代价是内存占用,所以 size 要给死,别让它无限涨。
为什么/etc只绑单个文件?因为整个目录里包含主机名、密码文件、各种服务配置,随便一个都可能泄露信息。只绑ld.so.cache和nsswitch.conf是因为动态链接和用户查找需要,其他都不给。如果你的程序不需要解析用户名,nsswitch.conf也可以省掉。
最后执行pivot_root,把旧根彻底摘掉:
cd "$NEWROOT" mkdir -p .oldroot pivot_root . .oldroot umount -l /.oldroot rmdir /.oldroot exec chroot . /bin/bash到这一步,/已经是新的了,旧的文件系统被卸载,进程手里的文件描述符指向的也是新根的路径。这比单纯chroot干净得多。
3.4 第三步:资源限额的参数怎么算
限额参数拍脑袋定是很容易出事的。我给一个可复用的计算思路,分四项。
内存:先测单次任务的峰值常驻内存,用/usr/bin/time -v就能拿到Maximum resident set size。假设是 180MB,然后考虑任务可能处理更大的输入,按 2.5 倍冗余算 450MB,再加 64MB 的运行余量,得到约 514MB,向上取整到 512MB 或 640MB 这种整齐的数。如果任务有多种规格,按最大规格算,或者做多档配置。
CPU:先确定你要允许多少并发核数。如果一台 8 核机器打算同时跑 16 个沙盒,平均每个分到 0.5 核,那就是quota=50000, period=100000。这里要注意cpu.max限制的是配额,不是绑核,进程仍然会在多个核之间迁移,只是累计运行时间受限。
进程数:这个数是最容易被忽视的。pids.max卡的是线程和进程总数。一个 Java 程序光启动就几十个线程,一个 Python 多进程任务可能有上百个。我的经验值是先给 512,跑起来看pids.current的实际峰值,再往下压到峰值的两倍左右。压得太低会莫名其妙地创建线程失败。
磁盘:如果用 tmpfs 做可写层,size 就是磁盘上限。计算方式是:最大输出文件大小 × 2 + 临时文件余量。比如输出可能是 100MB 的 CSV,中间还要落一个 80MB 的临时文件,那就给 300MB 左右。
| 维度 | 测量方法 | 定值公式 | 常见误区 |
|---|---|---|---|
| 内存 | /usr/bin/time -v取峰值 | 峰值 × 2.5 + 64MB | 忽略页缓存导致的虚高 |
| CPU | 单次执行耗时估算 | 目标并发数反推配额 | 以为配额等于绑核 |
| 进程数 | 观察pids.current峰值 | 峰值 × 2 | 忽略线程也算进程 |
| 磁盘 | 统计最大输出与临时文件 | 输出 × 2 + 余量 | tmpfs 吃的是内存 |
提示:所有限额都要留“可观测”的口子。cgroup 里可以读
memory.peak、cpu.stat的nr_throttled,这些指标能告诉你限额是不是卡得太紧。nr_throttled持续很高,说明 CPU 被限得厉害,任务会变慢但不报错,这种“慢性病”最难查。
3.5 第四步:系统调用白名单
白名单的构建前面说过用strace -c收集,这里补充实操细节。先跑一遍完整任务,拿到调用频次列表,然后把明显危险的一类删掉,包括:mount系列、ptrace、bpf、init_module、kexec_load、reboot、swapon、setns、unshare。这些要么能改系统状态,要么能跳出当前命名空间。
剩下的一股脑放进允许列表,然后开审计模式跑一段时间。我一般会跑至少几十次真实任务,观察有没有被拦的调用没在白名单里。确认稳定后再切成拒绝模式。
有个细节:execve要不要放行?如果沙盒里的任务是通过 shell 启动子进程的,必须放。但如果你的任务是一个单一可执行文件跑到底,可以不放,这样能挡住“下载一个二进制再执行”这类行为。我倾向于不放,让任务只能跑我们指定的那个程序。
openat和open是另一类需要斟酌的。完全禁止会让程序没法读文件,显然不行。缩小风险的方式不是禁调用,而是靠挂载层的只读绑定来限制路径,两者配合才有效果。这也说明一个原则:每一层只管自己该管的事,别指望单层解决所有问题。
3.6 第五步:怎么验证它真的关得住
搭完不算完,得验证。我有一套固定的“捣乱测试”,每次改配置后都跑一遍。
| 测试项 | 命令 | 期望结果 |
|---|---|---|
| 越权读文件 | cat /etc/shadow | 权限拒绝或文件不存在 |
| 写根分区 | touch /test | 只读文件系统错误 |
| 爆破内存 | 循环分配大数组 | 被 OOM kill,宿主内存稳定 |
| fork 炸弹 | 递归 fork | 达到 pids.max 后失败,系统不卡 |
| 建网络连接 | curl外部地址 | 无网络或连接超时 |
| 看宿主进程 | ps aux | 只看到沙盒内进程 |
| 提权 | 执行 setuid 程序 | 权限不提升 |
跑完这七项,如果全部符合预期,这个沙盒基本可用。任何一项不符合,都要回头检查对应的那一层。我特别强调“爆破内存”这一项,一定要在隔离的测试机上跑,因为没有限额的话它真的会把宿主机拖死。
我踩过的一次坑是 fork 炸弹测试通过了,但内存测试失败。原因是pids.max限制了进程数,但单个进程仍然能无限申请内存,最后靠 OOM killer 兜底。虽然最终没崩,但杀进程的时候误伤了同一台机器上另一个正常服务。后来加上了memory.max和oom.group,让 OOM 时只杀沙盒内的进程组,问题才解决。
4. 落地场景:不同领域的沙盒怎么用
4.1 浏览器与文档解析:最贴近普通人的隔离
普通人每天都在用沙盒,只是不知道。浏览器把每个页面、每个扩展放在独立进程里,用系统调用过滤和渲染进程沙盒限制它的能力。你打开一个恶意网页,它能做的顶多是让标签页卡死,很难碰到你的本地文件。
这个场景里最有借鉴意义的设计是多进程 + 权限剥夺组合。渲染进程被设计成“默认不能做任何危险操作”,需要文件访问时由浏览器主进程代为执行,主进程自己也是低权限的。这种“危险操作全部集中到一个小小的受控层”的思路,在服务端同样适用。
我在做文档解析服务的时候抄过这个思路。解析器本身跑在沙盒里,只负责把文档转成中间格式,不碰网络、不碰数据库;真正的写库和通知交给外面的协调进程。这样即使解析器被构造的畸形文档打穿,损失也止步于一个中间结果。
4.2 在线代码评测与插件生态
代码评测是最典型的场景:用户提交任意代码,你要编译、运行、拿结果,还得保证用户 A 的代码不能影响用户 B,更不能拿到评测机的任何数据。
这个场景的沙盒要求比较特殊,因为它需要短时间内高并发地创建和销毁。虚拟化方案在这里通常不可行,冷启动太慢,成本压不住。我见过做得比较稳的方案是:长驻一批预热的沙盒池,每个沙盒做完一次任务就整体销毁重建,中间不做清理。重建的成本靠预热池摊销掉。
插件生态是另一类。宿主程序加载第三方插件,插件和宿主在同一台机器上,语言可能都不一样。这种情况下沙盒边界通常画在“能力”上而不是“进程”上:插件只能通过宿主提供的接口访问资源,接口之外的一切都碰不到。这种模式强度低于进程隔离,但对生态来说够用,因为插件是开发者主动发布的,有基本的信誉约束。
4.3 数据处理与模型加载
这一块最近问的人很多。加载外部来源的模型文件或者数据脚本,本质上等同于执行别人的代码,因为很多格式在解析时会触发反序列化,甚至直接包含可执行逻辑。
我的做法比较保守:反序列化一律在沙盒内完成,且沙盒完全断网。模型加载完成后,把权重以纯数据格式(比如二进制数组)导出到共享目录,再由主进程读入。这样即使解析过程被利用,攻击者也只能拿到一个空环境。
数据脚本同理。很多团队会让分析师写脚本处理数据,这些脚本里什么都可能有,包括误写的死循环和误删的路径。给每个脚本一个独立的限额沙盒,跑完即毁,是最省心的方式。这里有个实践细节:把输入数据以只读方式挂进去,输出目录单独挂一个可写 tmpfs,脚本结束后由外部进程决定要不要把结果搬走。这样能挡掉“脚本删了原始数据”这类事故。
4.4 客户端与服务端的边界差异
同样叫沙盒,客户端和服务端的关注点完全不同,混着设计会出问题。
服务端的核心诉求是多租户隔离和成本。一个沙盒挂了不能影响别人,同时单实例成本要足够低。所以倾向轻量方案,命名空间 + cgroup 是主流,配合严格的销毁流程。
客户端的核心诉求是防本地数据泄露和防提权,因为攻击者就在用户机器上,物理接触是有的。这时候光靠命名空间不够,因为用户自己就是管理员,可以随意改配置。客户端更依赖系统提供的机制,比如操作系统的应用沙盒、签名校验、以及把自己拆成特权进程和非特权进程两部分。
我见过一个反例:某桌面应用为了保证插件兼容性,把插件直接加载进主进程,只做了个“接口白名单”。结果一个插件里误用了系统 API,把主进程搞崩了,用户数据也丢了。这个案例说明,客户端沙盒的边界应该画在进程之间,而不是函数之间。
5. 性能开销与分级策略
5.1 开销来源拆解
沙盒不是免费的,知道自己为哪些东西付了钱,才能决定哪些钱可以不付。
| 开销来源 | 量级 | 是否可优化 |
|---|---|---|
| 命名空间创建 | 毫秒级,通常 < 10ms | 可,靠预热池摊销 |
| 挂载与 pivot_root | 毫秒级,取决于挂载点数量 | 可,减少绑定项 |
| cgroup 创建与配置 | 毫秒级 | 可,复用 cgroup |
| seccomp 装载 | 微秒级,一次装载 | 不可免,但很低 |
| 系统调用过滤运行时 | 单次几百纳秒,高频调用会累积 | 可,减少白名单条目 |
| 冷启动可执行文件 | 数十到数百毫秒 | 可,预加载 |
| 独立网络栈 | 相对较高 | 可,不需要时直接断网 |
最容易被低估的是“冷启动”这一项。如果你的沙盒每次都要从磁盘加载一个几百 MB 的运行时,启动时间会远超隔离本身的成本。解决的思路是预热:起一个沙盒池,跑完的不要立刻销毁,清理后放回池子里。清理的关键是彻底——工作目录重建、环境变量重置、文件描述符全关,不能有残留。
最容易被高估的是 seccomp 的运行时开销。实际上正常程序的系统调用以每秒几万次计,每次过滤多几百纳秒,总量也就毫秒级,基本感知不到。真正慢的是“过滤器设计得太复杂”,比如几百条带参数的规则串起来,那就可感知了。把高频放行的调用放在最前面,能让匹配更快。
5.2 三档分级方案
按我实际用下来的经验,把沙盒分三档最实用,团队里谁该用哪档一目了然。
轻档:独立用户 + 只读工作目录 + 资源限额,不开命名空间。适合内部人员写的数据处理脚本,威胁主要是“手滑”。开销几乎为零,接入成本也低。
中档:命名空间 + 只读挂载 + cgroup 限额 + seccomp 白名单 + 断网。适合第三方提交的代码、插件运行、模型加载。这是绝大多数场景的正确选择,我 80% 的项目都用这一档。
重档:在中档基础上加硬件虚拟化或独立内核。适合多租户 SaaS 里跑客户代码、以及处理高价值数据的场景。开销大,但边界最硬。
分档的意义在于避免“一刀切”。我见过所有任务都上重档的团队,机器成本翻了几倍,实际收益为零,因为攻击面根本没到那个级别。也见过所有任务都走轻档的,一次内部脚本误删差点把线上数据带走。
5.3 逃逸风险的通用分析框架
“沙盒能不能被逃出去”这个问题没有绝对答案,但有一套可复用的检查方法。我不讲具体手法,只讲该检查哪些位置,因为对防守方来说这就够了。
第一看共享资源。沙盒和宿主之间所有共享的东西都是通道:共享目录、共享内存、共享网络、挂载进来的设备节点。逐个问“这个共享是必须的吗、能不能只读、能不能换成本地副本”。共享目录是最常见的问题源,我的原则是能用一次性拷贝就不用长期挂载。
第二看内核暴露面。沙盒里能调用多少系统调用、能访问哪些设备、内核版本有没有已知问题。前两项靠 seccomp 和最小化/dev控制,第三项靠及时更新。这一块的检查应该做成周期性任务,而不是上线前看一眼。
第三看销毁流程。这个最容易被忽略。沙盒销毁时不彻底,残留的文件、进程、挂载点会成为下一次的突破口。我一般会检查三件事:进程是不是真的全退了(包括孤儿进程)、挂载点是不是都卸载了、临时目录是不是重建而不是清空(清空可能漏掉隐藏文件)。
第四看横向移动。即使单个沙盒被攻破,能不能从一个沙盒跳到另一个?这取决于它们是否共享网络、共享存储、共享凭证。我在设计时会刻意让沙盒之间完全不可见,每个都用独立的网络命名空间和独立的临时存储,从一个逃到另一个的成本和从外面攻进来一样高。
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 程序启动即报权限错误 | seccomp 白名单缺条目 | 看 dmesg 里的 audit 记录 |
| 创建线程失败 | pids.max太小 | 读pids.current对比峰值 |
| 任务随机被杀 | 内存超限触发 OOM | 读memory.events的oom计数 |
| 任务变慢但无报错 | CPU 配额被打满 | 读cpu.stat的nr_throttled |
| 找不到命令 | 最小根缺目录或库 | 检查绑定挂载列表 |
| 无法解析主机名 | 缺nsswitch.conf或断网 | 确认是否本来就要断网 |
| 输出文件为空 | 写在 tmpfs 里没搬出来 | 检查结果回收逻辑 |
| 沙盒残留进程 | 销毁时未杀进程组 | 检查 PID 1 退出处理 |
6.2 几个典型排查过程
有一次线上出现“任务成功率约 92%”的怪现象,失败的都是同一批稍大的输入。日志里没有任何异常,进程就是消失了。第一反应是代码有 bug,但同样的输入在本地跑百分之百成功。
排查顺序是这样走的:先看 cgroup 的memory.events,oom计数和失败次数对上了,问题就定位到内存限额。再看memory.peak,发现峰值刚好压在memory.max上。结论很清楚:输入变大后峰值内存超过了我们按平均情况定的限额。
解决办法不是简单调大限额,而是先把任务改成流式处理,把峰值降下来,同时把限额按新的峰值重算。这里想说的是,OOM 的表现是“进程无声消失”,日志里通常什么都没有,很容易误判成别的故障。养成先看 cgroup 指标的习惯,能省很多时间。
另一次是nr_throttled的问题。某个任务在测试环境跑 3 秒,线上跑 11 秒,代码完全一样。查下来是线上沙盒的 CPU 配额按并发数除过之后只有 0.3 核,而测试环境没设限额。这种“慢但不报错”的问题最难查,因为所有人第一反应都是“线上机器性能差”。
我的经验是给这类场景加一个显式的告警:如果单次任务的 CPU 时间远高于基线,就报警。这比人眼看出“好像变慢了”靠谱得多。
还有一次是 seccomp 白名单导致的诡异失败。程序启动正常,跑到某个分支时突然挂掉,且没有错误输出。用strace一跟才发现某个系统调用返回EPERM,而这个错误程序没处理,直接走到了一条未定义的路径上。
这件事给我的教训是:白名单一定要从真实负载里收集,不能凭经验列。而且测试用例要覆盖所有分支,不能只跑主流程。后来我把SCMP_ACT_LOG的日志接进了监控,任何被拦的调用都会打点,一旦有新的调用出现,立刻能发现。
6.3 避坑心得
第一条,不要在沙盒里做登录鉴权。听起来很基础,但我真的见过把数据库凭据通过环境变量传进沙盒的,理由是“反正它用完就销毁”。问题是环境变量对沙盒内所有进程可见,包括可能被注入的子进程,也可能出现在崩溃转储里。正确做法是把数据通过标准输入或者一次性挂载的文件传进去,用完即焚。
第二条,销毁流程要当成正经功能来开发。很多团队把清理写成一行rm -rf,结果遇到挂载点残留、进程占着文件、符号链接指向外面等问题。我现在的做法是写一个独立的销毁组件:先杀进程组,等它真的退出,再卸载所有挂载点(按挂载顺序的反向),最后重建目录。每一步都有超时和重试,日志留全。
第三条,沙盒内的时钟和随机源要当回事。有些沙盒为了最小化会不挂/dev/urandom,导致沙盒里的程序拿不到熵,加密操作直接阻塞。也见过不挂/proc但程序依赖它的,跑起来各种奇怪。我的最小集合里这两个是必给的,另外/dev/null和/dev/zero也是。
第四条,别用“跑通了”当验收标准。跑通只能证明正常路径没问题,沙盒的价值恰恰在于异常路径。前面那七项捣乱测试要坚持每次配置变更后都跑,而且要自动化。我一般会把它做成一个脚本,跟配置变更绑在一起,跑不过就不让上线。
最后分享一个扩展思路。如果你们的任务量上来了,单机沙盒池会开始遇到调度问题:怎么决定一个新任务进哪个沙盒、怎么在池子紧张时排队、怎么处理“卡死但不退出”的任务。这一层的复杂度往往超过沙盒本身。我个人的做法是给每个沙盒加一个硬超时,超时就整个销毁重建,不做任何挽救,因为不可信代码卡死的原因你通常查不清,重来比修复便宜得多。这个策略简单粗暴,但在实际运行里省下来的排查时间远大于重建的成本。