【CVE复现NO.00001】CVE-2016-5195复现及简要分析
github blog addr
0x00.一切开始之前
CVE-2016-5195 即 dirtyCOW
,俗称「脏牛」漏洞,是 Linux Kernel 中的条件竞争漏洞,攻击者可以利用 Linux kernel 中的 COW(Copy-on-Write)技术中存在的逻辑漏洞完成对文件的越权读写
脏牛漏洞几乎涵盖了所有主流的 Linux 发行版,同时也是一个由Linus本人亲手修复的漏洞
笔者本人尝试复现的第一个 kernel 方向的 cve ,不禁感叹自己会的还是太少…
本篇文章中贴出的内核源码主要关注笔者写上中文注释的部分即可
一、写时复制机制(Copy-on-Write)
要想说清楚什么是 dirtyCOW
,首先得先把什么是 COW
给弄明白,这里我们先从教科书上讲的常规的 COW 入手
basic COW
COW 即 Copy On Write
——「写时复制」:为了减少系统的开销,在一个进程通过 fork()
系统调用创建子进程时,并不会直接将整个父进程地址空间的所有内容都复制一份后再分配给子进程(虽然第一代 UNIX 系统的确采用了这种非常耗时的做法),而是基于一种更为高效的思想:
「父进程与子进程共享所有的页框」而不是直接为子进程分配新的页框,「只有当任意一方尝试修改某个页框」的内容时内核才会为其分配一个新的页框,并将原页框中内容进行复制
- 在
fork()
系统调用之后,父子进程共享所有的页框,内核会将这些页框全部标为read-only - 由于所有页框被标为只读,当任意一方尝试修改某个页框时,便会触发**「缺页异常」**(page fault)——此时内核才会为其分配一个新的页框
大致过程如下图所示:
这便是「写时复制」的大体流程——只有当某个进程尝试修改共享内存时,内核才会为其分配新的页框,以此大幅度减少系统的开销,达到性能优化的效果
mmap 与 COW
同样地,若是我们使用 mmap 映射了一个只具有读权限而不具有写权限的文件,当我们尝试向 mmap 映射区域写入内容时,也会触发写时复制机制,将该文件内容拷贝一份到内存中,此时进程对这块区域的读写操作便不会影响到硬盘上的文件
二、缺页异常(page fault)
在 CPU 中使用 MMU(Memory Management Unit,内存管理单元)进行虚拟内存与物理内存间的映射,而在系统中并非所有的虚拟内存页都有着对应的物理内存页, 当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,MMU 无法完成由虚拟内存到物理内存间的转换,此时便会产生**「缺页异常」**(page fault)
可能出现缺页异常的情况如下:
- 线性地址不在虚拟地址空间中
- 线性地址在虚拟地址空间中,但没有访问权限
- 线性地址在虚拟地址空间中,但没有与物理地址间建立映射关系
虽然被命名为 “fault” ,但是缺页异常的发生并不一定代表出错
分类
①软性缺页异常(soft page fault)
软性缺页异常意味着相关的页已经被载入内存中,但是并未向 MMU 进行注册,此时内核只需要在 MMU 中注册相关页对应的物理页即可
可能出现软性缺页异常的情况如下:
- 两个进程间共享相同的物理页框,操作系统为其中一个装载并注册了相应的页,但是没有为另一个进程注册
- 该页已被从 CPU 的工作集(在某段时间间隔 ∆ 里,进程实际要访问的页面的集合,为提高性能,只有经常被使用的页才能驻留在工作集中,而长期不用的页则会被从工作集中移除)中移除,但是尚未被交换到磁盘上;若是程序重新需要使用该页内容,CPU 只需要向 MMU 重新注册该页即可
②硬性缺页异常(hard page fault)
硬性缺页异常意味着相关的页未经被载入内存中,此时操作系统便需要寻找到一个合适且空闲的物理页/将另一个使用中的页写到硬盘上
,随后向该物理页内写入相应内容,并在 MMU 中注册该页
硬性缺页异常的开销极大,因此部分操作系统也会采取延迟页载入的策略——只有到万不得已时才会分配新的物理页,这也是 Linux 内核的做法
若是频繁地发生硬性缺页异常则会引发系统颠簸(system thrashing,有的书上也叫系统抖动)——因资源耗尽而无法正常完成工作
③无效缺页异常(invalid page fault)
无效缺页异常意味着程序访问了一个无效的内存地址(内存地址不存在于进程地址空间),在 Linux 下内核会向进程发送 SIGSEGV
信号
处理缺页异常
由于本篇所分析的漏洞存在于老版本的 Linux kernel,故我们简要分析相应版本内核(笔者选择了 v4.4)中该函数的逻辑
在接下来的分析过程中所涉及到的地址如无说明皆为【线性地址】
仅针对**「文件映射缺页异常」**而言,大致的流程如下图所示:(字比较丑见谅qwq
预处理:__do_page_fault()
先来看处理缺页异常的顶层函数__do_page_fault ()
,该函数位于内核源码中的 arch/x86/mm/fault.c
中,代码逻辑如下:
注:找寻某个函数于内核源码中的位置可以使用https://elixir.bootlin.com/linux
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long error_code,
unsigned long address)//regs:寄存器信息;error_code:异常代码(三bit);address:请求的【线性地址】(虚拟地址转换到物理地址之间的中间量)
{
struct vm_area_struct *vma;//线性区描述符,用以标识一块连续的地址空间,多个vma之间使用单向链表结构连接
struct task_struct *tsk;//进程描述符,用以描述一个进程
struct mm_struct *mm;//内存描述符,用以描述一个进程的内存地址空间
int fault, major = 0;
unsigned int flags = FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_KILLABLE;//设置flag的允许重试 && 允许杀死(进程?)标志位
tsk = current;
mm = tsk->mm;
/*
* Detect and handle instructions that would cause a page fault for
* both a tracked kernel page and a userspace page.
*/
if (kmemcheck_active(regs))
kmemcheck_hide(regs);
prefetchw(&mm->mmap_sem);
if (unlikely(kmmio_fault(regs, address)))//mmiotrace跟踪器相关
return;
/*
* We fault-in kernel-space virtual memory on-demand. The
* 'reference' page table is init_mm.pgd.
*
* NOTE! We MUST NOT take any locks for this case. We may
* be in an interrupt or a critical region, and should
* only copy the information from the master page table,
* nothing more.
*
* This verifies that the fault happens in kernel space
* (error_code & 4) == 0, and that the fault was not a
* protection error (error_code & 9) == 0.
*/
if (unlikely(fault_in_kernel_space(address))) {
//发生缺页异常的地址位于内核空间,这里由于内核空间页面使用频繁,一般不会发生缺页异常,所以使用unlikely宏优化
if (!(error_code & (PF_RSVD | PF_USER | PF_PROT))) {
//三个标志位:使用了页表项保留的标志位、用户空间页异常、页保护异常,三个标志位都无说明是由内核触发的内核空间的缺页异常
if (vmalloc_fault(address) >= 0)//处理vmalloc异常
return;
if (kmemcheck_fault(regs, address, error_code))
return;
}
/* Can handle a stale RO->RW TLB: */
if (spurious_fault(error_code, address))//检测是否是假的page fault(TLB的延迟flush造成)
return;
/* kprobes don't want to hook the spurious faults: */
if (kprobes_fault(regs))//转内核探针处理
return;
/*
* Don't take the mm semaphore here. If we fixup a prefetch
* fault we could otherwise deadlock:
*/
bad_area_nosemaphore(regs, error_code, address);//前面的情况都不是,说明发生了对非法地址访问的内核异常(如用户态尝试访问内核空间),杀死进程和内核的"Oops"
return;
}
//接下来是对于发生在用户空间的缺页异常处理
/* kprobes don't want to hook the spurious faults: */
if (unlikely(kprobes_fault(regs)))//转内核探针处理
return;
if (unlikely(error_code & PF_RSVD))//使用了页表项保留的标志位
pgtable_bad(regs, error_code, address);//页表错误,处理
if (unlikely(smap_violation(error_code, regs))) {
//触发smap保护(内核直接访问用户地址空间)
bad_area_nosemaphore(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
/*
* If we're in an interrupt, have no user context or are running
* in a region with pagefaults disabled then we must not take the fault
*/
if (unlikely(faulthandler_disabled() || !mm)) {
//设置了不处理缺页异常 | 进程没有地址空间(?)
bad_area_nosemaphore(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
/*
* It's safe to allow irq's after cr2 has been saved and the
* vmalloc fault has been handled.
*
* User-mode registers count as a user access even for any
* potential system fault or CPU buglet:
*/
if (user_mode(regs)) {
//发生缺页异常时的寄存器状态为用户态下的
local_irq_enable();//本地中断请求(irq, interrupt request)开启
error_code |= PF_USER;//设置错误代码的【用户空间页】标志位
flags |= FAULT_FLAG_USER;//设置flag的【用户空间页】标志位
} else {
if (regs->flags & X86_EFLAGS_IF)
local_irq_enable();
}
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
if (error_code & PF_WRITE)//写页异常,可能是页不存在/无权限写
flags |= FAULT_FLAG_WRITE;//设置flag的【写页异常】标志位
/*
* When running in the kernel we expect faults to occur only to
* addresses in user space. All other faults represent errors in
* the kernel and should generate an OOPS. Unfortunately, in the
* case of an erroneous fault occurring in a code path which already
* holds mmap_sem we will deadlock attempting to validate the fault
* against the address space. Luckily the kernel only validly
* references user space from well defined areas of code, which are
* listed in the exceptions table.
*
* As the vast majority of faults will be valid we will only perform
* the source reference check when there is a possibility of a
* deadlock. Attempt to lock the address space, if we cannot we then
* validate the source. If this is invalid we can skip the address
* space check, thus avoiding the deadlock:
*/
//给进程的mm_struct上锁
if (unlikely(!down_read_trylock(&mm->mmap_sem))) {
//没能锁上
if ((error_code & PF_USER) == 0 && //内核空间页异常
!search_exception_tables(regs->ip)) {
bad_area_nosemaphore(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
retry:
down_read(&mm->mmap_sem);
} else {
//锁上了
/*
* The above down_read_trylock() might have succeeded in
* which case we'll have missed the might_sleep() from
* down_read():
*/
might_sleep();
}
vma = find_vma(mm, address);//寻找该线性地址位于哪个vma中
if (unlikely(!vma)) {
//没找到,说明该地址不属于该进程的任何一个vma中(非法访问?段错误?)
bad_area(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
if (likely(vma->vm_start <= address))//发生缺页异常的地址刚好位于某个vma区域中
goto good_area;
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
//设置了VM_GROWSDOWN标记,表示缺页异常地址位于堆栈区
bad_area(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
if (error_code & PF_USER) {
//缺页异常地址位于用户空间
/*
* Accessing the stack below %sp is always a bug.
* The large cushion allows instructions like enter
* and pusha to work. ("enter $65535, $31" pushes
* 32 pointers and then decrements %sp by 65535.)
*/
if (unlikely(address + 65536 + 32 * sizeof(unsigned long) < regs->sp)) {
//看不懂都...
bad_area(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
}
if (unlikely(expand_stack(vma, address))) {
//用户栈上的缺页异常,但是栈增长失败了
bad_area(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
/*
* Ok, we have a good vm_area for this memory access, so
* we can handle it..
*/
//运行到这里,说明是正常的缺页异常,addr属于进程的地址空间,此时进行请求调页,分配物理内存
good_area:
if (unlikely(access_error(error_code, vma))) {
//error code和vma冲突?
bad_area_access_error(regs, error_code, address);//杀死进程和内核的"Oops"
return;
}
/*
* If for any reason at all we couldn't handle the fault,
* make sure we exit gracefully rather than endlessly redo
* the fault. Since we never set FAULT_FLAG_RETRY_NOWAIT, if
* we get VM_FAULT_RETRY back, the mmap_sem has been unlocked.
*/
fault = handle_mm_fault(mm, vma, address, flags);//分配物理页的核心函数
major |= fault & VM_FAULT_MAJOR;
/*
* If we need to retry the mmap_sem has already been released,
* and if there is a fatal signal pending there is no guarantee
* that we made any progress. Handle this case first.
*/
if (unlikely(fault & VM_FAULT_RETRY)) {
//没找到设置这个标志位的,不管...
/* Retry at most once */
if (flags & FAULT_FLAG_ALLOW_RETRY) {
flags &= ~FAULT_FLAG_ALLOW_RETRY;//清除【重试】标志位
flags |= FAULT_FLAG_TRIED;//设置【已试】标志位
if (!fatal_signal_pending(tsk))
goto retry;
}
/* User mode? Just return to handle the fatal exception */
if (flags & FAULT_FLAG_USER)//用户态触发用户地址空间缺页异常,交由上层函数处理了
return;
/* Not returning to user mode? Handle exceptions or die: */
no_context(regs, error_code, address, SIGBUS, BUS_ADRERR);//内核地址空间缺页异常,简单处理一下,交由上层函数处理
return;
}
up_read(&mm->mmap_sem);
if (unlikely(fault & VM_FAULT_ERROR)) {
mm_fault_error(regs, error_code, address, fault);
return;
}
/*
* Major/minor page fault accounting. If any of the events
* returned VM_FAULT_MAJOR, we account it as a major fault.
*/
if (major) {
tsk->maj_flt++;
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1, regs, address);
} else {
tsk->min_flt++;
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, regs, address);
}
check_v8086_mode(regs, address, tsk);
}
NOKPROBE_SYMBOL(__do_page_fault);
大致流程应当如下:
- 判断缺页异常地址位于用户地址空间还是内核地址空间
- 位于内核地址空间
- 内核态触发缺页异常,
vmalloc_fault()
处理 - 用户态触发缺页异常,段错误,发送SIGSEGV信号
- 内核态触发缺页异常,
- 位于用户地址空间
- 内核态触发缺页异常
- SMAP保护已开启,终止进程
- 进程无地址空间 | 设置了不处理缺页异常,终止进程
- 进入下一步流程
- 用户态触发缺页异常
- 设置对应标志位,进入下一步流程
- 检查是否是写页异常,可能是页不存在/无权限写,设置对应标志位
- 找寻线性地址所属的线性区(vma)[1]
- 不存在对应vma,非法访问
- 存在对应vma,且位于vma所描述区域中,进入下一步流程
- 存在对应vma,不位于vma所描述区域中,说明可能是位于堆栈(stack),尝试增长堆栈
- ✳调用
handle_mm_fault()
函数处理,这也是处理缺页异常的核心函数- 失败了,进行重试(返回到[1],只会重试一次)
- 其他收尾处理
- 内核态触发缺页异常
其中进程描述符(task_struct)、内存描述符(mm_struct)、线性区描述符vm_arena_struct)之间的关系应当如下图所示