JanBox的小站 Exit Reader Mode

用 seccomp 去限制 Go 语言中的 ForkExec 所产生的子进程

本文所有内容均在 linux 环境下(Ubuntu 18.04)。

Go 提倡使用 Gorotuine 来替代系统所提供的 线程、进程,所以在语言中并没有提供直接 fork 或类似的方法。 只有提供了 ForkExec 及类似(包装后)的方法。 为了限制 syscall 的调用,go 语言只能使用 ptrace 进行捕捉后判断,这样使得效率会低下,(可以使用 time strace 做些测试)。那必然就要使用 seccomp,关于 seccomp 的一些东西可以看 开发OJ之沙箱 — syscall 限制;那问题来了,怎么把seccomp 的规则 给即将运行的命令呢?

这是个坑吧

最开始,想着在某一阶段 对本进程 load Seccomp filter 后直接调用 FrokExec 函数,但问题来了,ForkExec中有些系统调用会被继承到的的 seccomp 所限制导致无法执行。
所以 seccomp 只能在 执行命令前被载入。

这就是个坑

最初把go内部的ForkAndExec实现复制了一份,用 go:linkname 连接了所需函数,之后在最后Exec前添加 scmp.load 函数,但这样一运行就会 panic ,报 栈生长 错误。。。

花了大半天的时间看了一下 go 中关于 ForkExec 那部分的源码。

分析一下

只看了 Linux 下的源代码,毕竟 seccomp 只在 linux 下才能使用。 大致代码思路其实并没有什么不同,就是在 execve 前调用 Syscall 这个汇编函数去加载各种限制。只不过在fork开始前有一个 runtime_BeforeFork 的函数,父进程返回后之后会执行 runtime_AfterFork,子进程则会执行 runtime_AfterForkInChild,就是这么几个函数,导致这篇文章拖了这么久。

这三个函数,都只是link过来而已,函数的定义则都是在runtime包里。runtime_BeforeFork, runtime_AfterFork,[runtime_AfterForkInChild](https://github.com/golang/go/blob/master/src/runtime/proc.go#L3238];
这三个函数是为了 修复 这个 issue 而提交的 commit的。之后已经经过多次修改才成为现在看到的这样。

先说说这三个函数都干了啥。

BeforeFork

会在系统栈上进行操作, 通过别的资料可以了解到,golang协程必然也是有栈的,否则将无法记录函数的调用以及返回值。协程最先的栈是 2k ,之后会根据实际情况进行增长,所以这部分是语言自己维护的。BeforeFork 会先获取当前协程运行 M 的 G (GMP,之后锁定 G 绑定的 M 里的锁,将信号存到别的地方,当前 M 将不再处理信号,接着破坏当前栈,导致其在增长的时候会失败,接着抛出错误。

之后我们就可以调用 fork 了,这时候为了避免栈增长,只能调用汇编函数,好在 syscall 包里提供了一些方法来调用syscall。

AfterFork

fork之后,父线程将立刻返回,返回后才会父进程里会执行 afterFork, 这个函数也会在系统栈上操作。还是先获取当前 M 上的 G,之后将栈恢复,恢复刚刚存到别的地方的信号,继续接下去的操作。最后将 M 里的锁解除。

这样父进程就没有任何问题

AfterForkInChild

fork之后,子进程将会执行这个函数,与之前不停,它不会在系统栈上执行,这个函数只做两件事,清除原有的信号处理,恢复父进程继承下来的信号。

但是没有恢复之前被破坏的栈,所以,在这之后,都不能执行任何非汇编的函数调用。否则会直接因为栈空间增长直接 panic。

PS. 最先开始解决issue只会限制M里的信号处理防止信号冲突而fork失败,之后为了解决issue #7511 等问题才引入的,有兴趣可以去了解。

这样以来,我们如果使用 seccomp-golang 包的函数加载seccomp 由于涉及到函数调用,必然会出现栈增长,这时整个程序就会被破坏。
那如何解决呢?

solved it

查看了 libseccomp-golang 的包中提供了一个函数 ExportBpf, 可以将 seccomp 的规则导出成 bpf 规则链,之后使用 prctl 载入规则。

示例代码如下:

scmp, err := seccomp.NewFilter(seccomp.ActAllow)

scmpBpfFile, err := ioutil.TempFile("", "scmpBpfTempFile-")
if err != nil {
        panic(err)
}

err = scmp.ExportBPF(scmpBpfFile)
if err != nil {
        panic(err)
}
scmp.Release()

_, err = scmpBpfFile.Seek(0, os.SEEK_SET)
if err != nil {
        panic(err)
}
scmpBpfFileStat, _ := scmpBpfFile.Stat()
sockFilterFileSize := scmpBpfFileStat.Size()

if sockFilterFileSize % 8 != 0 {
        panic("sockFilterFileSize error " + string(sockFilterFileSize) )
}

sockFilters := make([]syscall.SockFilter, sockFilterFileSize / 8)

sockFilterFileContent, err := ioutil.ReadAll(scmpBpfFile)
bytesBuffer := bytes.NewBuffer(sockFilterFileContent)
err = binary.Read(bytesBuffer, binary.LittleEndian, &sockFilters)

bpf := syscall.SockFprog{
        Len: uint16(len(sockFilters)),
        Filter: &sockFilters[0],
}

r1, _, err1 := syscall.RawSyscall6(syscall.SYS_CLONE, uintptr(syscall.SIGCHID), 0, 0, 0, 0, 0)
if err1 != 0 || r1 != 0 {
    return 
}

_, _, err1 = syscall.RawSyscall6(syscall.SYS_PRCTL, syscall.PR_SET_SECCOMP, 2, uintptr(unsafe.Pointer(&bpf)), 0, 0, 0)

// ... exec code

大概就是这样的代码, 就能实现父进程不套 seccomp 限制,而子进程在 exec 前加上 seccomp 限制;如果想使用 seccomp 的 ACT_TRACE 功能,go 的syscall包中并没有 PTRACE_EVENT_SECCOMPPTRACE_O_TRACESECCOMP 的定义,需要手动定义

const PTRACE_EVENT_SECCOMP = 7
const PTRACE_O_TRACESECCOMP = 1 << PTRACE_EVENT_SECCOMP

至于 golang-seccomp 的使用,可以看文档,相比C语言来的更繁琐了,可以手动添加函数使得方便一些。包中的 ExportBPF 是写入文件,但是实现是写入一个 fd,可以手动加个函数 ExportBPF2Fd,直接写到文件,这样就可以使用 pipe 等更方便的方式传递,不用操作文件,相比之下效率会块很多。

以上~