在分析完 CVE-2016-5195 后,我注意到最近 Linux Kernel 又出了一个和内存管理子系统相关的洞 CVE-2023-3269,正好趁机会把前一篇文章没有分析的细节看一遍,主要是 mmap 的内核态实现,这也算是先射箭再画靶了。
背景知识
mmap 函数
摘自深入理解mmap—内核代码分析及驱动demo示例:
- 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
中也有相应的成员。进程可以通过内核的内存管理机制动态地添加或删除这些内存区域。
每个内存区域具有相关的权限,比如可读、可写、可执行。如果进程访问了不在有效范围内的内存区域、或非法访问了内存,那么处理器会报缺页异常,严重的会出现段错误。
一些主要的成员:
- 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 内核流程
在前一篇文章中,为了将文件映射到内存中,我们使用了这样的方式:
那么接下来让我们走进内核,看看 mmap 是如何为我们分配内存的吧。
在用户态调用 mmap 时,会通过系统调用进入内核空间:
这里会将 offset 的单位转换为页。
继续跟进,接下来会调用 mmap_pgoff
系统调用:
由于我们没有设置 MAP_ANONYMOUS
,因此在这个函数中会通过 fget 获取文件结构体。接下来进入 vm_mmap_pgoff
函数:
这个函数中,security_mmap_file
与安全相关,一般返回 0,接下来会以写者身份申请写信号量,接着进入 do_mmap_pgoff
函数,而后进入 do_mmap
函数。do_mmap
函数就是处理 mmap 的主要逻辑,这一段代码比较长,我们只关注重点:
这个函数主要将映射长度页对齐,对 prot 属性和 flags 标志进行了检查和处理,设置了 vm_flags,然后进入 mmap_region
函数,这个函数是实际创建 vma 的函数,我们进行详细的分析:
首先通过 vma_merge
判断能否和之前的映射扩展,如果可以的话就直接合并:
如果不能扩展,就分配空间然后初始化:
如果是文件映射,就调用文件句柄中的 mmap,否则会调用 shmem_zero_setup
,这个函数也会映射文件,只不过映射到了 /dev/zero
上,这样的好处是不需要对所有页面提前置零,只有访问到具体页面时才会申请一个零页。
本文关注的是文件句柄中的 mmap。以 ext4 文件系统为例,上述的文件指针最终会调用到 ext4_file_mmap
函数:
其中 IS_DAX
的 DAX 意思是 Direct Access,含义是绕过内存缓冲直接访问块设备。一般来说都只会设置后面的 op 操作:
这样之后访问这个地址空间时,就会调用相应的操作函数进行处理。比如页错误处理函数会调用 ext4_filemap_fault
,里面又会调用 filemap_fault
。
注意到在 mmap 映射内存之后并没有任何将文件从磁盘中复制到内存的操作,仅仅是分配了相关的虚拟空间,只有以后真正访问这块内存的时候才会从磁盘中读取数据,这就是 Linux 内核中的 COW 思想。
总结
- 当用户空间调用 mmap 时,系统会寻找一段满足要求的连续虚拟地址,然后创建一个新的 vma 插入到 mm 系统的链表和红黑树中。
- 调用内核空间 mmap,建立文件块/设备物理地址和进程虚拟地址 vma 的映射关系
- 如果是磁盘文件,没有特别设置标志的话这里只是建立映射不会实际分配内存。
- 如果是设备文件,直接通过 remap_pfn_range 函数建立设备物理地址到虚拟地址的映射。
- (如果是磁盘文件映射)当进程对这片映射地址空间进行访问时,引发缺页异常,将数据从磁盘中拷贝到物理内存。后续用户空间就可以直接对这块内核空间的物理内存进行读写,省去了用户空间跟内核空间之间的拷贝过程。
参考资料