runc 1.0-rc7 发布之际

在 18 年 11 月底时,我写了一篇文章 《runc 1.0-rc6 发布之际》 。如果你还不了解 runc 是什么,以及如何使用它,请参考我那篇文章。本文中,不再对其概念和用法等进行说明。

在 runc 1.0-rc6 发布之时,给版本的别名为 "For Real This Time",当时我们原定计划是发布 1.0 的,但是作为基础依赖软件,我们认为当时的版本还有几个问题:

  • 不够规范;
  • 发布周期不明确;

为了给相关的 runtime 足够的时间进行修正/升级,以及规范版本生命周期等,最终决定了发布 runc 1.0-rc6

为何有 runc 1.0-rc7 存在

前面已经基本介绍了相关背景,并且也基本明确了 rc6 就是在 1.0 正式发布之前的最后一个版本,那 rc7 为什么会出现呢?

CVE-2019-5736

我们首先要介绍今年 runc 的一个提权漏洞 CVE-2019-5736

2019 年 2 月 11 日在 oss-security 邮件组正式批露该漏洞,攻击者可以利用恶意容器覆盖主机上的 runc 文件,从而达到攻击的目的;(具体的攻击方式此处略过),注意不要轻易使用来源不可信的镜像创建容器便可有效避免被攻击的可能。

简单补充下可能被攻击的方式:

  • 运行恶意的 Docker 镜像
  • 在主机上执行 docker exec 进入容器内

关于容器安全或者容器的运行机制,其实涉及的点很多,我在去年的一次线上分享 《基于 GitLab 的 CI 实践》 有提到过 Linux Security Modules(LSM)等相关的内容,对容器安全感兴趣的朋友可以对 LSM 多了解下。

不过本文主要看的是 runc 如何修复该漏洞的,以及后续产生的影响。

修复方式

// 对 memfd_create 系统调用做了个封装 省略部分代码
#if !defined(SYS_memfd_create) && defined(__NR_memfd_create)
#  define SYS_memfd_create __NR_memfd_create
#endif
#ifdef SYS_memfd_create
#  define HAVE_MEMFD_CREATE
#  ifndef MFD_CLOEXEC
#    define MFD_CLOEXEC       0x0001U
#    define MFD_ALLOW_SEALING 0x0002U
#  endif
int memfd_create(const char *name, unsigned int flags)
{
    return syscall(SYS_memfd_create, name, flags);
}

// 一个简单的只读缓存区
static char *read_file(char *path, size_t *length)
{
    int fd;
    char buf[4096], *copy = NULL;

    if (!length)
        return NULL;

    fd = open(path, O_RDONLY | O_CLOEXEC);
    if (fd < 0)
        return NULL;

    *length = 0;
    for (;;) {
        int n;

        n = read(fd, buf, sizeof(buf));
        if (n < 0)
            goto error;
        if (!n)
            break;

        copy = must_realloc(copy, (*length + n) * sizeof(*copy));
        memcpy(copy + *length, buf, n);
        *length += n;
    }
    close(fd);
    return copy;

error:
    close(fd);
    free(copy);
    return NULL;
}

// 将复制后的 fd 重赋值/执行
static int clone_binary(void)
{
    int binfd, memfd;
    ssize_t sent = 0;

#ifdef HAVE_MEMFD_CREATE
    memfd = memfd_create(RUNC_MEMFD_COMMENT, MFD_CLOEXEC | MFD_ALLOW_SEALING);
#else
    memfd = open("/tmp", O_TMPFILE | O_EXCL | O_RDWR | O_CLOEXEC, 0711);
#endif
    if (memfd < 0)
        return -ENOTRECOVERABLE;

    binfd = open("/proc/self/exe", O_RDONLY | O_CLOEXEC);
    if (binfd < 0)
        goto error;

    sent = sendfile(memfd, binfd, NULL, RUNC_SENDFILE_MAX);
    close(binfd);
    if (sent < 0)
        goto error;

#ifdef HAVE_MEMFD_CREATE
    int err = fcntl(memfd, F_ADD_SEALS, RUNC_MEMFD_SEALS);
    if (err < 0)
        goto error;
#else

    int newfd;
    char *fdpath = NULL;

    if (asprintf(&fdpath, "/proc/self/fd/%d", memfd) < 0)
        goto error;
    newfd = open(fdpath, O_RDONLY | O_CLOEXEC);
    free(fdpath);
    if (newfd < 0)
        goto error;

    close(memfd);
    memfd = newfd;
#endif
    return memfd;

error:
    close(memfd);
    return -EIO;
}

