写点什么

安全 Linux 内核提权漏洞分析

  • 2022 年 3 月 22 日
  • 本文字数:3977 字

    阅读完需:约 13 分钟

漏洞复现

在 ubuntu-20.04-LTS 的虚拟机中进行测试, 内核版本号 5.10.0-1008-oem, 在 POC 执行后成功获取到 root shell。


从 POC 看漏洞利用流程

限于篇幅,这里截取 POC 的部分代码。

static void prepare_pipe(int p[2]){	if (pipe(p)) abort();
// 获取Pipe可使用的最大页面数量 const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ); static char buffer[4096];
// 任意数据填充 for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; write(p[1], buffer, n); r -= n; }
// 清空Pipe for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; read(p[0], buffer, n); r -= n; }}
int main(int argc, char **argv){ ......
// 只读打开目标文件 const int fd = open(path, O_RDONLY); // yes, read-only! :-) ...... // 创建Pipe int p[2]; prepare_pipe(p);
// splice()将文件1字节数据写入Pipe ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0); ...... // write()写入任意数据到Pipe nbytes = write(p[1], data, data_size);
// 判断是否写入成功 if (nbytes < 0) { perror("write failed"); return EXIT_FAILURE; } if ((size_t)nbytes < data_size) { fprintf(stderr, "short write\n"); return EXIT_FAILURE; }
printf("It worked!\n"); return EXIT_SUCCESS;}

复制代码

创建 pipe;

使用任意数据填充管道(填满, 而且是填满 Pipe 的最大空间);

清空管道内数据;

使用 splice()读取目标文件(只读)的 1 字节数据发送至 pipe;

write()将任意数据继续写入 pipe, 此数据将会覆盖目标文件内容;

只要挑选合适的目标文件(必须要有可读权限), 利用漏洞 Patch 掉关键字段数据, 即可完成从普通用户到 root 用户的权限提升, POC 使用的是/etc/passwd 文件的利用方式。

仔细阅读 POC 可以发现, 该漏洞在覆盖数据时存在一些限制, 我们将在深入分析漏洞原理之后讨论它们。

【一>所有资源获取<一】1、网络安全学习路线 2、电子书籍(白帽子)3、安全大厂内部视频 4、100 份 src 文档 5、常见安全面试题 6、ctf 大赛经典题目解析 7、全套工具包 8、应急响应笔记

复现原始 Bug

在作者的 paper 中可以了解到, 发现该漏洞的起因不是专门的漏洞挖掘工作, 而是关于日志服务器多次出现的文件错误, 用户下载的包含日志的 gzip 文件多次出现 CRC 校验位错误, 排查后发现 CRC 校验位总是被一段 ZIP 头覆盖。

根据作者介绍, 可以生成 ZIP 文件的只有主服务器的一个负责 HTTP 连接的服务(为了兼容 windows 用户, 需要把 gzip 封包即时封包为 ZIP 文件), 而该服务没有写入 gzip 文件的权限。

即主服务器同时存在一个 writer 进程与一个 splicer 进程, 两个进程以不同的用户身份运行, splicer 进程并没有写入 writer 进程目标文件的权限, 但存在 splicer 进程的数据写入文件的 bug 存在。

简化两个服务进程

根据描述, 简易还原出 bug 触发时最原本的样子, poc_p1 与 poc_p2 两个程序:

编译运行 poc_p1 程序, tmpFile 内容为全A


运行 poc_p2 程序, tmpFile 文件时间戳未改变, 但文件内容中出现了B


仔细观察每次出现脏数据的间隔, 发现恰好为 4096 字节, 4kB, 也是系统中一个页面的大小。


如果将进程可使用的全部 Pipe 大小进行一次写入/读出操作, tmpFile 的内容发生了变化。

同时可以注意到, tmpFile 文件后续并不是全部被B覆盖, 而是在 4096 字节处保留了原本的内容。

此时不执行任何操作, 重启系统后, tmpFile 将变回全A的状态, 这说明, poc_p2 程序对 tmpFile 文件的修改仅存在于系统的页面缓存(page cache)中。

以上便是漏洞出现的初始状态, 要分析其详细的原因, 就需要了解造成此状态的一些系统机制。

Pipe、splice()与零拷贝

限于篇幅, 这里简要介绍一下该漏洞相关的系统机制。

CPU 管理的最小内存单位是一个页面(Page), 一个页面通常为 4kB 大小, linux 内存管理的最底层的一切都是关于页面的, 文件 IO 也是如此, 如果程序从文件中读取数据, 内核将先把它从磁盘读取到专属于内核的页面缓存(Page Cache)中, 后续再把它从内核区域复制到用户程序的内存空间中;

如果每一次都把文件数据从内核空间拷贝到用户空间, 将会拖慢系统的运行速度, 也会额外消耗很多内存空间, 所以出现了 splice()系统调用, 它的任务是从文件中获取数据并写入管道中, 期间一个特殊的实现方式便是: 目标文件的页面缓存数据不会直接复制到 Pipe 的环形缓冲区内, 而是以索引的方式(即 内存页框地址、偏移量、长度 所表示的一块内存区域)复制到了 pipe_buffer 的结构体中, 如此就避免了从内核空间向用户空间的数据拷贝过程, 所以被称为"零拷贝";

管道(Pipe)是一种经典的进程间通信方式, 它包含一个输入端和一个输出端, 程序将数据从一段输入, 从另一端读出; 在内核中, 为了实现这种数据通信, 需要以页面(Page)为单位维护一个环形缓冲区(被称为pipe_buffer), 它通常最多包含 16 个页面, 且可以被循环利用;

当一个程序使用管道写入数据时, pipe_write()调用会处理数据写入工作, 默认情况下, 多次写入操作是要写入环形缓冲区的一个新的页面的, 但是如果单次写入操作没有写满一个页面大小, 就会造成内存空间的浪费, 所以 pipe_buffer 中的每一个页面都包含一个can_merge属性, 该属性可以在下一次 pipe_write()操作执行时, 指示内核继续向同一个页面继续写入数据, 而不是获取一个新的页面进行写入。

描述漏洞原理

splice()系统调用将包含文件的页面缓存(page cache), 链接到pipe的环形缓冲区(pipe_buffer)时, 在copy_page_to_iter_pipe 和 push_pipe函数中未能正确清除页面的"PIPE_BUF_FLAG_CAN_MERGE"属性, 导致后续进行pipe_write()操作时错误的判定"write操作可合并(merge)", 从而将非法数据写入文件页面缓存, 导致任意文件覆盖漏洞。

复制代码

这也就解释了之前原始 bug 造成的一些问题:

由于 pipe buffer 页面未清空, 所以第一次 poc_p2 测试时, tmpFile 从 4096 字节才开始被覆盖数据;

splice()调用至少需要将文件页面缓存的第一个字节写入 pipe, 才可以完成将 page_cache 索引到 pipe_buffer, 所以第二次 poc_p2 测试时, tmpFile 并没有全部被覆盖为"B", 而是每隔 4096 字节重新出现原始的"A";

每一次 poc_p2 写入的数据都是在 tmpFile 的页面缓存中, 所以如果没有其他可写权限的程序进行 write 操作, 该页面并不会被内核标记为“dirty”, 也就不会进行页面缓存写会磁盘的操作, 此时其他进程读文件会命中页面缓存, 从而读取到篡改后到文件数据, 但重启后文件会变回原来的状态;

也正是因为 poc_p2 写入的是 tmpFile 文件的页面缓存, 所以无限的循环会因文件到尾而写入失败, 跳出循环。

阅读相关源码

要了解漏洞形成的细节, 以及漏洞为什么不是从 splice()引入之初就存在, 还是要从内核源码了解 Pipe buffer 的can_merge属性如何迭代发展至今。

1.Linux 2.6, 引入了splice()系统调用;

2.Linux 4.9, 添加了 iov_iter 对 Pipe 的支持, 其中copy_page_to_iter_pipe()push_pipe()函数实现中缺少对 pipe buffer 中flag的初始化操作, 但在当时并无大碍, 因为此时的can_merge标识还在opspipe_buf_operations结构体中。 如图, 此时的buf->ops = &page_cache_pipe_buf_ops操作会使can_merge属性为 0, 此时并不会触发漏洞, 但为之后的代码迭代留下了隐患;


  1. Linux 5.1, 由于在众多类型的 pipe_buffer 中, 只有anon_pipe_buf_ops这一种情况的can_merge属性是为 1 的(can_merge字段在结构体中占一个 int 大小的空间), 所以, 将pipe_buf_operations结构体中的can_merge属性删除, 并且把 merge 操作时的判断改为指针判断, 合情合理。正是如此,copy_page_to_iter_pipe()中对buf->ops的初始化操作已经不包含can_merge属性初始化的功能了, 只是push_write()中 merge 操作的判断依然正常, 所以依然不会触发漏洞;


page_cache_pipe_buf_ops类型也在此时被修改。

然后是新的判断can_merge的操作, 直接判断是不是anon_pipe_buf_ops类型即可。

  1. Linux 5.8中, 把各种类型的pipe_buf_operations结构体进行合并, 正式把can_merge标记改为PIPE_BUF_FLAG_CAN_MERGE合并进入 flag 属性中, 知道此时, 4.9 补丁中没有flag字段初始化的隐患才真正生效

    合并后的anon_pipe_buf_ops不能再与can_merge强关联。再次修改了 merge 操作的判断方式。

    添加新的PIPE_BUF_FLAG_CAN_MERGE定义, 合并进入 pipe buffer 的 flag 字段。

  2. 内核漏洞补丁, 在copy_page_to_iter_pipe()push_pipe()调用中专门添加了对 buffer 中flag的初始化。


拓展与总结

关于该漏洞的一些限制:

显而易见的, 被覆写的目标文件必须拥有可读权限, 否则 splice()无法进行;

由于是在 pipe_buffer 中覆写页面缓存的数据, 又需要 splice()读取至少 1 字节的数据进入管道, 所以覆盖时, 每个页面的第一个字节是不可修改的, 同样的原因, 单次写入的数据量也不能大于 4kB;

由于需要写入的页面都是内核通过文件 IO 读取的 page cache, 所以任意写入文件只能是单纯的“覆写”, 不能调整文件的大小;

该漏洞之所以被命名为 DirtyPipe, 对比 CVE-2016-5195(DirtyCOW), 是因为两个漏洞触发的点都在于 linux 内核对文件读写操作的优化(写时拷贝/零拷贝); 而 DirtyPipe 的利用方式要比 DirtyCOW 的更加简单, 是因为 DirtyCOW 的漏洞触发需要进行条件竞争, 而 DirtyPipe 可以通过操作顺序直接触发;

值得注意的是, 该内核漏洞不仅影响了 linux 各个发行版, Android 或其他使用 linux 内核的 IoT 系统同样会受到影响; 另外, 该漏洞任意覆盖数据不只是影响用户或系统文件, 块设备、只读挂在的镜像等数据一样会受到影响, 基于此, 实现容器穿透也是有可能的。

总结

想想自己刚开始做漏洞复现的时候, 第一个复现的内核提权就是大名鼎鼎的 DirtyCOW, 所以看到 DirtyPipe 就不由得深入研究一下。这个漏洞的发现经历也非常有趣, 作者居然是从软件 bug 分析一路走到了内核漏洞披露, 相当佩服作者这种求索精神, 可以想象一个人在代码堆中翻阅各种实现细节时的辛酸, 也感谢作者如此详细的披露与分享。

用户头像

我是一名网络安全渗透师 2021.06.18 加入

关注我,后续将会带来更多精选作品,需要资料+wx:mengmengji08

评论

发布
暂无评论
安全Linux 内核提权漏洞分析_网络安全_网络安全学海_InfoQ写作平台