内核镜像攻击原理

目前,大部分Android手机内核配置为CONFIG_ARM64_4K_PAGES。相应地,MMU地址翻译过程如图1所示:


图1:4K页表地址翻译

从图中可以看出,Level 1和Level 2页表支持两种不同类型的翻译:一种是block类型,该类型的entry直接映射物理内存,没有后续页表;另一种是table类型,该类型的entry指向下一级页表。

同时,开启上述配置的Android内核通常开启CONFIG_ARM_VA_BITS_39。在这两种配置组合下(下文如没有特别指出,默认内核使用该配置组合),攻击者可以完成这样的攻击:通过漏洞(比如CVE-2017-7533)向Level 1的页表中插入一个精心构造的entry,然后用户态进程可以直接修改内核代码段。这样的entry具备以下特点:一是block类型,如果是table类型,需要构造后续页表。同时,构造block类型的entry比较容易(只涉及一次地址翻译);二是AP(Access Permissions)属性为01,这样内核和用户态均可访问相应内存:


图2:AP属性

该entry映射的物理内存范围包含或者部分包含内核镜像(代码段和数据段),攻击者便可以通过该映射修改内核代码段:


图3:KSMA

由此可知,完成镜像攻击需要两个条件:一是知道页表的位置(例如swapper_pg_dir),二是能够写入恶意的entry。

缓解思路与补丁实现过程

前面提到,完成镜像攻击需要两个条件。针对这两个条件,有两种缓解思路:

  1. 让攻击者难以找到swapper_pg_dir
  2. 攻击者无法在swapper_pg_dir中写入恶意entry。

早期补丁的实现思路是第一种:将swapper_pg_dir随机化(独立于KASLR),使攻击者难以找到目标页表。后来,在和ARM内核开发者James Morse的沟通中,考虑到将swapper_pg_dir随机化的缺陷(后面会提到),改用第二种思路:将swapper_pg_dir设置为只读。目前,该补丁已加入内核4.20中。

该补丁经过多次和ARM内核维护者的讨论、修改,主要的时间线如下:

时间 类型 版本
2018.6.1 随机化 v1
2018.6.20 只读 v1
2018.6.25 只读 v2
2018.7.2 只读 v3
2018.7.17 只读 v4
2018.9.17 只读 v5

下面详细介绍下这两种实现方式。

swapper_pg_dir随机化

内核初始化前期,首要任务是尽快开启MMU。在开启MMU之前,内核需要建立页表。页表使用的内存(swapper_pg_dir)是在链接脚本(vmlinux.ld.S)中分配的。因此,swapper_pg_dir在内核镜像中的相对位置比较固定,很容易被攻击者找到。内核建立完整页表时,swapper_pg_dir对应于Level 1页表。攻击者可以向其中写入Block类型的entry,进而完成镜像攻击。

为了提高攻击难度,补丁在内核建立完整页表时,为swapper_pg_dir动态分配内存(arch/arm64/mm/mmu.c中paging_init()):


图4:动态分配swapper_pg_dir

并使用new_swapper_pg_dir变量记录新的swapper_pg_dir的地址。

这种实现方式有两个问题:一是new_swapper_pg_dir变量会泄露swapper_pg_dir的地址。如果攻击者可以任意读,他可以轻松地找到swapper_pg_dir;二是虚假的随机化。early_pgtable_alloc()通过memblock_alloc()分配内存,而该函数从内存的顶端开始分配。由于内核初始化过程比较固定,所以新分配到的内存相对固定。攻击者可以通过实验确定swapper_pg_dir的地址。

所以,最终的补丁并没有采用这种方法。

swapper_pg_dir只读

在和James Morse沟通后,我们最终使用“将swapper_pg_dir设为只读”作为缓解措施。补丁需要将swapper_pg_dir移到rodata区。这样做会遇到以下问题:

第一个问题是swapper_pg_dir与其他pg_dir具有内在的位置逻辑关系。swapper_pg_dir定义在arch/arm64/kernel/vmlinux.lds.S中:


图5:swapper_pg_dir

从图5可以看出,swapper_pg_dir的周围还定义了其他的pg_dir:idmap_pg_dirtramp_pg_dirreserved_ttbr0。内核在这些pg_dir间切换时,假定了它们之间的相对位置关系,以tramp_unmap_kernel(arch/arm64/kernel/entry.S)为例:


图6:tramp_unmap_kernel

内核假定了(swapper_pg_dirPAGE_SIZE - RESERVED_TTBR0_SIZE)就是tramp_pg_dir的起始地址。在swapper_pg_dir移到rodata区后,这种位置关系将不再成立。解决办法是将这些pg_dir都移到rodata区。实际上这些pg_dir都应该避免被篡改,以防止攻击者进行类似的攻击。

第二个问题是swapper_pg_dir所占用的内存大小在内核初始化的过程中会变化,而rodata区的大小是不变的。

之前我们提到:MMU开启之前,swapper_pg_dir中存放内核早期页表,这时的swapper_pg_dir中包含了各级页表。而在内核建立完整页表时,swapper_pg_dir中只用来存放PGD,剩余空间在paging_init()中被释放:


图7:paging_init()中释放swapper部分空间

rodata区的大小在链接时就已经确定(arch/arm64/kernel/vmlinux.lds.S):


图8:rodata区

为了解决这个问题,我们引入init_pg_dir


图9:init_pg_dir

并在init_pg_dir中创建早期的页表:


图10:在init_pg_dir中创建页表

然后在swapper_pg_dir中建立完整的页表:


图11:在swapper中创建页表

这样就可以避免swapper_pg_dir占用的空间发生变化。

第三个问题是swapper_pg_dir需要更新,而rodata区是禁止直接写入的。

rodata区在内核初始化的后期会被设置为只读(rest_init() -> kernel_init() -> mark_readonly())。这意味着不能直接更新swapper_pg_dir,为了解决这个问题,我们引入了in_swapper_pgdir(),该函数的功能是确定要更新的地址是否属于swapper_pg_dir:


图12:in_swapper_pgdir函数

如果属于swapper_pg_dir,我们使用set_swapper_pgd()完成相应的更新:


图13:set_swapper_pgd

该函数通过fixmap映射到目标地址上,在更新完成后,再取消fixmap映射。

缓解措施的局限性

防护应该是一个体系,单一的缓解措施都有其局限性,在某些情况下可以被绕过。目前的缓解措施,提高了攻击者的成本,但具有以下局限性:

一是补丁通过fixmap更新swapper_pg_dir。攻击者可能通过两种方式完成攻击:一是借助ROP实现fixmap更新逻辑;二是修改swapper_pg_dir原始页表的属性,从而使得rodata区可写(这只是一种可能,如果攻击者可以修改rodata区页表的属性,他也可以直接修改代码段页表的属性)。前者需要攻击者控制PC并找到合适的gadget,后者需要攻击者能够多次任意读以及任意写。

二是补丁只保护swapper_pg_dir。在之前提到的配置组合下,补丁可以保护Level 1的页表。但是,攻击者可以使用Level 2页表。这需要攻击者能够找到Level 2页表位置。

致谢

感谢James Morse和Mark Rutland对补丁提出的建议。在他们的帮助下,补丁得以顺利地被社区接受。



Published

02 January 2019

Tags