linux 内存管理_Linux内核内存管理分析

本文主要介绍Buddy System、Slab Allocator的实现机制以及现实中的一些漏洞利用方法,从攻击者角度加深对Linux内核内存管理机制的理解。

前言

网上已经有很多关于Linux内核内存管理的分析和介绍了,但是不影响我再写一篇:) 一方面是作为其他文章的补充,另一方面则自己学习的记录、总结和沉淀。所谓条条大路通罗马,本文只作为其中一条路,强烈建议想去罗马的朋友看完文末所列举的参考文章。

伙伴系统

伙伴系统即Buddy System,是一种简单高效的内存分配策略。其主要思想是将大块内存按照一定策略去不断拆分(在到达最小的块之前),直至存在满足指定请求大小的最小块。其中块的大小由其相对根块的位置指定,通常称为order(阶)。一个最简单的拆分方式就是以2为指数进行拆分,例如定义最小块的大小为64K,order上限为4,则最大块的大小为:

64K * 2^4 = 1024K

最大块的order为4,最小块的order为0。对于请求大小为k的块,最小块为N,则其order值为align(k)/N。为什么叫buddy system呢?假设一个大块A所分解成的两个小块B和C,其中B和C就相互为彼此的

天使

buddy。只有彼此的buddy才能够进行合并。

使用Buddy算法的的应用有很多,其中Linux内核就是一个,此外jemalloc也是使用Buddy技术的一个现代内存分配器。

维基百科中有一个很直观的例子: Buddy memory allocation 。Linux内核中的伙伴系统块大小为一页,通常是4096字节。最大的order一般是10,即MAX_ORDER为11。

/* Free memory management - zoned buddy allocator.  */  #ifndef CONFIG_FORCE_MAX_ZONEORDER  #define MAX_ORDER 11  #else  #define MAX_ORDER CONFIG_FORCE_MAX_ZONEORDER  #endif  #define MAX_ORDER_NR_PAGES (1 << (MAX_ORDER - 1))

在Linux内核中,分配和释放较大内存都是直接通过伙伴系统,即:

struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);void free_pages(unsigned long addr, unsigned int order);

order为 0-10 ,指定从哪一阶伙伴中分配,order为 n 则分配 2^n 页大小的内存。操作系统中可以通过procfs查看伙伴系统的分配情况,如下:

$ cat /proc/buddyinfoNode 0, zone      DMA      1      1      0      0      2      1      1      0      1      1      3Node 0, zone    DMA32   3416   4852   3098   3205   3209   3029     33     22     15      7     52Node 0, zone   Normal  29330 192053 148293  90568  33732   9018   2688    411    942    999   1852Node 1, zone   Normal    107   1229  18644  76650  46053   8383   4398   5486   1751    497     84

此外,还有 /proc/pagetypeinfo 也可用于查看内存页的信息。

Slab分配器

上面说到,由于效率原因,伙伴系统中分配内存是以页为单位的,即使所分配的object大小为1byte,也需要分配一页,这样就导致了比较大的内存碎片。因此Linux引入了Slab分配器,加速对object的分配和释放速度,同时也减少碎片空间。

最初接触的时候心里通常有个大大的问号:Slab是什么?理解这个问题至关重要,经过翻阅多种资料和文章,可以大概这么回答:

  1. Slab是一种缓存策略
  2. Slab是一片缓冲区
  3. Slab是一个或者多个连续的page

在内核代码中,我们能看到SLAB、SLOB、SLUB,其实都是兼容SLAB接口的具体分配器。目前默认使用的是SLUB,因此经常将其混称。按照引入Linux内核的时间划分如下:

d09774c53b654efd3aa96ecd0bb22ac6.png
from:https://events.static.linuxfound.org/sites/events/files/slides/slaballocators.pdf

说句题外话,SLOB (Simple List Of Blocks) 可以看做是针对嵌入式设备优化的分配器,通常只需要几MB的内存。其采用了非常简单的 first-fit 算法来寻找合适的内存block。这种实现虽然去除了几乎所有的额外开销,但也因此会产生额外的内存碎片,因此一般只用于内存极度受限的场景。

数据结构

在本文中,我会尽量少粘贴大段的代码进行分析,但Slub分配器是比较依赖于实现而不是设计的,因此数据结构的介绍是难免的。

page

描述一个页的数据结构就是 struct page 。为了节约空间,page使用了大量的union结构,针对不同用处的页使用不同的字段。

