Docker 代码沙箱防逃逸实战:从 Namespace 到 gVisor,我是怎么一层层锁死容器的
前阵子有个做在线编程平台的朋友找到我,说他们平台要支持用户在线提交代码并执行,问我用 Docker 跑用户代码安不安全。我当时就笑了——这事儿我太熟了,之前在做一个类似 LeetCode 的 OJ 系统的时候,就踩过不少坑。
说白了,Docker 容器不是什么铜墙铁壁,它本质上就是跑在宿主机内核上的进程,只不过套了几层"隔离马甲"。你要是觉得 docker run 一下就万事大吉了,那迟早要出事。今天我就把自己在生产环境中是怎么一步步加固 Docker 代码沙箱的经验,原原本本讲出来。
先搞清楚一个问题:Docker 容器到底隔离了什么?
很多人对 Docker 有个误解,觉得容器就是轻量级虚拟机。这个想法很危险。虚拟机有自己独立的内核,而容器是共享宿主机内核的,区别大了去了。
Docker 的隔离主要靠两样东西:Namespace 和 Cgroup。
Namespace 做的是资源视角的隔离,让容器里的进程觉得自己是系统唯一的主人。Linux 目前提供了 6 个主要的 Namespace:
- PID Namespace:容器里的进程看不到宿主机和其他容器的进程
- Mount Namespace:容器有自己独立的文件系统挂载点
- Network Namespace:容器有独立的网络栈、IP地址、端口
- IPC Namespace:进程间通信隔离
- UTS Namespace:主机名隔离
- User Namespace:用户和用户组隔离
Cgroup 做的是资源使用的限制,防止一个容器把宿主机的 CPU、内存、磁盘 IO 全吃光。
听着挺完善的对吧?但问题在于,这些隔离都是"软隔离",不是"硬隔离"。共享内核意味着,一旦内核有漏洞,或者你的配置有疏漏,容器里的恶意代码就有可能突破这些限制,逃到宿主机上。
我之前看过一个案例,某在线编程平台因为配置不当,用户通过 Python 的 os.system() 直接在宿主机上执行了命令,把整台服务器搞崩了。所以,做代码沙箱,安全这根弦必须绷紧。
第一道锁:资源限制,别让恶意代码把机器吃干抹净
做代码沙箱,最基本的就是限制资源。你想想,要是用户提交一个死循环,或者疯狂 fork 进程的 fork 炸弹,不限制的话宿主机直接就废了。
我一般会在 docker run 的时候加上这些参数:
docker run \
--memory="512m" \
--memory-swap="512m" \
--cpus="1" \
--pids-limit="50" \
--cpu-shares=512 \
sandbox-runner逐个解释下:
--memory="512m":限制容器最多使用 512MB 内存,超过就直接 OOM Kill,不给任何商量余地--memory-swap="512m":这个和 memory 设成一样的值,意思就是禁用 swap。别小看这个,有些恶意代码会利用 swap 来绕过内存限制--cpus="1":最多使用 1 个 CPU 核心的算力--pids-limit="50":限制容器内最多 50 个进程。这个是防 fork 炸弹的关键,:(){ :|:& };:这种东西直接就废了--cpu-shares=512:CPU 时间的权重分配,多个容器竞争 CPU 时按这个比例来
还有一点容易忽略的,磁盘 IO 也得限制:
# 创建限速的 cgroup
mkdir /sys/fs/cgroup/blkio/sandbox
echo "8:0 1048576" > /sys/fs/cgroup/blkio/sandbox/blkio.throttle.read_bps_device不过说实话,磁盘 IO 限制在实际操作中比较麻烦,我更倾向于直接把容器的文件系统设成只读,后面会讲。
第二道锁:Capability 裁剪,别给容器不需要的权限
Linux 把 root 的权限拆分成了几十个 Capability,Docker 默认只保留了容器运行所需的一小部分。但即便如此,默认配置下容器还是有一些不必要的权限。
你可以用这个命令看看默认情况下容器有哪些 Capability:
docker run --rm alpine capsh --print我这边输出大概有这些:CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE。
对于代码沙箱来说,这里面很多都是多余的。我的做法是把所有 Capability 都去掉,只加回必须的:
docker run \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
sandbox-runner--cap-drop=ALL 先把所有权限都干掉,然后 --cap-add=NET_BIND_SERVICE 只加回绑定 1024 以下端口的权限(如果你的沙箱不需要网络,这个也省了)。
千万别用 --privileged 参数!这个参数等于把所有安全机制全关了,容器里的 root 和宿主机的 root 基本没区别。我见过有人在生产环境用 --privileged 跑容器,被领导骂了三天。
第三道锁:Seccomp 系统调用过滤,把攻击面缩到最小
Seccomp 是我最喜欢的一个安全机制。它可以在内核层面限制容器进程能调用哪些系统调用,相当于给容器加了一层"系统调用防火墙"。
Docker 默认就启用了 Seccomp,使用一个白名单配置文件,默认会屏蔽大约 44 个危险或不常用的系统调用。你可以通过 docker info 确认:
Security Options:
seccomp
Profile: default但是,默认配置对于代码沙箱来说还是太宽松了。我一般会写一个自定义的 Seccomp Profile,只允许代码执行真正需要的系统调用。
举个实际的例子,如果沙箱只用来运行 Python 代码,我可以通过 strace 先抓取 Python 运行时需要哪些系统调用:
strace -c -f python3 /tmp/test.py 2>&1 | head -50然后根据抓取结果编写 Seccomp Profile。一个精简版的 Profile 大概长这样:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "open", "close", "fstat", "lseek",
"mmap", "mprotect", "munmap", "brk", "rt_sigaction",
"rt_sigprocmask", "ioctl", "access", "pipe", "select",
"poll", "mremap", "nanosleep", "clock_gettime",
"clone", "fork", "vfork", "execve", "exit", "wait4",
"uname", "fcntl", "flock", "fsync", "dup", "dup2",
"getpid", "getppid", "getuid", "getgid", "geteuid",
"getegid", "getrlimit", "gettimeofday", "arch_prctl",
"set_tid_address", "set_robust_list", "futex",
"clock_getres", "clock_nanosleep"
],
"action": "SCMP_ACT_ALLOW"
}
]
}关键点在于 defaultAction 设成了 SCMP_ACT_ERRNO,意思就是默认拒绝所有系统调用,只在白名单里的才放行。这比默认配置安全得多。
使用自定义 Profile 启动容器:
docker run \
--security-opt seccomp=/path/to/seccomp-profile.json \
sandbox-runner有个真实的案例很有说服力:CVE-2017-16995 这个内核漏洞,攻击者可以通过 bpf 系统调用实现提权。但 Docker 默认的 Seccomp 配置屏蔽了 bpf 系统调用,所以这个漏洞在 Docker 容器里根本没法利用。这就是 Seccomp 的价值。
不过写 Seccomp Profile 是个苦力活,不同语言运行时需要的系统调用不一样,你得一个一个测。我建议先用 SCMP_ACT_LOG 模式跑一段时间收集日志,确认没问题再切换到 SCMP_ACT_ERRNO。
第四道锁:AppArmor / SELinux,强制访问控制再加一道门
Seccomp 管的是系统调用,AppArmor 管的是资源访问。它可以在文件读写、网络访问、Capability 等层面做更细粒度的限制。
Ubuntu 默认使用 AppArmor,CentOS/RHEL 默认使用 SELinux。Docker 在 Ubuntu 上默认会给容器加载一个 AppArmor Profile,叫做 docker-default。
对于代码沙箱,我一般会写一个更严格的自定义 AppArmor Profile:
#include <tunables/global>
profile sandbox-profile flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# 只允许读 /usr 和 /lib
/usr/** r,
/lib/** r,
/lib64/** r,
# 允许写临时目录
/tmp/** rw,
/dev/null rw,
/dev/urandom r,
# 禁止网络访问(除了已建立的连接)
deny network,
deny network inet,
deny network inet6,
# 禁止 ptrace(防止调试其他进程)
deny ptrace,
# 禁止 mount
deny mount,
# 禁止加载内核模块
deny /sys/module/** rw,
}加载并使用这个 Profile:
# 编译并加载 Profile
apparmor_parser -r /etc/apparmor.d/sandbox-profile
# 使用 Profile 启动容器
docker run \
--security-opt apparmor=sandbox-profile \
sandbox-runnerAppArmor 还有个好处是它的日志很好用。当容器进程尝试访问被禁止的资源时,/var/log/syslog 或 /var/log/audit/audit.log 里会有详细记录,方便你排查问题。
如果你的系统是 CentOS,那就用 SELinux,思路是一样的,只是配置方式不同。SELinux 用标签和策略来管理访问控制,比 AppArmor 更严格但也更复杂。不过对于代码沙箱场景,AppArmor 够用了。
第五道锁:User Namespace,让容器里的 root 变成宿主机的 nobody
这是个很容易被忽略但非常重要的安全机制。默认情况下,Docker 容器里的 root 用户(UID 0)和宿主机的 root 用户在文件权限上是等价的。也就是说,一旦容器逃逸,攻击者拿到的是宿主机的 root 权限,想想都后怕。
User Namespace 的作用是把容器内的 UID 映射到宿主机的非特权 UID。比如容器内的 root(UID 0)在宿主机上实际是 UID 100000,这样就算逃逸了,攻击者在宿主机上也没有任何特权。
Docker 启用 User Namespace 的方式:
# 先在 /etc/docker/daemon.json 中配置
{
"userns-remap": "default"
}
# 重启 Docker
systemctl restart dockerDocker 会自动创建一个叫 dockremap 的用户,并配置 subuid 和 subgid 映射。你可以验证一下:
cat /etc/subuid
# dockremap:100000:65536
cat /etc/subgid
# dockremap:100000:65536这意味着容器内的 UID 0-65535 会映射到宿主机的 UID 100000-165535。
启用 User Namespace 之后有个副作用:一些需要特权的操作(比如挂载 NFS、使用 --net=host)就不能用了。但对于代码沙箱来说,这些功能本来就不需要,所以影响不大。
第六道锁:只读文件系统 + 临时存储,不给恶意代码留后门
代码沙箱最怕的就是恶意代码往文件系统里写东西,比如写个后门脚本、改个配置文件什么的。我的做法是把容器的根文件系统设成只读:
docker run \
--read-only \
--tmpfs /tmp:size=100m,mode=1777 \
--tmpfs /run:size=10m,mode=0755 \
sandbox-runner--read-only 把整个根文件系统挂载为只读,--tmpfs 给需要写入的目录单独挂载内存文件系统。/tmp 给 100MB,/run 给 10MB,够用了。
有些运行时还需要 /dev/shm,也可以加上:
--tmpfs /dev/shm:size=64m,mode=1777这样恶意代码就算想写东西,也只能写到 /tmp 里,而且容器一销毁就没了,啥也留不下。
还有一点,别把宿主机的目录挂载到容器里,尤其是敏感目录。我见过有人把 /var/run/docker.sock 挂进容器的,这等于把宿主机的 Docker 控制权拱手让人,攻击者可以直接创建一个特权容器然后逃逸。
第七道锁:网络隔离,让恶意代码连不上外网
代码沙箱里的程序一般不需要网络访问。就算需要,也应该严格限制。
最简单粗暴的方式就是直接禁用网络:
docker run \
--network=none \
sandbox-runner这样容器里连 ping 都用不了,恶意代码想外传数据?没门。
如果你的沙箱确实需要网络(比如代码里要请求某个 API),那可以用自定义网络 + iptables 规则来做白名单:
# 创建自定义网络
docker network create --driver bridge sandbox-net
# 启动容器
docker run \
--network=sandbox-net \
sandbox-runner
# 在宿主机上用 iptables 限制容器只能访问特定 IP
iptables -I FORWARD -s 172.20.0.0/16 -d 允许的IP -j ACCEPT
iptables -I FORWARD -s 172.20.0.0/16 -j DROP这样容器只能访问你允许的 IP,其他一概不通。
第八道锁:超时机制,别让代码跑到天荒地老
恶意代码不一定要搞破坏,它也可以就是跑个死循环,占着你的计算资源不放。所以超时机制必须有。
我一般这样处理:
timeout 10 docker run \
--rm \
--memory="512m" \
--cpus="1" \
sandbox-runnertimeout 10 表示 10 秒后强制终止。--rm 表示容器结束后自动删除,不留痕迹。
但在生产环境中,我更倾向于在代码层面控制超时,因为 timeout 命令发的是 SIGTERM,有些进程可能捕获信号后不退出。更可靠的做法是在容器内部用进程管理器来控制:
import subprocess
import signal
try:
result = subprocess.run(
["python3", "/tmp/user_code.py"],
timeout=10,
capture_output=True,
preexec_fn=lambda: signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError()))
)
except subprocess.TimeoutExpired:
# 超时处理
pass终极大招:gVisor / Kata Containers,真正的沙箱容器
前面说的那些加固措施,都是基于传统 Docker 容器的,本质上还是共享内核。如果你的安全要求特别高(比如多租户环境、运行完全不可信的代码),那可以考虑使用沙箱容器运行时。
gVisor 是 Google 开源的一个项目,它实现了一个用户态内核叫 Sentry,拦截容器进程的所有系统调用,在用户态处理后再转发给宿主机内核。这样容器进程和宿主机内核之间就多了一层防护,攻击面大幅缩小。
gVisor 和宿主机内核的通信只用了不到 20 个系统调用,而传统容器可以用 300 多个。这差距,安全程度可想而知。
安装和使用 gVisor 也比较简单:
# 安装 runsc
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install runsc
# 配置 Docker 使用 runsc
# 在 /etc/docker/daemon.json 中添加
{
"runtimes": {
"runsc": {
"path": "/usr/bin/runsc"
}
}
}
# 重启 Docker
systemctl restart docker
# 使用 gVisor 运行容器
docker run --runtime=runsc sandbox-runnergVisor 的缺点也很明显:系统调用开销导致性能有损耗,不适合系统调用密集型的应用;不支持 GPU 直通;没有实现全部的 Linux 系统调用,有些应用可能跑不起来。
Kata Containers 是另一种方案,它本质上是在轻量级虚拟机里跑容器。每个容器(或 Pod)都有自己独立的内核,隔离性接近虚拟机,但使用体验和普通容器一样。
Kata 的安全边界比 gVisor 更强,但资源开销也更大,每个容器至少需要几十 MB 的额外内存。对于在线编程平台这种需要同时运行成百上千个沙箱的场景,gVisor 可能更合适。
我的最终方案:层层叠加,纵深防御
说了这么多,最后晒一下我在生产环境中实际使用的完整配置。我把它封装成了一个脚本:
#!/bin/bash
docker run \
--rm \
--runtime=runsc \
--network=none \
--read-only \
--memory="512m" \
--memory-swap="512m" \
--cpus="1" \
--pids-limit="50" \
--cap-drop=ALL \
--security-opt seccomp=/etc/docker/seccomp/sandbox.json \
--security-opt apparmor=sandbox-profile \
--tmpfs /tmp:size=100m,mode=1777 \
--tmpfs /run:size=10m,mode=0755 \
--user=1000:1000 \
sandbox-runner \
timeout 10 python3 /tmp/user_code.py这里有几个值得注意的点:
--runtime=runsc:使用 gVisor 运行时,从内核层面加强隔离--network=none:完全禁用网络--read-only:只读文件系统--cap-drop=ALL:去掉所有 Linux Capabilities--security-opt seccomp=...:自定义 Seccomp Profile--security-opt apparmor=...:自定义 AppArmor Profile--user=1000:1000:以非 root 用户运行timeout 10:10 秒超时
这套配置不是一次性就定下来的,是在实际运行中不断调整的。刚开始的时候,因为 Seccomp 太严格导致很多正常代码跑不了,我就把 SCMP_ACT_ERRNO 改成 SCMP_ACT_LOG 先观察了一周,收集了所有被拒绝的系统调用,确认哪些是必须的后才逐步放开。
还有个经验:每次更新语言运行时版本后,都要重新测试 Seccomp Profile,因为新版本可能会用到之前不需要的系统调用。我就吃过这个亏,升级 Python 版本后用户代码全报错,排查了半天才发现是 Seccomp 屏蔽了一个新加的系统调用。
一些血的教训
最后分享几个我在实战中遇到的真实问题:
1. docker.sock 千万别挂进去
这个前面提过了,但还是要强调。我见过不止一次,有人为了在容器里操作 Docker,把 /var/run/docker.sock 挂进容器。这就等于把宿主机的 root 权限交出去了。
2. 别用 latest 标签跑生产环境
镜像版本要固定,用 digest 更好。恶意镜像替换这种攻击虽然少见,但不是不可能。
3. 定期更新内核
Docker 的安全很大程度上依赖内核。脏牛漏洞(CVE-2016-5195)可以直接从容器里逃逸,就是因为内核的问题。保持内核更新是最基本的安全措施。
4. CVE-2019-5736 的启示
这个漏洞非常经典。攻击者通过覆写宿主机上的 runc 二进制文件,实现了容器逃逸。根本原因是 runc 进入容器命名空间时,/proc/self/exe 指向了宿主机的 runc 程序,而容器内的 root 用户可以写入这个文件。这个漏洞的修复方式是在 runc 进入容器前先克隆一份自身的二进制文件。这个案例告诉我们,不要低估攻击者的创造力,即使看起来很小的疏漏也可能被利用。
5. 监控和日志不能少
再安全的沙箱也需要监控。我会在宿主机上用 auditd 记录所有和容器相关的系统调用,用 Falco 做运行时安全检测。一旦发现异常行为(比如容器进程尝试访问 /proc 或 /sys 下的敏感文件),立即告警。
总结
做 Docker 代码沙箱,没有银弹,只有纵深防御。单靠任何一层安全机制都不够可靠,必须把 Namespace、Cgroup、Capability、Seccomp、AppArmor、User Namespace、只读文件系统、网络隔离这些手段叠加起来,再加上 gVisor 这类沙箱运行时,才能把风险降到可接受的水平。
记住一个原则:最小权限。容器只需要能完成任务的最低权限,多给一点都不行。安全这事儿,宁可过度,不能不足。
还有就是,安全是一个持续的过程,不是一次性的配置。新的漏洞会不断出现,你的防御措施也要跟着更新。保持关注 Docker 和 Linux 内核的安全公告,及时打补丁,这是最基本的运维素养。
如果你觉得这篇文章对你有帮助,欢迎关注公众号【耕云躬行录】,我会持续分享更多运维和云原生安全方面的实战经验。也欢迎转发给你的同事朋友,让更多人少踩坑。
有问题可以在公众号留言,我看到都会回复的。
公众号:耕云躬行录
个人博客:躬行笔记