int ensure_cloned_binary(void)
{
    int execfd;
    char **argv = NULL, **envp = NULL;

    int cloned = is_self_cloned();
    if (cloned > 0 || cloned == -ENOTRECOVERABLE)
        return cloned;

    if (fetchve(&argv, &envp) < 0)
        return -EINVAL;

    execfd = clone_binary();
    if (execfd < 0)
        return -EIO;

    fexecve(execfd, argv, envp);
    return -ENOEXEC;
}

省略掉了部分代码,完整代码可直接参考 runc 代码仓库

整个的修复逻辑我在上面的代码中加了备注,总结来讲其实就是:

  • 创建了一个只存在于内存中的 memfd ;
  • 将原本的 runc 拷贝至这个 memfd ;
  • 在进入 namespace 前,通过这个 memfd 重新执行 runc ; (这是为了确保之后即使被攻击/替换也操作的还是内存中的这个只读的 runc)

经过以上的操作,就基本修复了 CVE-2019-5736 。

影响

内核相关

在上面讲完修复方式后,我们来看下会产生哪些影响。

  • 涉及到了系统调用 memfd_create(2)fcntl(2)

增加了系统调用,那自然就要看内核是否支持了。实际上,这些函数是在 2015 年 2 月(距这次修复整整 4 年,也挺有趣)被加入到 Linux 3.17 内核中的。

换句话说就是 凡是在此内核版本之前的系统,均无法正常使用该功能,对我们的影响就是,如果你在此版本内核之前的机器上使用了包含上述修复代码的 runc 或构建在其之上的 containerd、 Docker 等都无法正常工作

以 Docker 举例:安装 docker-ce-18.09.2 或 docker-ce-18.06.3 可避免受 CVE-2019-5736 影响,但如果内核版本较低,在运行容器时可能会有如下情况出现: (不同版本/内核可能出现其他情况)

[tao@moelove ~]# docker run --rm my-registry/os/debian echo Hello     
docker: Error response from daemon: OCI runtime create failed: container_linux.go:344: starting container
process caused "process_linux.go:293: copying bootstrap data to pipe caused \"write init-p: broken pipe\"": unknown.
  • 解决办法

    • 升级内核;这是最直接的办法,而且使用一个新版本的内核也能省去很多不必要的麻烦:)
    • rancher 提供了一个 runc-cve 的 patch,可兼容部分 3.x 内核的系统(我没有测试过)
    • 如果你不升级 runc/containerd/Docker 等版本的话,那建议你 1. 将 runc 可执行程序放到只读文件系统上,可避免被覆盖;2. 启动容器时,启用 SELinux; 3. 在容器内使用低权限用户或者采用映射的方式,但保证用户对主机上的 runc 程序无写权限。

注意:

memfd_create 等相关系统调用,也被加入到了 Debian 3.16 和 Ubuntu 14.04 updates 中,当然也被反向移植到了 CentOS 7.3 内核 3.10.0-514 版本之后。 (Red Hat 给 CentOS 7.x 的 3.10 内核上反向移植了很多特性)

内存相关

从上面的说明中,也很容易可以看到, 内存的使用上会有所增加,不过之后已做了修复。这里不再进行展开。

其他

偶尔可能触发一些内核 bug 之类的(总之建议升级 :)

等待 rc8 发布

上面已经介绍了 1.0-rc7 出现的主要原因 CVE-2019-5736;当然这个版本中也有一些新特性和一些 bugfix 不过不是本文的主要内容,不再赘述。

值得一提的是这次的版本命名:runc 1.0-rc7 -- "The Eleventh Hour" 后面这个别名其实来自于一部英剧,感兴趣也可以去看看。

至于下个版本是不是会是 1.0 正式版呢?目前来看应该不是,有极大可能会发布 runc 1.0-rc8 做一些 bugfix,让我们拭目以待。


可以通过下面二维码订阅我的文章公众号【MoeLove】

runc 1.0-rc7 发布之际

相关推荐