在分析完 CVE-2016-5195 后,我注意到最近 Linux Kernel 又出了一个和内存管理子系统相关的洞 CVE-2023-3269,正好趁机会把前一篇文章没有分析的细节看一遍,主要是 mmap 的内核态实现,这也算是先射箭再画靶了。

背景知识

mmap 函数

摘自深入理解mmap—内核代码分析及驱动demo示例

// 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 中也有相应的成员。进程可以通过内核的内存管理机制动态地添加或删除这些内存区域。

每个内存区域具有相关的权限,比如可读、可写、可执行。如果进程访问了不在有效范围内的内存区域、或非法访问了内存,那么处理器会报缺页异常,严重的会出现段错误。

// 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 内核流程

在前一篇文章中,为了将文件映射到内存中,我们使用了这样的方式:

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

那么接下来让我们走进内核,看看 mmap 是如何为我们分配内存的吧。

在用户态调用 mmap 时,会通过系统调用进入内核空间:

// 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 系统调用:

// 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 函数:

// 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 的主要逻辑,这一段代码比较长,我们只关注重点:

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 的函数,我们进行详细的分析:

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 判断能否和之前的映射扩展,如果可以的话就直接合并:

	vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
			NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
	if (vma)
		goto out;
	... ...

如果不能扩展,就分配空间然后初始化:

	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 上,这样的好处是不需要对所有页面提前置零,只有访问到具体页面时才会申请一个零页。

	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 函数:

// 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 操作:

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 思想。

总结

  1. 当用户空间调用 mmap 时,系统会寻找一段满足要求的连续虚拟地址,然后创建一个新的 vma 插入到 mm 系统的链表和红黑树中。
  2. 调用内核空间 mmap,建立文件块/设备物理地址和进程虚拟地址 vma 的映射关系
    1. 如果是磁盘文件,没有特别设置标志的话这里只是建立映射不会实际分配内存。
    2. 如果是设备文件,直接通过 remap_pfn_range 函数建立设备物理地址到虚拟地址的映射。
  3. (如果是磁盘文件映射)当进程对这片映射地址空间进行访问时,引发缺页异常,将数据从磁盘中拷贝到物理内存。后续用户空间就可以直接对这块内核空间的物理内存进行读写,省去了用户空间跟内核空间之间的拷贝过程。

参考资料