Slab是一个或者多个连续页组成的内存空间,那么本质上指向一个Slab的数据结构不是别的,就是 struct page * ,对应Slab中的信息可以通过第一个page的某些字段描述。记住这点对后面的理解很重要。

kmem_cache

kmem_cache是Slab的主要管理结构,申请和释放对象都需要经过该结构操作,部分重要字段如下:

/*   * Slab cache management.   */  struct kmem_cache {      struct kmem_cache_cpu __percpu *cpu_slab;      /* Used for retriving partial slabs etc */      unsigned long flags;      unsigned long min_partial;      int size;       /* The size of an object including meta data */      int object_size;    /* The size of an object without meta data */      int offset;     /* Free pointer offset. */  #ifdef CONFIG_SLUB_CPU_PARTIAL      int cpu_partial;    /* Number of per cpu partial objects to keep around */  #endif      ...  struct kmem_cache_node *node[MAX_NUMNODES];}

重点关注 cpu_slab 和 node 。

cpu_slab 包含当前CPU的Slab。这是个 __percpu 的对象,什么意思呢?我的理解是内核为了加速当前CPU的访问,会对每个CPU保存一个变量,这样在当前CPU访问该变量时候就可以免去加锁的开销。在调试中发现该变量的值是个类似 0x18940 这样比较小的数,这个地址是没有映射的,访问percpu变量需要通过 raw_cpu_ptr 宏去获取实际的地址。

node 数组中包括其他CPU的Slab。为什么叫做node?其实这是NUMA系统中的node概念。NUMA是为了多核优化而产生的架构,可以令某个CPU访问某段内存的速度更快。node的定义是“一段内存,其中每个字节到CPU的距离相等”,更加通俗的解释是:“在同一物理总线上的内存+CPUs+IO+……”,更多细节可以参考 NUMA FAQ

kmem_cache_cpu

cpu_slab是 kmem_cache_cpu 结构,如下:

struct kmem_cache_cpu {      void **freelist;    /* Pointer to next available object */      unsigned long tid;  /* Globally unique transaction id */      struct page *page;  /* The slab from which we are allocating */  #ifdef CONFIG_SLUB_CPU_PARTIAL      struct page *partial;   /* Partially allocated frozen slabs */  #endif  #ifdef CONFIG_SLUB_STATS      unsigned stat[NR_SLUB_STAT_ITEMS];  #endif  };

freelist指向第一个空闲的对象(假设为x),page指向x所在slab(的第一页)。这里的page有以下特点:

  • objects = slab的对象数
  • inuse = objects
  • frozen = 1
  • freelist = NULL

partial主要包含本地部分分配的slab。partial指向的page有以下特点:

  • next指向下一个slab(page)
  • freelist指向slab中第一个空闲object
  • inuse = slab中已使用的object个数
  • frozen = 1

其中第一个page的 pbojects 记录了partial objects数。

kmem_cache_node

struct kmem_cache_node {    spinlock_t list_lock;    ...#ifdef CONFIG_SLUB    unsigned long nr_partial;    struct list_head partial;    ..#endif};

这个数据结构根据配置的 SL[OAU]B 分配器而异,对于SLUB而言,使用的字段就只有两个,nr_partial和partial。其中partial是Linux内核中可插拔式通用双链表结构,使用内核中双链表的接口进行操作。nr_partial表示partial双链表中的元素个数,即slab的个数。

partial->next指向的page结构,用于该结构的page有如下特点:

  • frozon = 0
  • freelist指向slab中第一个空闲object
  • inuse表示对应slab使用中的object个数
  • 通过lru字段索引链表中的下一个/前一个page

前三点没什么好说的,大家都差不多。需要关注的是第四点,这里不像cpu partial那样通过next指针连接页表,而是通过lru字段:

struct page {...      /*       * Third double word block       *       * WARNING: bit 0 of the first word encode PageTail(). That means       * the rest users of the storage space MUST NOT use the bit to       * avoid collision and false-positive PageTail().       */      union {          struct list_head lru;   /* Pageout list, eg. active_list                       * protected by zone_lru_lock !                       * Can be used as a generic list                       * by the page owner.                       */     ...

分配和释放

终于讲到了重点。关于slub的分配和释放有很多文章介绍过,而且风格不同,有的是对着代码逐行分析,有的是画图介绍,这里我仅按照我自己的理解去说,如有谬误欢迎指出。

对象的分配和释放涉及到几个指针,分别是:

void *struct page*struct page*struct kmem_cache*

一个虚地址所对应的页首地址是是通过PAGE_MASK,因为页是对齐的,但需要注意页首地址并不是page指针所指向的地方。p1->p2的转换通过 virt_to_page 实现。

p2->p4可以通过 page->slab_cache 得到,这也是p1->p4函数 virt_to_cache 的操作。

分配

对象的分配,不考虑特殊情况的话(比如超过N页的对象直接通过伙伴系统分配),一般流程如下:

kmem_cache_cpu->freelistkmem_cache_cpu->page->freelistkmem_cache_cpu->partialkmem_cache_node->partial
9f4531e36e13e4200948182beea3dfca.png

释放

对象的释放相对复杂,和释放之前对象所处的位置以及释放后cache情况有关。假设待释放的object地址为p1,p1对应的page为p2,p1对应的slab为p3,参考上面的几个指针定义,大致有以下路径:

  1. p3 就是当前CPU的kmem_cache_cpu->freelist所对应的slab,即p1位于当前cpu的kmem_cache_cpu->freelist所在的page中( p2 == cpu_slab->page ),此时可以直接释放到freelist上并返回;
  2. p3 位于当前CPU的kmem_cache_cpu->partial链表中,或者其他CPU的kmem_cache_cpu->freelist/partial中。此slab处于冻结状态,将p1链入p3->freelist中;
  3. p3 位于kmem_cache_node->partial链表中,此时释放分为两种情况: 3.1 释放p1后,p3的状态为半满。此时正常将p1链入p3的freelist中。 3.2 释放p1后,p3的状态为全空。此时除了将p1链入p3的freelist以外,还需要判断node中slab数是否超过规定值( node->nr_partial >= min_partial )。如果超过则需要将p3移出node->partial链表,并将p3释放给伙伴系统。
  4. p3 是一个全满的slab,不被任何kmem_cache管理。释放后p3变成一个半满的slab(更新freelist),同时p3会被加入到当前CPU的kmem_cache_cpu.partial中。加入之前需要判断cpu partial中的空闲对象是否超过了规定值( partial.pobjects > cachep.cpu_partial ),并进行相应的处理: 4.1 如果没超过,直接链入cpu partial即可 4.2 如果超过,则将cpu partial中所有slab解冻,将其中所有半满的slab交由node进行管理;将其中所有空的slab回收给伙伴系统;最后再将slab链入到partial中。

其中3的判断是为了避免node partial中存放了太多空slab;4的判断是为了避免cpu partial中存放太多空slab以及加快搜索速度。

133ca60ea5ddf2a340df0e4ae9a4a200.png

slab分配和释放的过程大致就是这样,上面的图主要来自窝窝的smcdef大神,其中还有一张大图可以配合观看理解:http://www.wowotech.net/content/uploadfile/201803/4a471520078976.png

调试

slab分配器是如此复杂,因此Linux内核中提供了很多调试措施,开启特定的调试编译选项后可以在object前后加上red zone检测越界,也可以检测slab的引用错误。

在用户态中,我们可以通过vfs进行调试。比如可以用 slabinfo 或者 slabtop 命令查看当前的slab分配情况,这些命令实际上是读取了 /proc/slabinfo 信息以及 /sys/kernel/slab/* 中的信息。

slabtop输出示例:

Active / Total Objects (% used)    : 25864761 / 26174849 (98.8%) Active / Total Slabs (% used)      : 661098 / 661098 (100.0%) Active / Total Caches (% used)     : 93 / 158 (58.9%) Active / Total Size (% used)       : 4921033.80K / 5046143.93K (97.5%) Minimum / Average / Maximum Object : 0.01K / 0.19K / 295.08K  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME18380583 18380583 100%    0.10K 471297       39   1885188K buffer_head1785462 1711901   0%    0.19K  42511       42    340088K dentry1705350 1629533   0%    1.06K  56845       30   1819040K ext4_inode_cache589064 567016   0%    0.57K  21038       28    336608K radix_tree_node530112 495734   0%    0.06K   8283       64     33132K kmalloc-64475728 429025   0%    0.04K   4664      102     18656K ext4_extent_status357632 355306   0%    0.06K   5588       64     22352K pid258944 245861   0%    0.03K   2023      128      8092K kmalloc-32247494 246414   0%    0.20K   6346       39     50768K vm_area_struct231794 230768   0%    0.09K   5039       46     20156K anon_vma174780 169836   0%    0.13K   5826       30     23304K kernfs_node_cache164224 159505   0%    0.25K   5132       32     41056K filp146688 143610   0%    0.02K    573      256      2292K kmalloc-16120480 117291   0%    0.66K   2510       48     80320K proc_inode_cache101376  97721   0%    0.01K    198      512       792K kmalloc-8 86310  85793   0%    0.19K   2055       42     16440K cred_jar 78122  75197   0%    0.59K   1474       53     47168K inode_cache 48512  46984   0%    0.50K   1516       32     24256K kmalloc-512

直接从实现角度分析也许理解得不是很深刻,下面就来看几个实际的攻击案例,它们都巧妙地利用了上面提到的slab分配器的特性进行内存布局。

案例1:内核堆溢出漏洞利用

第一种类型是内核堆溢出漏洞。假如我们使用kmalloc分配了一个大小为30字节的对象,根据配置不同很可能会使用到名为 kmalloc-32 的kmem_cache去进行分配。因此,如果我们使用对象时写入超过32字节的数据,就可能产生堆溢出。

堆溢出的直接后果就是覆盖了slab中后面一个object块的数据,如果后面的object对象中被覆盖的部分包含函数指针,那么就有可能劫持内核控制流,跳转到任意地址。如下图所示:

9507f5cc50e26a17c92b7ebaff4be0ee.png

这是最简单的情况。实践中的主要问题是,如何保证攻击者分配的含函数指针的对象(简称 victim obj)就在溢出对象(简称 vuln object)的后面。

创建对象前slab的freelist可能是乱七八糟的,因此我们可以先申请足够多的object,在分配流程中进入到第5步,直接从伙伴系统分配,此时slab的freelist应该也是连续的。

即便我们可以保证freelist连续,要知道内核分配对象可不是一个个分配的,可能一个函数中就有多处分配对象,也就是说分配vuln object的时候肯定有个object跟着,姑且称之为xo。这时候如何利用呢?一个办法就是自己构造freelist。具体来说,就是:

A->C->B

这样,只要有合适的堆占位对象(如tty_struct),就能稳定利用这类堆溢出漏洞了。

案例2:CVE-2018-9568(WrongZone)漏洞利用

这里不涉及漏洞的详细细节,只需要知道这个漏洞的核心是类型混淆,即 Slab-A 中分配的对象,错误地用 Slab-B 进行了释放(这也是为什么这个漏洞名为WrongZone的原因)。

在WrongZone漏洞中,Slab-B比Slab-A要大(实际上Slab-A存放的是TCPv4 socket,而Slab-B存放的是TCPv6 socket,后者包含前者,并增加了一些额外字段)。而且因为RCU,Slab的free object中指向下一个free object的指针(x + offset)是包含在object末尾的。

释放、链入freelist,实际上是将当前freelist的值写入 x + offset ,并且将freelist重新指向刚释放的object。由于错误释放,在修改objA的offset时,超出了范围,写到下一个对象里面了。开启KASAN或者 SLUB_DEBUG_ON/MEMCG_KMEM 可以看到出错信息,否则很难触发明显的异常。

假设类型混淆对象为objA,从slabA中分配。实际的利用计划是这样的:

  1. 令objA在释放前所在的slabA为全满状态
  2. 释放objA,根据上面的分析,此时slabA会被链入B的cpu partial中。这意味着后续从B分配对象时候会出现从SlabA取对象的时候
  3. 依次释放slabA中的其他对象,令最后的布局如下:
1718c7feb9407801bc63abd4d0a9d886.png

follow_sk是位于objA后面的对象,我们不释放它,防止slab变空被回收。

这样一来,再在Slab-B中分配对象时候,就可能出现读取fragment中的值作为下一个free对象的情况。fragment中的值可以通过堆喷方式填充,这样就有可能令其在分配时读取一个我们能控制的值作为slab_alloc的返回从而进行进一步提权,或者出现显式的kernel panic。

这个利用的关键点就是对于全满slab中对象释放的处理,将slab链入cpu partial的时候并不会考虑slab是否正确,因为slab本身也只是一段连续的空间而已。当然该漏洞还有其他利用方法,比如将其转化为UAF,这里就不再赘述了。

总结

在我们平时学习相对复杂的系统时,仅仅了解实现文档只能算是走出第一步;阅读代码并且上机调试可以将理解加深一个层次,知道“what’s inside“;不过,如果能从攻击者的角度去分析和利用(滥用),那理解又会加深一个层次,真正做到”inside out“。魔鬼隐藏在细节之中,把一个知识点融会贯通,也是挺有趣的事情。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值