【注意】最后更新于 August 19, 2023,文中内容可能已过时,请谨慎食用。
在分析完 CVE-2016-5195 后,我注意到最近 Linux Kernel 又出了一个和内存管理子系统相关的洞 CVE-2023-3269,正好趁机会把前一篇文章没有分析的细节看一遍,主要是 mmap 的内核态实现,这也算是先射箭再画靶了。
背景知识
mmap 函数
摘自深入理解mmap–内核代码分析及驱动demo示例:
1
2
3
|
// include<sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t > offset);
int munmap(void *addr, size_t length);
|
- addr:指定起始地址,为了可移植性一般设为 NULL
- length:表示映射到进程地址空间的大小
- prot:属性,PROT_EXEC、PROT_READ、PROT_WRITE、PROT_NONE
- flags:标志,如共享映射、私有映射
- fd:文件描述符,匿名映射时设为 -1
- offset:文件映射时,表示偏移量
flag标志
MAP_SHARED
:创建一个共享的映射区域。多个进程可以这样映射同一个文件,修改后的内容会同步到磁盘> 文件中。
MAP_PRIVATE
:创建写时复制的私有映射。多个进程可以私有映射同一个文件,修改之后不会同步到磁盘> 中。
MAP_ANONYMOUS
:创建匿名映射,即没有关联到文件的映射
MAP_FIXED
:使用参数 addr 创建映射,如果无法映射指定的地址就返回失败,addr 要求按页对齐。如果指定的地址空间与已有的 VMA 重叠,会先销毁重叠的区域。
MAP_POPULATE
:对于文件映射,会提前预读文件内容到映射区域,该特性只支持私有映射。
4类映射
根据 prot 和 flags 的不同组合,可以分为以下4种映射类型:
- 私有匿名:通常用于内存分配(大块)
- 私有文件:通常用于加载动态库
- 共享匿名:通常用于进程间共享内存,默认打开
/dev/zero
这个特殊的设备文件
- 共享文件:通常用于内存映射 I/O,进程间通信
VMA 结构体
进程地址空间在Linux内核中使用 struct vm_area_struct
来描述,简称 VMA。由于这些地址空间归属于各个用户进程,所以在用户进程的 struct mm_struct
中也有相应的成员。进程可以通过内核的内存管理机制动态地添加或删除这些内存区域。
每个内存区域具有相关的权限,比如可读、可写、可执行。如果进程访问了不在有效范围内的内存区域、或非法访问了内存,那么处理器会报缺页异常,严重的会出现段错误。
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
64
65
66
67
68
69
|
// include/linux/mm_types.h
/*
* This struct defines a memory VMM memory area. There is one of these
* per VM-area/task. A VM area is any part of the process virtual memory
* space that has a special rule for the page-fault handlers (ie a shared
* library, the executable area etc).
*/
struct vm_area_struct {
/* The first cache line has the info for VMA tree walking. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
/*
* Largest free memory gap in bytes to the left of this VMA.
* Either between this VMA and vma->vm_prev, or between one of the
* VMAs below us in the VMA rbtree and its ->vm_prev. This helps
* get_unmapped_area find a free area of the right size.
*/
unsigned long rb_subtree_gap;
/* Second cache line starts here. */
struct mm_struct *vm_mm; /* The address space we belong to. */
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap interval tree.
*/
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_sem &
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
};
|
一些主要的成员:
- vm_start 和 vm_end:表示 vma 的起始和结束地址,相减就是 vma 的长度
- vm_next 和 vm_prev:链表指针
- vm_rb:红黑树节点
- vm_mm:所属进程的内存描述符 mm_struct 数据结构
- vm_page_prot:vma 的访问权限
- vm_flags:vma的标志
- anon_vma_chain 和 anon_vma:用于管理 RMAP 反向映射
- vm_ops:指向操作方法结构体
- vm_pgoff:文件映射的偏移量
- vm_file:指向被映射的文件
不过不论是红黑树,还是 Linux 6.1+ 引入的 Maple 树都不是本文讨论的重点,因此仅在这里做一下记录。
mmap 内核流程
在前一篇文章中,为了将文件映射到内存中,我们使用了这样的方式:
1
|
map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0);
|
那么接下来让我们走进内核,看看 mmap 是如何为我们分配内存的吧。
在用户态调用 mmap 时,会通过系统调用进入内核空间:
1
2
3
4
5
6
7
8
9
10
11
|
// arch/x86/kernel/sys_x86_64.c
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
{
... ...
error = sys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
out:
return error;
}
|
这里会将 offset 的单位转换为页。
继续跟进,接下来会调用 mmap_pgoff
系统调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// mm/mmap.c
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, pgoff)
{
struct file *file = NULL;
unsigned long retval;
if (!(flags & MAP_ANONYMOUS)) {
audit_mmap_fd(fd, flags);
file = fget(fd);
... ...
} else if (flags & MAP_HUGETLB) {
... ...
}
flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
if (file)
fput(file);
return retval;
}
|
由于我们没有设置 MAP_ANONYMOUS
,因此在这个函数中会通过 fget 获取文件结构体。接下来进入 vm_mmap_pgoff
函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// mm/util.c
unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flag, unsigned long pgoff)
{
unsigned long ret;
struct mm_struct *mm = current->mm;
unsigned long populate;
ret = security_mmap_file(file, prot, flag); // 安全相关,返回 0
if (!ret) {
down_write(&mm->mmap_sem); // 以写者身份申请写信号量
ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff,
&populate);
up_write(&mm->mmap_sem);
if (populate)
mm_populate(ret, populate);
}
return ret;
}
|
这个函数中,security_mmap_file
与安全相关,一般返回 0,接下来会以写者身份申请写信号量,接着进入 do_mmap_pgoff
函数,而后进入 do_mmap
函数。do_mmap
函数就是处理 mmap 的主要逻辑,这一段代码比较长,我们只关注重点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate)
{
struct mm_struct *mm = current->mm;
*populate = 0;
... ...
... ...
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
... ...
... ...
len = PAGE_ALIGN(len);
... ...
... ...
addr = mmap_region(file, addr, len, vm_flags, pgoff);
... ...
return addr;
}
|
这个函数主要将映射长度页对齐,对 prot 属性和 flags 标志进行了检查和处理,设置了 vm_flags,然后进入 mmap_region
函数,这个函数是实际创建 vma 的函数,我们进行详细的分析:
1
2
3
4
5
6
7
8
9
10
|
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma, *prev;
int error;
struct rb_node **rb_link, *rb_parent;
unsigned long charged = 0;
... ...
|
首先通过 vma_merge
判断能否和之前的映射扩展,如果可以的话就直接合并:
1
2
3
4
5
|
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
goto out;
... ...
|
如果不能扩展,就分配空间然后初始化:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
INIT_LIST_HEAD(&vma->anon_vma_chain);
|
如果是文件映射,就调用文件句柄中的 mmap,否则会调用 shmem_zero_setup
,这个函数也会映射文件,只不过映射到了 /dev/zero
上,这样的好处是不需要对所有页面提前置零,只有访问到具体页面时才会申请一个零页。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
if (file) {
... ...
vma->vm_file = get_file(file);
error = file->f_op->mmap(file, vma);
... ...
addr = vma->vm_start;
vm_flags = vma->vm_flags;
}else if (vm_flags & VM_SHARED) {
error = shmem_zero_setup(vma);
... ...
}
... ...
out:
... ...
return addr;
unmap_and_free_vma:
... ...
}
|
本文关注的是文件句柄中的 mmap。以 ext4 文件系统为例,上述的文件指针最终会调用到 ext4_file_mmap
函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// fs/ext4/file.c
const struct file_operations ext4_file_operations = {
... ...
.mmap = ext4_file_mmap,
... ...
};
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
struct inode *inode = file->f_mapping->host;
... ...
file_accessed(file);
if (IS_DAX(file_inode(file))) {
vma->vm_ops = &ext4_dax_vm_ops;
vma->vm_flags |= VM_MIXEDMAP | VM_HUGEPAGE;
} else {
vma->vm_ops = &ext4_file_vm_ops;
}
return 0;
}
|
其中 IS_DAX
的 DAX 意思是 Direct Access,含义是绕过内存缓冲直接访问块设备。一般来说都只会设置后面的 op 操作:
1
2
3
4
5
|
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
|
这样之后访问这个地址空间时,就会调用相应的操作函数进行处理。比如页错误处理函数会调用 ext4_filemap_fault
,里面又会调用 filemap_fault
。
注意到在 mmap 映射内存之后并没有任何将文件从磁盘中复制到内存的操作,仅仅是分配了相关的虚拟空间,只有以后真正访问这块内存的时候才会从磁盘中读取数据,这就是 Linux 内核中的 COW 思想。
总结
- 当用户空间调用 mmap 时,系统会寻找一段满足要求的连续虚拟地址,然后创建一个新的 vma 插入到 mm 系统的链表和红黑树中。
- 调用内核空间 mmap,建立文件块/设备物理地址和进程虚拟地址 vma 的映射关系
- 如果是磁盘文件,没有特别设置标志的话这里只是建立映射不会实际分配内存。
- 如果是设备文件,直接通过 remap_pfn_range 函数建立设备物理地址到虚拟地址的映射。
- (如果是磁盘文件映射)当进程对这片映射地址空间进行访问时,引发缺页异常,将数据从磁盘中拷贝到物理内存。后续用户空间就可以直接对这块内核空间的物理内存进行读写,省去了用户空间跟内核空间之间的拷贝过程。
参考资料
文章作者
QRZ
上次更新
2023-08-19
(600c28c)