Go-Sandbox 开发实录 (3):画地为牢 —— 我们是如何构建“监狱”的?

把 Go 代码注入到 Python 进程后,我们拿到了控制权。现在的任务是:把这个进程关进“监狱”里。

internal/core/lib/python/add_seccomp.go 中,我们定义了这座“监狱”的构造。

第一步:切断退路 (Chroot)

首先要解决的是文件系统的隔离。我们不希望用户代码能看到 /etc/passwd,也不希望它能看到宿主机的任何文件。

chroot 是最古老也是最有效的手段之一。

// internal/core/lib/python/add_seccomp.go

func InitSeccomp(uid int, gid int, enable_network bool) error {
    // 1. 改变根目录
    // 这一步之后,进程眼中的 "/" 就变成了我们指定的临时目录
    if err := syscall.Chroot("."); err != nil {
        return err
    }
    // 2. 切换工作目录
    // 必须做这一步,否则进程还在原来的目录里,chroot 就失效了
    if err := syscall.Chdir("/"); err != nil {
        return err
    }
    // ...
}

开发时我们遇到的一个麻烦是:依赖库怎么办?
Python 跑起来需要标准库(os, sys 等)。一旦 chroot 了,它就找不到宿主机的 /usr/lib/python3.10 了。

我们最初尝试用 mount --bind 把宿主机的库挂载进去,但这样需要 root 权限,而且在某些容器环境下会失败。
最终我们采用了一个“笨”办法:在 Go Server 启动时,把需要的库文件复制(或硬链接)到临时目录里。 虽然有点繁琐,但兼容性最好。

第二步:捆住手脚 (Seccomp)

隔离了文件系统还不够,恶意代码还可以通过系统调用搞破坏。比如 socket 联网,或者 fork 炸弹。

这时候 Seccomp (Secure Computing Mode) 就派上用场了。它允许我们定义一个白名单:除了这些允许的动作,其他一律禁止!

我们在 internal/static/python_syscall/syscalls_amd64.go 里维护了一份长长的白名单。这份名单不是拍脑袋想出来的,而是我们用 strace 一个个试出来的。

// internal/static/python_syscall/syscalls_amd64.go

var ALLOW_SYSCALLS = []int{
    // 必须允许读写文件,不然代码怎么跑?
    syscall.SYS_READ, syscall.SYS_WRITE, syscall.SYS_OPENAT,
    
    // 内存分配也是必须的
    syscall.SYS_MMAP, syscall.SYS_BRK,
    
    // ... 还有很多杂七杂八的 syscall ...
}

开发趣事:
刚开始我们漏掉了 SYS_FUTEX。结果代码一跑多线程就卡死,查了半天才发现 Python 的线程锁底层依赖这个 syscall。
还有一次,我们发现代码莫名其妙 crash,原来是新版 Python 在启动时会尝试获取随机数 (SYS_GETRANDOM),而我们忘了加进去。

这也给我们一个教训:Seccomp 白名单维护是一个持续的过程,随着 Python 版本升级,可能需要不断调整。

第三步:剥夺身份 (Setuid)

最后,为了双重保险,我们强制进程放弃 root 权限。

// internal/core/lib/python/add_seccomp.go

// 禁止获取新权限(防止通过 execve 提权)
lib.SetNoNewPrivs()

// 变成普通人
syscall.Setuid(uid)
syscall.Setgid(gid)

这一步至关重要。因为如果 Seccomp 规则有漏洞,或者内核有漏洞,root 身份的进程造成的破坏是毁灭性的。降级为普通用户,至少能把破坏范围限制在最小。

总结

通过 Chroot 限制空间,Seccomp 限制行为,Setuid 限制身份,我们终于构建了一个密不透风的牢笼。

但这个牢笼里发生的事情,外面怎么知道呢?下一篇,我们聊聊如何打通“探监”的通道:IO 通信与超时控制。