https://x3fwy.bitcron.com/post/runc-malicious-container-escape
The `nsenter` package will `import "C"` and it uses [cgo](https://golang.org/cmd/cgo/) package. In cgo, if the import of "C" is immediately preceded by a comment, that comment, called the preamble, is used as a header when compiling the C parts of the package. So every time we import package `nsenter`, the C code function `nsexec()` would be called. And package `nsenter` is only imported in `init.go`, so every time the runc `init` command is invoked, that C code is run. Because `nsexec()` must be run before the Go runtime in order to use the Linux kernel namespace, you must `import` this library into a package if you plan to use `libcontainer` directly. Otherwise Go will not execute the `nsexec()` constructor, which means that the re-exec will not cause the namespaces to be joined. You can import it like this: ```go import _ "github.com/opencontainers/runc/libcontainer/nsenter" ```
init.go
func init() { if len(os.Args) > 1 && os.Args[1] == "init" { runtime.GOMAXPROCS(1) runtime.LockOSThread() level := os.Getenv("_LIBCONTAINER_LOGLEVEL") logLevel, err := logrus.ParseLevel(level) if err != nil { panic(fmt.Sprintf("libcontainer: failed to parse log level: %q: %v", level, err)) } err = logs.ConfigureLogging(logs.Config{ LogPipeFd: os.Getenv("_LIBCONTAINER_LOGPIPE"), LogFormat: "json", LogLevel: logLevel, }) if err != nil { panic(fmt.Sprintf("libcontainer: failed to configure logging: %v", err)) } logrus.Debugf("child process in init() and child pid is %d", os.Getpid()) } } var initCommand = cli.Command{ Name: "init", Usage: `initialize the namespaces and launch the process (do not call it outside of runc)`, Action: func(context *cli.Context) error { factory, _ := libcontainer.New("") if err := factory.StartInitialization(); err != nil { // as the error is sent back to the parent there is no need to log // or write it to stderr because the parent process will handle this os.Exit(1) } panic("libcontainer: container init failed to exec") }, }
main.go
app.Commands = []cli.Command{
checkpointCommand,
createCommand,
deleteCommand,
eventsCommand,
execCommand,
initCommand,
killCommand,
listCommand,
pauseCommand,
psCommand,
restoreCommand,
resumeCommand,
runCommand,
specCommand,
startCommand,
stateCommand,
updateCommand,
}
nsenter模块分析
nsenter模块,主要涉及namespace管理(把当前进程加入到指定的namespace或者创建新的namespace)、uid和gid的映射管理以及串口的管理等。
涉及golang和c两种语言实现,具体实现代码:
libcontainer/nsenter, 核心实现在libcontainer/nsenter/nsexec.c。
模块入口
1
|
package nsenter
|
当有包import _ "github.com/opencontainers/runc/libcontainer/nsenter"
的时候,会导致C语言实现的部分在编译的时候,编译到对应的可执行文件中。而这里的C代码,定义了一个构造函数init(void)
,从C语言的构造函数特性,可以了解到,构造函数会在main函数执行之前运行。那么,init(void)
函数会在可执行文件一开始就运行。所以,nsexec()
函数会第一个执行。
nsexec函数
主要功能如下:
- 设置log pipe,用于日志传输;
- 设置init pipe,用于namespace等配置数据的传输以及子进程pid的回传;
- ensure clone binary,用于解决CVE-2019-5736,防止/proc/self/exe导致的安全漏洞;
- 读取并解析init pipe传入的namespace等数据信息;
- 更新oom配置;
- 执行double fork
ensure clone binary
在第一次运行时,拷贝原始的二进制文件内容到内存。后续的二进制执行,都是使用的内存数据。从而消除,运行过程中二进制被修改,导致的安全漏洞。
具体实现待分析:clone_binary.c — ensure_cloned_binary()
tatic int clone_binary(void) { int binfd, execfd; struct stat statbuf = {}; size_t sent = 0; int fdtype = EFD_NONE; /* * Before we resort to copying, let's try creating an ro-binfd in one shot * by getting a handle for a read-only bind-mount of the execfd. */ execfd = try_bindfd(); if (execfd >= 0) return execfd; /* * Dammit, that didn't work -- time to copy the binary to a safe place we * can seal the contents. */ execfd = make_execfd(&fdtype); if (execfd < 0 || fdtype == EFD_NONE) return -ENOTRECOVERABLE; binfd = open("/proc/self/exe", O_RDONLY | O_CLOEXEC);
double clone
nsexec中,进行了2次clone进程。
至于为何需要进行2次clone操作的原因,可以参考注释:
1
|
/*
|
包括父进程在内,一共涉及了3个进程,它们的关系序列如下:
1
|
Title: How to clone init process
|