-
-
[原创]PWN入门-24-MMAP除妖
-
发表于: 2025-7-1 21:09 3095
-
内存这个概念,对于现代计算机的重要性,当然是无需多言的,但是内存这个概念并不是计算机一旦诞生就有的。
内存概念首次被正式提出,应该归功于第一草稿团队的贡献。
第一草稿是计算机业界中里程碑式的白皮书,第一草稿中设计的计算机架构模型一直被沿用到了今天。
虽然这些成果已经被一个名为冯·诺伊曼的小偷给偷走了。
在今天这个时代,你经常可以听到“冯·诺伊曼架构”这一名词。
冯·诺伊曼是匈牙利人,名字中的冯是匈牙利皇家的赐姓,他在二战之间来到了美国,结识了奥本海默等人,并选择计算机科学的研究方向进行研究。
第一草稿就可以说是一个非常杰出的研究成果。
但是,第一草稿的成果应该是属于团队的,而冯·诺伊曼在发表第一草稿时只填写了自己的名字,并没有添加其他的人员为作者。
值得一提的是,第一草稿中将内存的概念提到了与CPU一样的高度。
是的,从那个时候开始,计算机领域的先贤们就意识到了内存的重要性,光CPU快是没有用的,内存也要跟上才行。
说起Linux的内存分配方式,你一定可以在第一时间想起来很多种,在这众多的内存方式中,有一种特殊的内存分配方式,它将磁盘文件与内存关联了起来。
将内存与文件相结合的做法,并不算太罕见,这种方式常常被用在两种场景下,一是内存不够用的时候,这个时候可以从磁盘上取出一段空间作为交换内存使用,二是加速外部设备访问的时候。
对于用户态程序来讲,mmap是一种显示且常见的将文件映射到内存中的方法。
对于一个正常程序来讲,不管程序主体中有没有使用mmap,在实际情况这个,它也会被动的使用mmap。
比如程序在启动过程中,其中一个很重要的操作,就是将程序所依赖的动态链接库加载到内存中。
在了解程序是如何显示的将文件映射到内存上之前,我们先来看一下程序是如何被动的使用文件映射的内存缓存。
Linux中有一个非常重要的概念,那就是一切皆文件。
对于用户态程序来讲,它是不能直接访问CPU信息、内核资源以及外部IO设备的,但是有的时候用户态程序又有访问它们的实际需求。
为此Unix提供了虚文件系统,允许CPU信息、内核资源以及外部IO设备通过高特权的驱动建立一个虚文件,用户态程序通过读写虚文件,实现针对敏感设备的数据访问与控制。
这样做最大的好处就是统一了用户态程序的访问接口。只需要通过write或read这样的文件读写接口,就可以实现一套接口访问和控制各种设备的资源。
至于访问规则,则由设备的驱动负责管理,驱动有必要保证高特权资源的安全性问题,并且自身也不会在性能及安全性上影响其他模块。
所以,从用户态程序的视角来看,虚文件系统的好处一共有三处,一是让用户态程序可以使用一套文件读写接口访问各种高特权资源,二是高特权资源的管理驱动,可以灵活的提供资源,而不需要在场景变更时重新编译内核。
在上面的描述中,你可能会好奇什么是虚文件,所谓的虚文件和真实的磁盘文件都有什么样的区别,虚文件就一定对应磁盘文件吗?
下面就让我们来逐个揭秘!
用户态程序通过__NR_write或__NR_read系统调用号发出系统调用后,内核会通过sys_write或sys_read函数进行响应。
从代码中可以看出,sys_write和sys_read只是针对系统调用而产生的一个过度层,真正负责处理读写流程的函数应该是vfs_read和vfs_write。
不直接使用vfs_read和vfs_write对接系统调用的原因并不复杂,首要的原因是内核需要SYSCALL_DEFINE将系统调用对接函数与系统调用表直接的串联起来(总不能逐个赋值吧)。
不管是vfs_read函数,还是vfs_write函数,它们都要求上级函数传递一个类型为struct file的变量,实际的读写操作有该变量中的f_op字段决定。
那么struct file是个什么东西,又是从哪里产生的呢?
用户态程序读写文件时,有一个前提要求,那就是需要程序先将文件打开,然后获取文件描述符。
内核要求文件在读写文件时传递文件描述符,内核会使用fdget_pos接口将文件描述符fd转换成struct fd f。
struct fd中的file字段的类型就是struct file。
我们暂缺不论struct file是从哪里来的,又是如何与文件描述符进行绑定,在这里,我们只关注一件事情,那就是为什么要单独封装一个struct fd结构体。
struct fd的产生由fdget_pos函数负责,该函数的执行流程可以分成__to_fd和__fdget_pos两个部分。
__fdget_pos会根据内核第一霸current宏(指向CPU当前允许的进行)取出struct files_struct字段存放到files变量中,当进程只打开一个文件时,会通过files_lookup_fd_raw直接获取文件描述符对应的struct file,反之则通过__fget遍历获取struct file。
__fdget_pos根据文件描述符匹配到struct file后,会将数值交给__to_fd,__to_fd函数会将其转换为struct fd。
之所以__to_fd函数还要再转换一次,是因为__fdget_pos传回来的数值,不止包含文件描述符对应的struct file,低三个比特位并不包含真实的数值,它们被留作是标志位使用。
那么这个文件描述符又是怎么来的呢?
对于用户态程序来讲,它需要通过open接口打开文件获取文件描述符。
内核会为每个进程维护一套使用文件的列表,这个列表由结构体struct files_struct描述,该结构体中有一个名为next_fd的字段,它就是内核分配文件描述符的关键。
每当用户态程序申请打开文件时,内核都会将next_fd返回给程序使用,并会将next_fd加1,让程序下次申请时获取新的文件描述符。
对于用户态程序来讲,文件描述符就是标识进程自身已打开文件的唯一标识,而且这个文件描述符对于其他进程来讲是隔离的。
如果说,文件描述符的数量是无限的,那么当然不需要再额外考虑什么,但是我们知道文件描述符是属于int类型的,其中负数部分代表文件打开失败,只有文件描述符数值为正数时,才代表文件被正确的打开,int类型一般占用32个比特位,其正数值的上限就是2147483647。
如果打开文件的数量超过2147483647了,应该怎么办呢?
而且文件既可以打开又可以关闭,已关闭的文件描述符难道就不能使用了吗,那这样太浪费了吧!
如果你对上面两点感到好奇,可以重点关注下find_next_fd函数以及expand_files函数。
其中find_next_fd函数可以查找未使用的文件描述符,而函数expand_files则可以扩展文件描述符的可获取范围。
当然expand_files并不是无限制扩张的,一旦进程打开的文件数量过多,就会返回错误,内核不允许用户态进程无限制的打开文件。
对于用户态进程来讲,可以查看nr_open虚文件获取单个进程被允许打开的最大文件数量,file-max则代表整个系统中所有进程被允打开的最大文件数量。
file-nr中的信息,代表当前系统中所有进程已打开的文件数量。
struct file和文件描述符是操作虚文件的关键所在,文件描述符这个东西内核和用户态程序都会使用,因为它是标识进程打开文件的唯一标识,至于struct file,它只会留给内核使用,这个结构体可以理解成是虚文件的真身。
虚文件的读写方法、权限信息以及路径等等信息,都记录在这里了。
虚文件的创建在文件描述符创建之后(先于文件描述符创建也不是不行),虚文件的创建依赖于文件打开时提交的文件路径,path_openat会根据提交路径信息遍历结点。
根据路径遍历结点找到inode(真正的文件)后,内核才会正式填充path_openat新分配出来的struct file,然后返回。
虚文件与文件描述符的关联动作是在fd_install函数中进行的,Linux会在struct files_struct维护一个文件描述符表struct fdtable,fd_install会在该表中将新分配出来的文件描述符和struct file进行绑定。
struct file对应的虚文件并不是实际的文件,真实的文件不管是内存文件还是磁盘文件,它们都由struct inode结构体描述。
对于Linux内核来讲这些真实文件也被称作是inode。
inode信息中记录着最为原始的文件信息,包括但不限于创建时间、文件大小等等信息,这些信息都是极为有用的。
Linux系统支持许多类型的文件系统,文件系统间读写操作往往有着不小的差异,所以Linux内核会要求文件系统按照struct file_operations结构体实现一套自己的操作方法。
文件系统实现的操作方法会在inode创建时绑定在一起。
当用户态程序根据文件路径打开文件时,inode上存放的文件系统操作方法会赋值给struct file的f_op字段。
当虚文件实际进行读写时,直接根据f_op进行操作就可以了。
从这个角度上来看,虚文件不止帮助用户态程序统一了接口,也帮助内核统一了多文件系统的问题。
在上面我们提到过,用户态程序使用read和write这样的接口读写文件时,会用到一段用户空间的内存。
如果你对内核有一点熟悉,就会轻松的发现程序使用的内存地址和内核使用的内存地址会因为特权问题而有比较大的差异,对于内核来讲,操作用户态内存属于跨特权操作,这个事情需要一点技巧。
那么Linux内核获取到用户态内存后,是怎么处理它的呢?
Linux内核为读写文件专门设置一个名为struct iov_iter的数据结构,该结构体中存在一个名为ubuf的字段,专门存放用户态内存。
不管是vfs_write还是vfs_read,在正式读写之前,都会通过函数iov_iter_ubuf将struct iov_iter初始化。
到了这里,内核只是将用户态内存地址放到了ubuf字段中
iov_iter最基础的作用就是将后续读写文件所需要的数据统一到了一起,当然这个收纳袋的作用不少数据结构都可以做到,至于iov_iter数据结构的优势在哪里,我们暂且忽略。
Linux内核中针对ext4文件系统的读写设置了三种途径。
一是DAX Direct Access模式,二是DIO Direct I/O模式,三就是最普通的读写模式。
DAX和DIO读写磁盘文件的方式我们先暂时跳过,在这里我们着重观察下普通的读写方式。
对于ext4文件来讲,该文件系统并没有在file_operations中注册write的方法,仅仅只是注册了write_iter方法,所以vfs_write会直接通过new_sync_write调用write_iter。
内核进入generic_perform_write会开始真正的复制流程,首先做到是取出folio结构,该结构中存放着内存页,拿到folio结构就会调用函数copy_page_from_iter_atomic将用户态空间的内存数据赋值到文件映射的内存页中。
上面的复制的流程并不算复杂,重点需要关注下内核是如何处理内存和页之间的联系的。
一个被映射到内存的文件,其inode信息中的mapping字段会指向struct address_space,该结构会在虚文件被打开时继承给struct file,结构体struct address_space的真实作用是维护虚拟内存和内存页之间的关系,struct address_space的i_mmap指向一个由红黑树数据结构维护的数据中,这个数据记录了VMA信息,struct address_space中还有一个名为i_pages的字段,该字段指向xarray数据,它通过folio数据结构维护内存页struct page信息。
因为struct page的mapping字段会指回struct address_space结构,那么这个时候内核的数据结构间就形成了闭环。
既可以通过struct address_space找到对应的内存页,又可以通过内存页找到对应的文件。
读操作与上方类似,唯一不同的就是,读操作是将文件映射的内存页中的数据复制到用户态内存中。
不管是读操作还是写操作,真正核心的还是内核针对文件与内存建立联系的数据结构上。
为什么要使用内存页作为缓存呢?
这一点在上面的内容中实际是已经提到过了,接下来会以性能分析场景为例进行进一步的解释。
在性能优化的江湖上,流传着这样的一个传说,有一个这个程序的运行速度还非常慢,并且这个程序还使用了很多的printf,所有的程序员都对优化这个程序束手无策,但是某一天他们请来一个顾问程序员,这个顾问程序员并没有做什么特殊的操作,只是将printf从程序中清除后,但是程序的运行速度却大大提高了。
这是为什么呢?
在性能优化时,通过top观察CPU占用率的情况当然是一个很重要的指标,但是你也不要忘了观察/proc/interrupts内的中断使用情况。
中断指标也是一个非常重要的指标,但它为什么重要呢?
首先你需要明确一点,那就是CPU可能会和各种各样的外部设备连接,但是呢,CPU如何知道外部设备的变化呢,总不能给每个外部设备安排一个CPU专门用while循环盯着吧,中断机制为此而诞生。
CPU就像是一个服务员,这个服务员并不会一直跟着你,但是当你有需要时,可以按服务铃(中断机制)呼叫服务员。
那么问题就来了,CPU响应中断需要时间,处理中断也需要时间,处理中断带来的任务也需要时间,通过总线与外部设备传输数据也需要时间,更何况总线传输数据还慢(相比于CPU自身的运行速度来讲)。
中断一来,CPU就知道这是麻烦事找上门了。
特别是对于文件读写这样的操作来讲,要是读写次数多一些,那是不是每次都要立即同步到磁盘文件中?
内核为了尽可能的避免CPU与磁盘过多的进行交互,所以在它们中间使用内存设置了一道中间层,CPU读写内存的速度会更快一些,所以当读写操作先作用在内存上时,可以大大提高CPU的运行效率。
不过缺点嘛,就是数据不是立即同步到磁盘的,如果磁盘突然掉盘,那么仍位于内存上的数据也不会持久化存储。
对于Linux来讲,它有一条名为sync的命令,帮助你显式的将缓存数据同步到磁盘中。
到这里程序员就要问了,在正常情况下,反正程序读写操作的第一目的地都是页缓存,那么为什么还要先写一次用户空间的缓冲区,然后再让内核将页缓存与用户态缓冲区数据同步呢?
那么程序可不可以直接操作页缓存,而不经过用户态程序的缓冲区直接读写文件呢?
当然是可以的,mmap负责的就是这个工作。
对于Linux用户空间来讲,虚拟内存想要和物理内存建立关系,最为常见的途径就是匿名映射了。
常见在什么地方呢?
一般来讲,程序使用的栈内存和堆内存属于匿名映射建立的内存。
匿名映射即支持私有内存,又支持共享内存,唯独不支持文件映射。
匿名映射不支持文件映射的原因,是因为它们的属性是冲突的,匿名就代表着不会与任何文件相关联。
通俗意义下的mmap指的是Linux内核提供的系统调用。
GLibC针对mmap系统调用封装了专门的函数,用户只需要调用mmap接口函数传递参数就可以了。
GLibC封装的函数会使用INLINE_SYSCALL_CALL宏发出mmap系统调用。
这个接口并不复杂,主要就是让用户态程序只传递参数,避免处理系统调用的细节问题。
mmap系统调用要求的参数并不复杂,一共需要6个参数。
参数addr可以忽略,除非你想让物理内存映射到特定的地址上。
参数len是申请内存的大小(需要是页大小的倍数)。
参数prot是指定的内存权限(读写可执行)。
参数flags是内存的属性(匿名、私有、共享等等)。
参数fd指的是被映射文件的文件描述符。
mmap是一种非常强大的内存分配方式,它支持的类型包括但不限于私有内存、共享内存、匿名内存、文件映射内存等等。
匿名映射可以与私有内存或共享内存共存,文件映射内存也是可以的,但是匿名内存和文件映射内存不可以共存,私有内存和共享内存也不可以共存。
用户态程序如果想要建立文件映射内存,那就需要在参数fd的位置上指定已打开文件的文件描述符,如果用户态程序在flags中添加了MAP_ANONYMOUS,那么建立匿名映射的途中,则会自动将参数fd上的数值是为-1。
这一点在内核代码的逻辑中可以看到,内核只会在不指定MAP_ANONYMOUS的情况下,才会使用文件描述符。
前面呢,只介绍了mmap所需要的五个参数,空了第六个参数没有介绍,这是因为参数6要稍微复杂一下。
第六个参数offset,在文件映射内存中,它代表相对于文件开头的偏移值,在匿名内存中,它则代表物理内存地址。
内核响应mmap后,主要的处理流程都集中在处理VMA上。
如果你仔细观察,内核响应mmap的时候,处理用户态程序传递过来参数时会非常谨慎。
到这里你会发现,从一开始的文件映射,再到使用文件映射引出mmap,好像没有什么漏洞啊。
GLibC处理mmap的流程非常简单,接收参数然后发出系统调用,就是它的全部工作。
内核响应mmap后,处理参数是非常严谨的,在这个基础上想要出现什么岔子也不是容易的事情。
上面的内核处理VMA时的流程,虽然没有详细介绍,但你也应该知道,VMA可能会有漏洞,但未必会以如此简单的方式出现。
那么漏洞在哪里呢?
对于GLibC来讲,其实是存在两种mmap的。
第一个mmap,就是上面提到的,GLibC针对mmap系统调用封装的mmap函数,用户态程序通过mmap函数申请到的内存需要通过unmmap释放。
第二个mmap是GLibC自己定义的mmap。
GLibC自定义mmap这点,可以在堆内存的是否流程中看到。
堆内存释放时,程序会检查chunk的标志位,如果一个chunk不是fast chunk也带有IS_MMAPPED标志位,那么GLibC就会将该chunk视为通过自定义mmap方式分配的堆内存。
IS_MMAPPED标志位一般是在chunk生成的时候通过set_head宏添加给mchunk_size。
set_head宏设置IS_MMAPPED标志位的时机主要发生在下面的三个函数中。
其中sysmalloc一般用于GLibC自己管理内存不够用时进行扩展的场景,__libc_realloc主要用在已分配的chunk调整大小的场景,_mid_memalign主要用在指定内存对齐大小的场景。
其中__libc_realloc会根据已分配chunk是否含有IS_MMAPPED决定分配方式。
至于sysmalloc和_mid_memalign,实际上_mid_memalign算是sysmalloc的变体,申请与特定大小对齐的内存时,GLibC最终还是会调用函数_int_malloc,如果已存在的空闲chunk(包括top chunk)都不够用,那么_mid_memalign最终就会调用到sysmalloc。
sysmalloc判断加不加IS_MMAPPED标志位,有两种情况。
情况一,就是arena还没有分配,这个时候需要给arena申请初始内存。
情况二,当申请大小nb超过mmap_threshold时,且n_mmaps小于n_mmaps_max的时候。
mp_属于struct malloc_par,它是GLibC用于记录内存分配器信息的数据结构,该结构体中的信息并不是在初始化后,就不再发生改变的,GLibC提供mallopt结构体,用于修改mp_的信息。
在这里,我们重点关注mp_中的n_mmaps和mmap_threshold字段。
n_mmaps代表单个进程通过GLibC内部使用mmap分配的内存数量,GLibC并不允许内部无限制的通过mmap申请内存,申请数量超过n_mmaps_max后,就会禁止这种行为。
mmap_threshold则是GLibC规定一个大小限制,只有申请大小超过mmap_threshold的时候,才会使用mmap分配内存,反之GLibC则会继续使用brk扩展内存。
程序正常通过malloc这样的接口申请大于mmap_threshold的内存时,会与_mid_memalign的执行流程一样,最终进入_int_malloc中,然后进入sysmalloc中,并开始使用mmap分配内存。
不管是程序还是GLibC,它们通过mmap接口申请内存并没有什么本质上的区别,唯一的区别就是程序申请到的内存会交给程序自己管理,而GLibC申请到的内存自然就交给GLibC进行管理。
程序自己申请mmap并自行管理的情况暂时忽略,因为这种情况完全需要根据具体程序进行具体的分析的。
但是GLibC管理mmap分配内存的情况就值得注意了,因为GLibC被非常多的程序使用,在一定程度上具备通用性。
在GLibC中的诸多堆问题,都是从释放开始的,GLibC为了自己管理堆内存,会在内存中占用一段空间,用于记录chunk的基本属性,这些基本属性会在释放阶段和已释放阶段起到重要的作用,帮助GLibC管理空闲的chunk。
在GLibC的堆内存管理策略中,sysmalloc通过mmap分配出来的内存只会加上IS_MMAPPED标志位。
众所周知,在非mmap分配途径的内存中,GLibC一般会使用PREV_INUSE标志位,记录上一个chunk是否空闲,方便在chunk合并时与其他chunk进行合并,但是mmap分配出来的内存就并不需要PREV_INUSE标志位。
这是因为mmap要求分配的内存至少也是以页大小为单位,所以GLibC并不需要考虑内存碎片化的问题。
这也就导致了GLibC在释放带有IS_MMAPPED标志位的内存时,释放流程会更加简洁。
简洁的释放流程,最大的好处就是简洁,但坏处也是简洁。
这就导致了两个问题。
首先,通过mmap分配出来的chunk,如果它的mchunk_size位置上的数值可以被修改,那么是不是就代表可以释放任意大小的chunk呢?
这个当然是不行的,在chunk大小的修改上还需要点技巧。
这是因为GLibC早就注意到了这个问题,所以在munmap_chunk代码中添加了下面的检查。
PREV_INUSE标志位的弃用,也就代表着chunk中mchunk_prev_size也被随之弃用,假如mchunk_prev_size不为0,那就代表出现异常了。
要知道mmap分配处理的内存至少也是跟页大小对齐的,所以chunk的内存地址减去0后与total_size进行或运算后,最低12个比特位上的数值一定是0,再跟pagesize - 1进行与运算,得到的结果一定就是0,不为0,就说明mchunk_prev_size或mchunk_size有问题。
至于powerof2,它是一个用于判断数值是否为2的次方,判断逻辑是这样的,如果一个数值是2^n,那么2^n减1后,其二进制表现形式就是低n个比特位全部为1,第n + 1个比特位的值则为0,此时再与原数值进行进行与运算时,原数值中低n个为0比特位就会被保留下来,使得最后结果为0。
其实上面的检查只要保证修改mchunk_prev_size和mchunk_size都跟页大小对齐,就可以绕过检查。
假设我们可以绕过上面的检查,导致mmap释放过多的内存。
那么,被释放的内存中,能不能包含程序申请的其他内存呢。
这个问题,其实也可以转化成另一种问法,那就是程序多次通过mmap申请所得到的内存地址是连续的吗,如果是连续的,那就代表我们可以修改mchunk_size,可靠的向上包含尚在使用的内存,并进行释放,使得下次申请时申请到内存区域重复的chunk。
首先感谢虚拟内存机制!
因为该机制的存在,使得程序可以在概念上独占内存空间,每当程序申请内存时,内核都会先通过get_unmapped_area找到一段可用的内存,然后根据空闲内存信息填充VMA数据结构。
get_unmapped_area决定了具体的内存地址,它负责在虚拟内存中找到一段未被占用的区域。
但是虚拟内存对应的物理内存可就不一定连续了。
get_unmapped_area查找虚拟内存主要依赖一个名为Mapple Tree的数据结构,该数据结构已经全面替代了旧的数据结构红黑树,但是Mapple Tree相对于红黑树有什么优点,这里不会详细讨论。
在这里我们重点关注get_unmapped_area在查找地址上的规律。
如果你对Linux熟悉一点,就会知道Linux内核对应用程序的虚拟内存布局空间有着清晰的划分,比如你看到5xxx开头的地址就会知道这是主程序的内存空间,看到7xxx开头的地址就会知道这是动态链接库或栈的内存空间。
对于堆内存来讲,5xxx开头的地址和7xxx开头的地址都是可能的。
5xxx开头的地址是主程序建立的初始堆内存区域,在程序的/proc/$pid/maps文件中,这段内存会被附上[heap]的字样进行标记。
程序是否可以申请到5xxx开头的内存地址,取决于两个方面,一是程序是否使用brk机制申请内存,二是程序是否为主线程。
如果程序既是主线程又是通过brk机制申请内存,那么恭喜你,此时得到的就是5xxx开头的内存地址。
如果主线程和通过brk机制申请内存两个条件,有一个不能被满足,那么得到的就是7xxx开头的地址。
显然通过mmap申请的内存都是7xxx开头的地址,那么通过mmap申请到的内存还会再有差别吗?
当然还是会有的。
内核进入函数vm_unmapped_area后,查找流程会被切割成两个部分,第一部分是通过unmapped_area_topdown函数从上到下开始查找,第二部分是通过unmapped_area函数从下到上开始查找。
至于到底是是从上而下进行查找,还是从下而上进行查找,取决于mm中的flags字段是否设置了MMF_TOPDOWN。
flags字段会不会添加MMF_TOPDOWN标志又取决于mmap_is_legacy。
mmap_is_legacy函数检查的依据是程序的虚拟内存布局情况,在禁用32位程序兼容的情况下,会返回1开始从上向下查找内存,反之则返回0开始从下向上查找内存。
处于安全性的考虑,Linux内核一般是期望从上向下查找内存的,因为放弃32位内存布局的兼容,可以提高内存地址的随机化程度。
并且从上向下查找内存区域,也会给内存地址带来更大的随机性。
不管是从上向下还是从下向上查找内存区域,它们都需要一个起始的内存地址,对于从上向下查找内存的场景来说,其搜索范围是由mmap_base和SZ_4G决定的。
其中SZ_4G是一个固定值,而mmap_base的数值并不算一个固定值。
在X86架构的四级页表场景下,mmap_base对应数值task_size_max,即0x7FFFFFFFF000。
由于程序依赖的动态链接库也是通过mmap映射出来的,所以你也会发现动态链接库的内存地址都是0x7xxx打头的。
不管是从上向下进行查找,还是从下向上进行查找,mmap都会以特定地址为基地址进行查找,并尽可能返回与前次申请想连续的内存区域。
在mchunk_size被合理篡改的情况下,我们可以释放尚在使用且不想被释放高位内存区域,这段不应该进入释放区域的高位内存区域可以被再次申请出来,但在默认情况下,程序应该是不能进行申请到同一块内存区域的。
这种漏洞要产生还是比较困难的,因为他要求修改mchunk_size字段,GLibC在拿到内存后会偏移0x10,再将内存地址交给程序,这样程序操作操作时就永远都不会修改前0x10内存区域上的数据。
总的来说呢,就是尚在使用高位内存区域被动释放,程序再次申请内存时又会从高位内存开始遍历,使得程序申请到重复的内存区域。
一般来讲,各种动态链接库、栈、vvar、vdso等东西都是通过mmap完成映射的,所以它们也都会使用get_unmapped_area查找,一般来讲栈是最先分配的所以会位于虚拟内存空间的最高处。
按照道理来讲,程序开始运行后,一开始指会加载程序自身的ELF、LD的ELF文件、用户态程序的栈以及VDSO几个部分。
这几个部分完成映射后,LD会开始通过mmap映射其他的部分,其中主要的部分就是程序依赖的动态链接库。
要知道,Linux下查找动态链接库除了依赖ld.so.conf中配置的路径外,还会依赖缓存/etc/ld.so.cache,LD在自己运行起来后,会将文件/etc/ld.so.cache也映射到虚拟文件系统中,用于辅助查找动态链接库。
不过呢,文件/etc/ld.so.cache在动态链接库查找任务完成之后,会通过unmmap释放掉,这就导致动态链接库和LD间的内存不是连续的,当程序首次通过mmap申请内存时,可能会从获得一个动态链接库和LD间的空闲内存,而之后通过mmap申请得到的内存,则会位于动态链接库的下方。
所以想要控制chunk A的mchunk_size来覆盖chunk B,造成chunk A释放连带着chunk B一块释放时,你需要保证chunk A和chunk B的内存区域是相连的,不然的话,你很有可能会将libc.so.6或其他动态连接库的内存释放掉,造成程序正常使用动态链接库时出现段错误。
上面给出了程序的源代码。
从程序的源代码中可以看到,程序通过malloc触发了四次mmap,其中poniter_3是有机会篡改mchunk_size的,因为函数vuln中只检查了上界,但没有检查下界,数组索引值可以是负数的,当索引值为1时,ptr[idx]指向的就是mchunk_size字段所在的位置,篡改poniter_3的mchunk_size,可以将poniter_2一块释放,当poniter_4申请时,就有机会申请到和poniter_2重叠的区域。
poniter_2上存放着一个名为check_flag的变量,该变量可以控制程序执行system("/bin/sh"),只不过check_flag在默认情况下永远都是false的状态,但当poniter_4和poniter_2重叠时,就有机会改写check_flag为true的状态,导致程序执行system("/bin/sh")。
根据上方的分析构造出下面的exploit。
运行上面的exploit后成功获取Shell。
SYSCALL_DEFINE3(write, ......) sys_write -> ksys_write(fd, buf, count); -> struct fd f = fdget_pos(fd); -> if (f.file) -> ret = vfs_write(f.file, buf, count, ppos); -> file->f_op->read(file, buf, count, pos);SYSCALL_DEFINE3(read, ......) sys_read -> ksys_read(fd, buf, count); -> struct fd f = fdget_pos(fd); -> if (f.file) -> ret = vfs_read(f.file, buf, count, ppos); -> file->f_op->write(file, buf, count, pos);SYSCALL_DEFINE3(write, ......) sys_write -> ksys_write(fd, buf, count); -> struct fd f = fdget_pos(fd); -> if (f.file) -> ret = vfs_write(f.file, buf, count, ppos); -> file->f_op->read(file, buf, count, pos);SYSCALL_DEFINE3(read, ......) sys_read -> ksys_read(fd, buf, count); -> struct fd f = fdget_pos(fd); -> if (f.file) -> ret = vfs_read(f.file, buf, count, ppos); -> file->f_op->write(file, buf, count, pos);struct fd { struct file *file; unsigned int flags;};fdget_pos -> __to_fd(__fdget_pos(fd));__fdget_pos -> unsigned long v = __fdget(fd); -> __fget_light(fd, FMODE_PATH); -> struct files_struct *files = current->files; -> if (files->count == 1) -> file = files_lookup_fd_raw(files, fd); -> return rcu_dereference_raw(fdt->fd[fd]); -> else -> file = __fget(fd, mask); -> return (unsigned long)file; -> struct file *file = (struct file *)(v & ~3); -> return (unsigned long)file;__to_fd -> return (struct fd){(struct file *)(v & ~3),v & 3};struct fd { struct file *file; unsigned int flags;};fdget_pos -> __to_fd(__fdget_pos(fd));__fdget_pos -> unsigned long v = __fdget(fd); -> __fget_light(fd, FMODE_PATH); -> struct files_struct *files = current->files; -> if (files->count == 1) -> file = files_lookup_fd_raw(files, fd); -> return rcu_dereference_raw(fdt->fd[fd]); -> else -> file = __fget(fd, mask); -> return (unsigned long)file; -> struct file *file = (struct file *)(v & ~3); -> return (unsigned long)file;__to_fd -> return (struct fd){(struct file *)(v & ~3),v & 3};SYSCALL_DEFINE3(open, const char __user *, filename, ......) -> return do_sys_open(AT_FDCWD, filename, flags, mode); -> return do_sys_openat2(dfd, filename, &how); -> fd = get_unused_fd_flags(how->flags); -> __get_unused_fd_flags -> alloc_fd -> -> struct files_struct *files = current->files; -> fd = files->next_fd; -> files->next_fd = fd + 1; -> struct file *f = do_filp_open(dfd, tmp, &op); -> fd_install(fd, f); -> return fd;SYSCALL_DEFINE3(open, const char __user *, filename, ......) -> return do_sys_open(AT_FDCWD, filename, flags, mode); -> return do_sys_openat2(dfd, filename, &how); -> fd = get_unused_fd_flags(how->flags); -> __get_unused_fd_flags -> alloc_fd -> -> struct files_struct *files = current->files; -> fd = files->next_fd; -> files->next_fd = fd + 1; -> struct file *f = do_filp_open(dfd, tmp, &op); -> fd_install(fd, f); -> return fd;alloc_fd -> if (fd < fdt->max_fds) -> fd = find_next_fd(fdt, fd); -> error = expand_files(files, fd); -> if (nr >= sysctl_nr_open) -> return -EMFILE;alloc_fd -> if (fd < fdt->max_fds) -> fd = find_next_fd(fdt, fd); -> error = expand_files(files, fd); -> if (nr >= sysctl_nr_open) -> return -EMFILE;cat /proc/sys/fs/nr_open1048576cat /proc/sys/fs/file-nr1216 0 922337203685477580cat /proc/sys/fs/file-max9223372036854775807cat /proc/sys/fs/nr_open1048576cat /proc/sys/fs/file-nr1216 0 922337203685477580cat /proc/sys/fs/file-max9223372036854775807do_sys_openat2 -> fd = get_unused_fd_flags(how->flags); -> struct file *f = do_filp_open(dfd, tmp, &op); -> struct file *filp = path_openat(&nd, op, flags | LOOKUP_RCU); -> set_nameidata(&nd, dfd, pathname, NULL); -> __set_nameidata(p, dfd, name); -> p->name = name; -> file = alloc_empty_file(op->open_flag, current_cred()); -> while (!(error = link_path_walk(s, nd)) && (s = open_last_lookups(nd, file, op)) != NULL); -> error = do_open(nd, file, op); -> return error; -> return filp; -> fd_install(fd, f); -> struct fdtable *fdt; -> struct files_struct *files = current->files; -> fdt = rcu_dereference_sched(files->fdt); -> rcu_assign_pointer(fdt->fd[fd], file); -> return fd;do_sys_openat2 -> fd = get_unused_fd_flags(how->flags); -> struct file *f = do_filp_open(dfd, tmp, &op); -> struct file *filp = path_openat(&nd, op, flags | LOOKUP_RCU); -> set_nameidata(&nd, dfd, pathname, NULL); -> __set_nameidata(p, dfd, name); -> p->name = name; -> file = alloc_empty_file(op->open_flag, current_cred()); -> while (!(error = link_path_walk(s, nd)) && (s = open_last_lookups(nd, file, op)) != NULL); -> error = do_open(nd, file, op); -> return error; -> return filp; -> fd_install(fd, f); -> struct fdtable *fdt; -> struct files_struct *files = current->files; -> fdt = rcu_dereference_sched(files->fdt); -> rcu_assign_pointer(fdt->fd[fd], file); -> return fd;const struct file_operations ext4_file_operations = { .read_iter = ext4_file_read_iter, .write_iter = ext4_file_write_iter, .open = ext4_file_open, .mmap = ext4_file_mmap,}struct inode { union { const struct file_operations *i_fop; void (*free_inode)(struct inode *); };}const struct file_operations ext4_file_operations = { .read_iter = ext4_file_read_iter, .write_iter = ext4_file_write_iter, .open = ext4_file_open, .mmap = ext4_file_mmap,}struct inode { union { const struct file_operations *i_fop; void (*free_inode)(struct inode *); };}const struct inode_operations ext4_dir_inode_operations = { .create = ext4_create,}ext4_create -> inode->i_fop = &ext4_file_operations;const struct inode_operations ext4_dir_inode_operations = { .create = ext4_create,}ext4_create -> inode->i_fop = &ext4_file_operations;SYSCALL_DEFINE3(open, ......) -> do_sys_open -> do_sys_openat2 -> path_openat -> do_open -> vfs_open -> do_dentry_open -> f->xx = xx; -> f->f_op = fops_get(inode->i_fop);SYSCALL_DEFINE3(open, ......) -> do_sys_open -> do_sys_openat2 -> path_openat -> do_open -> vfs_open -> do_dentry_open -> f->xx = xx; -> f->f_op = fops_get(inode->i_fop);ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count) -> vfs_read(f.file, buf, count, ppos); -> ret = new_sync_read(file, buf, count, pos); -> iov_iter_ubuf(&iter, ITER_DEST, buf, len) -> iter.ubuf = bufvfs_write -> new_sync_write -> iov_iter_ubuf(&iter, ITER_SOURCE, (void __user *)buf, len);ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count) -> vfs_read(f.file, buf, count, ppos); -> ret = new_sync_read(file, buf, count, pos); -> iov_iter_ubuf(&iter, ITER_DEST, buf, len) -> iter.ubuf = bufvfs_write -> new_sync_write -> iov_iter_ubuf(&iter, ITER_SOURCE, (void __user *)buf, len);#define IS_DAX(inode) ((inode)->i_flags & S_DAX)ext4_file_write_iter -> if (IS_DAX(inode)) -> return ext4_dax_write_iter(iocb, to); -> if (iocb->ki_flags & IOCB_DIRECT) -> return ext4_dio_write_iter(iocb, to); -> return ext4_buffered_write_iter(iocb, to);ext4_file_read_iter -> if (IS_DAX(inode)) -> return ext4_dax_read_iter(iocb, to); -> if (iocb->ki_flags & IOCB_DIRECT) -> return ext4_dio_read_iter(iocb, to); -> return generic_file_read_iter(iocb, to);#define IS_DAX(inode) ((inode)->i_flags & S_DAX)ext4_file_write_iter -> if (IS_DAX(inode)) -> return ext4_dax_write_iter(iocb, to); -> if (iocb->ki_flags & IOCB_DIRECT) -> return ext4_dio_write_iter(iocb, to); -> return ext4_buffered_write_iter(iocb, to);ext4_file_read_iter -> if (IS_DAX(inode)) -> return ext4_dax_read_iter(iocb, to); -> if (iocb->ki_flags & IOCB_DIRECT) -> return ext4_dio_read_iter(iocb, to); -> return generic_file_read_iter(iocb, to);const struct file_operations ext4_file_operations = { .write_iter = ext4_file_write_iter,}const struct file_operations ext4_file_operations = { .write_iter = ext4_file_write_iter,}ext4_file_write_iter -> ext4_buffered_write_iter -> generic_perform_write -> struct folio *folio; -> struct address_space *mapping = file->f_mapping; -> const struct address_space_operations *a_ops = mapping->a_ops; -> status = a_ops->write_begin(file, mapping, pos, bytes, &folio, &fsdata); -> copied = copy_folio_from_iter_atomic(folio, offset, bytes, i); -> copy_page_from_iter_atomic(&folio->page, offset, bytes, i); -> iterate_and_advance(i, n, base, len, off, copyin(p + off, base, len), memcpy_from_iter(i, p + off, base, len) -> __iterate_and_advance(i, n, base, len, off, I, ((void)(K),0)) -> if (iter_is_ubuf(i)) -> iterate_buf(i, n, base, len, off, i->ubuf, (I)) -> len -= (STEP); -> copyin -> if (access_ok(from, n)) -> res = raw_copy_from_user(to, from, n); -> copy_user_generic -> rep movsb )ext4_file_write_iter -> ext4_buffered_write_iter -> generic_perform_write -> struct folio *folio; -> struct address_space *mapping = file->f_mapping; -> const struct address_space_operations *a_ops = mapping->a_ops; -> status = a_ops->write_begin(file, mapping, pos, bytes, &folio, &fsdata); -> copied = copy_folio_from_iter_atomic(folio, offset, bytes, i); -> copy_page_from_iter_atomic(&folio->page, offset, bytes, i); -> iterate_and_advance(i, n, base, len, off, copyin(p + off, base, len), memcpy_from_iter(i, p + off, base, len) -> __iterate_and_advance(i, n, base, len, off, I, ((void)(K),0)) -> if (iter_is_ubuf(i)) -> iterate_buf(i, n, base, len, off, i->ubuf, (I)) -> len -= (STEP); -> copyin -> if (access_ok(from, n)) -> res = raw_copy_from_user(to, from, n); -> copy_user_generic -> rep movsb ) |-------------------------------------------------------| v ^| struct file | <----> | struct files_struct | <----> | struct fdtable | ^ current | fd / path | vma | | ^ |-> xarray | | | +-+-+ | | rb root | | | | | | ^ | +-+-+ v mapping i_mmap | | | |--------> | folio/page (struct page) || struct inode | --------> | struct address_space | -------| | |----------> | folio/page (struct page) | ^ | i_pages | | | |-------------------------------| mapping | |-----------------------------------------------------------------------| |-------------------------------------------------------| v ^| struct file | <----> | struct files_struct | <----> | struct fdtable | ^ current | fd / path | vma | | ^ |-> xarray | | | +-+-+ | | rb root | | | | | | ^ | +-+-+ v mapping i_mmap | | | |--------> | folio/page (struct page) || struct inode | --------> | struct address_space | -------| | |----------> | folio/page (struct page) | ^ | i_pages | | | |-------------------------------| mapping | |-----------------------------------------------------------------------|ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from) -> return ext4_buffered_write_iter(iocb, from); -> generic_perform_write(iocb, from); -> status = a_ops->write_begin(file, mapping, pos, bytes, &page, &fsdata); -> copy_page_from_iter_atomicext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from) -> return ext4_buffered_write_iter(iocb, from); -> generic_perform_write(iocb, from); -> status = a_ops->write_begin(file, mapping, pos, bytes, &page, &fsdata); -> copy_page_from_iter_atomiccat /usr/include/asm-generic/unistd.h | grep -i mmap#define __NR3264_mmap 222__SC_3264(__NR3264_mmap, sys_mmap2, sys_mmap)#define __NR_mmap __NR3264_mmap#define __NR_mmap2 __NR3264_mmapcat /usr/include/asm-generic/unistd.h | grep -i mmap#define __NR3264_mmap 222__SC_3264(__NR3264_mmap, sys_mmap2, sys_mmap)#define __NR_mmap __NR3264_mmap#define __NR_mmap2 __NR3264_mmapvoid *mmap(void addr, size_t length, int prot, int flags, int fd, off_t offset);#define MMAP_CALL(__nr, __addr, __len, __prot, __flags, __fd, __offset) \ INLINE_SYSCALL_CALL (__nr, __addr, __len, __prot, __flags, __fd, __offset)weak_alias (__mmap64, mmap)void *__mmap64 (void *addr, size_t len, int prot, int flags, int fd, off64_t offset) -> return (void *) MMAP_CALL (mmap, addr, len, prot, flags, fd, offset);void *mmap(void addr, size_t length, int prot, int flags, int fd, off_t offset);#define MMAP_CALL(__nr, __addr, __len, __prot, __flags, __fd, __offset) \ INLINE_SYSCALL_CALL (__nr, __addr, __len, __prot, __flags, __fd, __offset)赞赏
- [原创]PWN入门-24-MMAP除妖 3096
- PWN入门-23-LargeBin托梦 3261
- [原创]PWN入门-22-FastBin与DoubleFree降妖 4661
- [原创]PWN入门-21-OffByOne遇险 4741
- [原创]Web安全入门-网络资源的访问-隧道 6027