Nested Kernel Implementation on Linux Kernel*
Tldr
记录在 Linux 内核上实现嵌套内核架构的过程。
嵌套内核架构的实现包括以下几个方面:
- 控制页表相关操作,所有页表页只读,仅支持使用接口修改页表;
- 控制敏感指令,相关寄存器,CR0、CR3、CR4、MSR 等,要替换为对应的嵌套内核接口;
- 控制流保护,控制中断和异常处理,进入和离开嵌套内核需要经过调用门;
- 嵌套内核数据段保护,包括栈,通过预留内存方式。
与 KVM(AMD SVM)结合的嵌套内核架构还包括以下几方面:
- 控制嵌套页表相关操作,类似对内核页表页,将嵌套页表页设为只读,KVM 只能使用接口修改;
- 控制虚拟化相关指令,VMRUN、VMLOAD、VMSAVE 等;
- 控制流保护,将 VM Entry 和 VM Exit 相关代码放在嵌套内核中。
下面所说的内核指的是应用了嵌套内核之后的 outer kernel。
嵌套内核接口*
相关函数定义位置如下,新增 nested_kernel.c,需要在 Makefile 中添加 nested_kernel.o:
- arch/x86/mm/nested_kernel.c
- arch/x86/include/asm/pgtable_64.h
- arch/x86/include/asm/pgtable.h
- arch/x86/include/asm/pgalloc.h
- arch/x86/kernel/setup.c
- arch/x86/mm/pgtable.c
- arch/x86/mm/init.c
- arch/x86/mm/fault.c
- init/main.c
- mm/memory.c
arch/x86/mm/init.c 中的
probe_page_size_mask负责探测能否使用大页(2M,1G)进行直接映射,在内核启动参数中添加nogbpages即可禁用 1G 大页,让内核在直接映射时最多只映射到 2M 大页,方便设置页表页。
相关数据结构和变量:
ptp_t:页表页信息结构体,包含页表页地址和页表页对应的页表项地址;struct ptp_info:页表页数组结构体,包含数组基地址和数组中页表页数量;pgt_pages[PGD_LEVEL]:页表页数组,每个等级的页表页填入对应的数组;nk_page_info_t:嵌套内核维护的页信息结构体,其中的 type 成员标识页的用途;nk_mem_map:系统页表信息数组,每个物理页都有一个对应的nk_page_info_t结构体。
相关辅助函数:
rebuild_page_table_for_ptp:为页表页重构页表;get_pte_for_ptp:获取页表页对应的页表项;save_ptp_info:保存页表页信息;remove_ptp_info:移除页表页信息;walk_through_ptp:递归遍历制定等级的页表页;walk_pgtable:根据地址遍历页表,获取页表项地址、等级和内容;set_ptp_read_only:设置页表页只读;int3_in_target_rip:处理页表页 PF 导致的 INT3 中断;save_rip_info:保存触发页表页 PF 的 RIP 信息;fault_in_ptp:判断 PF 是否由写页表页触发;do_ptp_page_fault:处理页表页 PF。
嵌套内核接口:
NESTED_KERNEL_ENTRY:嵌套内核入口门,关中断、关 WP、切换到独立的嵌套内核栈;NESTED_KERNEL_EXIT:嵌套内核退出门,切栈、开 WP、使用循环检查确保启用 WP、开中断;NESTED_KERNEL_WRAPPER:嵌套内核接口定义宏,使用入口和出口门包装接口操作;nested_kernel_write_pte: 写页表项,判断页表项地址和内容;nested_kernel_get_and_clear_pte:使用 XCHG 指令修改页表项并获取旧值;nested_kernel_declare_ptp:声明页表页,将页表页设置为只读;nested_kernel_undeclare_ptp:取消声明页表页,将页表页恢复为可写;nested_kernel_load_cr3:加载顶级页目录表物理地址到 CR3,判断是否为声明过的顶级页目录表;nested_kernel_load_cr0/cr4/msr:加载控制寄存器,判断关键标志位是否置位;nested_kernel_idtentry:统一的中断处理入口,先关闭 WP 后在跳转到内核的中断处理执行。
虚拟化安全模块接口:
vsm_vm_entry:VSM VM Entry ,KVM 调用此接口运行虚拟机;vsm_nptp_alloc:VSM 分配 NPT 页表页;vsm_nptp_free:VSM 释放 NPT 页表页;vsm_set_npte:VSM 写 NPT 表项,判断映射和标志位。
页表相关*
控制内核空间的页表,包括直接映射(基址 0xffff888000000000)、内核代码段数据段(基址 0xffffffff80000000)以及后续按需设置的内核空间页表。
对于用户页表,虽然存在内核将用户页表设置为特权模式(页表项 U/S 位清零)绕过 SMAP、SMEP 的可能,但是正常的内核里并没有这样的代码。因此只要保证内核代码完整性,就可以确保避免此类攻击。因此嵌套内核不会去检查顶级页表页前半部分(用户地址空间)设置。对于其他的类似问题(指存在攻击可能但是正常的内核中没有这样的逻辑),嵌套内核也不会做特殊检查。
通过 per-page 结构体中的 page type 成员在 set pte 时判断目标物理地址是否是嵌套内核内存/页表页,可实施安全策略。
内核初始化后使用的顶级页表为 swapper_pg_dir,即 init_top_pgt,详情可见 内核页表演进过程。
控制页表相关操作,主要包括以下几个方面:
- 收集所有的页表页(Page Table Pages, PTPs):
- 在页表初始化
init_mem_mapping中,设置内核直接映射之后,遍历以init_top_pgt为顶级页目录表的页表,记录所有的页表页和对应的页表等级; - 对于此后新加入的页表页,通过在页表页分配操作
pgd/pud/pmd/pte_alloc中调用嵌套内核接口nested_kernel_declare_ptp,向嵌套内核声明页表页; - 同时在页表页释放操作
pgd/pud/pmd/pte_free中调用嵌套内核接口nested_kernel_undeclare_ptp,向嵌套内核声明删除页表页。 - 找到页表页对应的页表项,并设为只读(清除 R/W 标志位):
- 根据页表页的虚拟地址(除了在内核数据段的顶级页表页,内核访问所有页表页都是通过直接映射的虚拟地址),调用
walk_pgtable遍历整个页表,即可找到对应的页表项; - 存在问题:内核代码数据和直接映射大多使用 2M 或 1G 大页映射,遍历页表找到的页表项其实是管理 2M/1G 的空间,如果直接设置为只读,则会使对这部分区域其他地址的写访问触发 page fault, PF,特别是对内核数据段的设置,容易导致内核崩溃;
- 解决方案:根据页表页地址遍历页表找到的页表项及其页表等级,如果是大页(页表等级为 2 或 3,对应 2M 和 1G 大页),则调用
rebuild_page_table_for_ptp为当前页表页重构页表,将大页拆分为 4K 页,并返回新的 4K 页表项地址。对于 2M 大页,需要新增一个页表页,将原来的 2M 页表项指向新的页表页,并填充新页表页内容,覆盖原来的 2M 内存区域。对于 1G 大页,需要新增两个页表页,过程类似。对于重构页表过程中新增的页表页,也要调用嵌套内核接口声明。 - 页表页 PF 处理:
- 在 PF 处理程序
do_page_fault中添加对因设置页表页只读触发的 PF 处理,通过fault_in_ptp判断当前 PF 是否为内核写页表页触发,是则由do_ptp_page_fault处理; - 为快速收集所有设计页表页修改的内核代码,需要在触发 PF 时记录 RIP 和符号信息,并放行本次操作(为了让系统能继续运行)。放行的操作主要分两种:设置页表项为可写、关闭 CR0.WP,前者将导致对本页表页的后续写操作不会触发 PF 而无法捕获,后者则会放行让系统中所有的页表页写操作。理想的情况是在 PF 处理返回后重新执行当前指令,在其执行后关闭 WP,因此将下一条指令的第一个字节改为 INT3,当前指令执行后继续执行会触发 INT3 中断,进入对应的处理程序
do_int3,而在其中通过判断本次 INT3 是否为页表页 PF 导致,是则恢复指令,开启 WP,继续执行。 - 将页表页修改操作修改为对嵌套内核接口的调用:
- 修改内核原有的页表页修改操作
set_pte/pmd/pud/p4d/pgd,调用嵌套内核接口nested_kernel_write_pte,其中会对要设置的页表项地址和内容进行判断,包括地址是否属于嵌套内核中记录的页表页、页表项覆盖地址范围是否包含敏感数据以及页表项标志位等; - 对于极少部分不使用
set_xxx接口修改页表页的内核代码,如vunmap_page_range中使用 XCHG 指令修改页表项,也要将其替换为嵌套内核接口。
控制 KVM 嵌套页表相关操作,与嵌套内核类似:
- 在嵌套页表页分配和释放操作
kvm_mmu_alloc/free_page中调用嵌套内核接口nested_kernel_declare/undeclare_ptp; - 将嵌套页表项修改操作
__set_spte等改为调用嵌套内核接口nested_kernel_set_pte。
敏感指令*
| 敏感指令 | 描述/影响 | 策略(处理方式) |
|---|---|---|
| MOV-to-CR0 | WP 控制写保护,PG 控制分页,PE 控制保护模式 | 禁止置零相关标志位(指令执行后检查) |
| MOV-to-CR4 | SMAP 和 SMEP 控制特权模式对用户页的访问权限 | 禁止置零相关标志位(指令执行后检查) |
| WRMSR EFER | LME 控制长模式,NXE 控制不可执行权限,SVME 控制虚拟化扩展 | 禁止置零相关标志位(指令执行后检查) |
| MOV-to-CR3 | 切换内存地址空间 | 仅允许设置为已声明的顶级页表页 (按需映射) |
| LIDT | 设置 IDT 基址 | 禁止修改 IDT |
与 KVM 结合,还需要将虚拟化指令加入到敏感指令序列,AMD 平台上的指令包括 VMRUN、VMLOAD、VMSAVE、VMMCALL、STGI/CLGI 等。
控制敏感指令的方法:
- 内核本身封装了对特殊寄存器的修改和访问操作
native_write_cr0/cr3/cr4/msr,将这些接口改为调用嵌套内核的接口即可; - 对于汇编源代码文件中的相关指令,也需要替换为嵌套内核接口,防止内核跳转到此处执行;
- 对于 MOV-to-CR0/CR4, WRMSR 这种敏感指令,可以在执行后通过循环检查相关标志位是否合法;
- 对于 MOV-to-CR3, LIDT 这种执行后可能会改变控制流的指令(执行后直接切换地址空间),需要取消其内存映射,按需映射,并在映射前检查指令参数;
- 扫描编译生成的内核二进制文件,确保内核中没有敏感指令实例,包括未对齐边界的指令。
控制流保护*
- 一部分控制流的保护要依赖敏感指令的保护,如
MOV-to-CR3以及虚拟化指令; - 控制 IDT,中断和异常处理要先通过嵌套内核,确保开启 WP;
- 嵌套内核操作要由入口门和出口门包装。
嵌套内核数据保护*
- 嵌套内核的数据结构需要与内核隔离,可以通过预留内存的方式实现;
- 执行嵌套内核操作需要切换到独立的栈。
相关相关操作如下:
使用内核参数 memmap=4M\$0x140000000 保留内存,直接影响 e820 表,保留的内存不会出现在 memblock 中,也不会映射在直接映射页表中。
e820_table 内容如下,可以看到 5G 开始的 4M 空间被标记为保留。
[qhx]: print e820 table
[qhx]: [mem 0x0000000000000000-0x0000000000000fff] reserved
[qhx]: [mem 0x0000000000001000-0x000000000009fbff] usable
[qhx]: [mem 0x000000000009fc00-0x000000000009ffff] reserved
[qhx]: [mem 0x00000000000f0000-0x00000000000fffff] reserved
[qhx]: [mem 0x0000000000100000-0x000000007ffdefff] usable
[qhx]: [mem 0x000000007ffdf000-0x000000007fffffff] reserved
[qhx]: [mem 0x00000000b0000000-0x00000000bfffffff] reserved
[qhx]: [mem 0x00000000fed1c000-0x00000000fed1ffff] reserved
[qhx]: [mem 0x00000000feffc000-0x00000000feffffff] reserved
[qhx]: [mem 0x00000000fffc0000-0x00000000ffffffff] reserved
[qhx]: [mem 0x0000000100000000-0x000000013fffffff] usable
[qhx]: [mem 0x0000000140000000-0x00000001403fffff] reserved // 保留的内存,5G 开始 4M
[qhx]: [mem 0x0000000140400000-0x000000027fffffff] usable
memblock 信息如下,[0x0000000140000000-0x00000001403fffff] 保留的内存区域不在 memblock 中。
MEMBLOCK configuration:
memory size = 0x00000001ffb7dc00 reserved size = 0x0000000002759000
memory.cnt = 0x4
memory[0x0] [0x0000000000001000-0x000000000009efff], 0x000000000009e000 bytes on node 0 flags: 0x0
memory[0x1] [0x0000000000100000-0x000000007ffdefff], 0x000000007fedf000 bytes on node 0 flags: 0x0
memory[0x2] [0x0000000100000000-0x000000013fffffff], 0x0000000040000000 bytes on node 0 flags: 0x0
// [0x0000000140000000-0x00000001403fffff] 保留的内存区域不再 memblock 中
memory[0x3] [0x0000000140400000-0x000000027fffffff], 0x000000013fc00000 bytes on node 0 flags: 0x0
reserved.cnt = 0x6
reserved[0x0] [0x0000000000000000-0x000000000000ffff], 0x0000000000010000 bytes on node 0 flags: 0x0
reserved[0x1] [0x0000000000099000-0x00000000000fffff], 0x0000000000067000 bytes on node 0 flags: 0x0
reserved[0x2] [0x0000000001000000-0x000000000340cfff], 0x000000000240d000 bytes on node 0 flags: 0x0
reserved[0x3] [0x000000007fd2b000-0x000000007ffcffff], 0x00000000002a5000 bytes on node 0 flags: 0x0
reserved[0x4] [0x000000027ffd0000-0x000000027fff9fff], 0x000000000002a000 bytes flags: 0x0
reserved[0x5] [0x000000027fffa000-0x000000027fffffff], 0x0000000000006000 bytes on node 0 flags: 0x0
内核直接映射页表如下,可以看到指定的保留内存区域未被映射。
pgd[273] = 3401067 [P R/W U/S A]
pud-ptp_addr: va = ffff888003401000 pa = 3401000
pud[0] = 3402067 [P R/W U/S A]
...
pud[1] = 27ffff067 [P R/W U/S A]
...
pud[4] = 80000001000000e3 [1G NX P R/W A D PAT/PSE]
pud[5] = 3404067 [P R/W U/S A]
pmd-ptp_addr: va = ffff888003404000 pa = 3404000
// pmd[0] 和 pmd[1] 应该是覆盖 [0x0000000140000000-0x00000001403fffff] 的连个
pmd[2] = 80000001404000e3 [2M NX P R/W A D PAT/PSE]