虽然已经有很多文章和博客贴出了该漏洞的利用,但是没有一个给出从内核角度分析Dirty Cow原理的详细描述。如下的分析是基于这个Dirty Cow POC的,同样的思路也适用于其他相似的攻击。例子代码很短,最重要的是两个线程的操作:一个线程调用write(2)
写/proc/self/mem
,另一个线程调用madvice(MADV_DONTNEED)
。由于这两个线程操作的相互竞争,当wirte(2)
直接修改基于文件的内存映射时(即使涉及到的文件不允许被攻击者进程写)会产生一个安全问题,最终导致提权。
该文章颇重于技术层面,故假设读者已经掌握了如下的基本技术点:
尽管我们最终是想写入,然而代码首先会以只读的方式O_RDONLY
来打开open
一个特定的文件,这样做的目的是为了让内核”happy”,由于我们目前只有比较低的权限,无法写入特定的文件。在成功打开并获取了文件描述符之后,立即调用mmap
来映射该文件。
调用函数mmap
在进程的虚拟地址空间中创建了一个基于文件的(file-backed)的只读的内存映射,内核中通过结构struct vm_area_struect
来描述该内核对象,其包含映射的文件描述符,对于映射的页的读写权限等一些信息。之后创建了两个线程,一个调用madvice
,另一个调用write
。
首先看下madviseThread
做了什么:
madvise(MADV_DONTNEED)
基本功能是清除被管理的内存映射的物理页。就当前情况而言, 在调用完该函数后,提到的这些页将被clear。当下一次用户尝试访问这些内存区域时,原始的内容会重新从磁盘或者页缓存中导入,而对于匿名的堆内存,则会填充零。
官方文档解释如下:
MADV_DONTNEED
在Linux上的行为一直都是有争议的,它并没有完全服从POSIX的标准。事实上,我们将会看到它非标准的行为而导致Dirty COW的攻击变为可能。
继续看另一个线程,此处是攻击的关键点:
它首先lseek
到映射的地址,之后调用write(2)
便实现了直接修改原本是只读权限的file-backed的内存映射。究竟是哪种行为导致特权文件的修改?HOW???!!!
write(2)
on /proc/{pid}/mem
/proc/{pid}/meme
是一个假的文件,它提供了一些Out-of-band的访问内存的方法。另一个类似的访问是调用ptrace(2)
,同样的,也可称为Dirty COW的另一个可选的攻击点。
为弄清楚/proc/self/mem
如何工作,我们需要深入的了解内核。首先看下对于虚拟的文件,write(2)
是如何实现的。
在内核层面中,文件系统的操作的实现是利用面向对象的思想设计的(OOP)。有一个通用的抽象的结构struct file_operations
。不同的文件类型,可以提供不同的实现。对于/proc/{pid}/mem
,它的实现在文件/fs/proc/base.c
中。
当write(2)
写一个虚拟文件时,内核将调用函数mem_write
,它只是对meme_rw
的一个简单的封装。
函数开始的时候分配了一个临时的内存buffer,用来在源进程(i.e. 写的那个进程)和目的进程(被写/proc/self/mem的那个进程)之间的内存交换。当前,这两个进程是一样的。但是在一般情况下这一步是非常重要的,对于两个不同的进程。因为一个进程不能直接访问另一个进程的虚拟地址空间。
之后它拷贝源进程的用户态bufferbuf
中的内容到当前刚申请的空间中,通过调用函数copy_from_use
。
当这些前奏工作准备好之后,真正关键的部分是access_remote_vm
。正如其名字含义一样,它允许内核读写另一个进程的虚拟地址空间。它是所有out-of-band访问内存方式的核心实现(比如,ptrace(2), /proc/self/mem, process_vm_readv, process_vm_writev
等)。
access_remote_vm
调用了多个中间层函数,最终调用__get_user_pages_locked(...)
,在这个函数中,它第一次开始解析这种out-of-band访问方式的flags
,当前情况的标志为:
FOLL_TOUCH | FOLL_REMOTE | FOLL_GET | FOLL_WRITE | FOLL_FORCE
这些被称为gup_flags
(Get User Pages flags)或者foll_flags
(Follow flags),它们来代表一些信息,比如调用者为什么或以何种方式访问和获得目标的内存页。我们暂称它为access semantics(访问语义)
。
之后flag
和所有其他的参数之后传递给__get_user_pages
,此时才是开始真正地访问远程进程内存。
__get_user_pages
和faultin_page
。
__get_use_pages
函数用来查找和锁定一个指定的虚拟地址范围(在远程进程的虚拟地址空间范围内)到内核地址空间范围内。锁定内存是必须的,若没有这一步,用户态页面可能不在内存中。之后__get_user_pages
以某种方式模拟用户态内存访问,但是是在内核层面上,之后使用faultin_page
来完成对页错误的处理。
如下是相关代码片段:
代码首先定位远程进程中起始地址为start
的内存页,而且foll_flags
决定着当前的内存访问语义。如果该页面不可用(page==NULL),即该页面不在内存中,需要进行页错误处理。之后faultin_page
被调用,内部模拟一个内存空间的访问和触发页错误处理,以期待handler换进丢失的页。
通常有几个原因导致follow_page_mask`返回空,如下是一个不完全的列表:
访问语义foll_flags
与页的权限配置不一致(比如,写一个只读的映射)。
最后一个原因就是我们调用write(2)
写/proc/self/mem
后发生的情况。通常的做法是页错误handler成功的处理错误,返回一个有效的页,之后再次重新访问。
注意那个retry
标志。此刻还不清楚作用,之后我们会提到,它是另一个导致此次exploit的“帮凶”。
心里明白这点后,继续看fault_page
的实现:
函数的前半段解释foll_flags
为对应的fault_flags
,用以传递给handle_mm_fault
,而该函数负责解析页错误,这样__get_user_pages
可以继续执行。
在当前情况中,因为我们要修改的原始的内存映射为只读的,故handle_mm_fault
将创建一个新的只读的COW page(do_wp_page)
给我们想要写的地址,同时使它为变为私有的和dirty,因此称为Dirty COW
。
真正创建COWed page的是嵌入在handle深处的do_wp_page
,粗略的执行流程如下:
现在我们将视线转回fault_page
的结束位置,在它返回之前,它做了如下一件事,使得该利用变为可能。
在它检测到一个写时复制发生后,(ret & VM_FAULT_WRITE == true
),它决定移除FOLL_WRITE
flag。为什么要这样做?
还记得那个retry
lable么?如果不移除FOLL_WRITE
,则下一次retry,将执行同样的流程。新申请的COWed 页和原来的也有同样的访问权限。同样的访问权限,同样的foll_flags
,同样的retry,会导致死循环。
为了打破这种无限的retry循环,一个聪明的想法是移除write flag。这样当下一次调用follow_page_mask
时,将返回一个有效的页,指向起始地址。因为当前FOLL_WRITE
不在了,foll_flags
仅仅是一个普通的读权限,这对于新申请的COWed 只读页时允许的。
此处到了问题的关键。通过从foll_flags
移除write标志, follow_page_mask
在下一次retry时,该访问将被视为只读的,尽管我们的目标是要写。现在,假如我们在同一时刻,COWed page被抛弃了通过另一个线程调用madvice(MADV_DONTNEED)
会怎样?当然什么灾难也不会发生。follow_page_mask
将仍然失败由于定位COWed page时发生缺页。但是下一次在faultin_page
发生的将非常有趣。因为这次foll_flags
并不包含FOLL_WRITE
,故不再创建一个dirty COW 页,handle_mm_fault
将简单地将该页从page cache中移除!为什么这么直接,因为万能的kernel只是在处理请求read 权限(切记,FOLL_WRITE已经被移除了),为什么要费尽创建页的另一个拷贝,如果kernel已经约定不再修改它。
faultin_page
返回不久之后,__get_user_pages
将做另一次retry,来获取它请求了多次的页。多亏follow_page_mask
在这次尝试中,最终返回给我们页。而且,它不再是普通的页,它是直接绑定特权文件的原始页。
Kernel帮助我们获得了打开特权城堡的钥匙。有这个页在手,通用的commonner non-root程序现在有能力修改root file了。
所有一切都是因为kernel在此撒谎了。在被告知dirty COW页已经ready之后的retry中,它只告诉了follow_page_mask
和handle_mm_fault
,只需要只读权限。这两个函数高兴的接受,最终返回一个当前任务最优的一个页。在这种情况下,它返回了一个如果我们修改它,它就将修改内容写回到原始特权文件的页。
在最终获得页之后,__get_user_pages
可以最终跳过faultin_page
调用,返回页给__access_remote_vm
来进行更多的处理。
该页怎样被修改?如下是access_remote_vm
的相关代码
上个代码片段中的page
将直接映射我们之前提到的页。内核首先映射kmap
这些页到内核地址空间中,之后调用copy_to_user_page
快速地将buf
中的用户数据写入到提到的页中,修改原始页的内容。
过一段时间以后,内核守护者线程(kflushd, bdflush, kupdated, pdflush线程等)会将被修改的页将被会写回到位于磁盘的特权文件中,这样就完成了整个攻击。
你可以会问,是听起来不错,但是发生的概率是多大?利用的话,有多少成功率?所有这些是在内核空间中吧?内核拥有权力来决定什么时候一个线程运行吧?
不幸的是,你可能已经猜到了。概率很大,Dirty COW甚至在一个单核的机器上,利用都相当稳定,归功于__get_user_ages
会显示请求任务机制来切换线程,通过调用cond_resched
。
以下是两个线程如何相互竞争的:
机敏的读者可能已经注意到了,如果我们直接访问一个基于文件的只读映射,一个段错误将会产生。但是,为什么我们使用wirte
写proc/self/mem
确返回了一个dirty COWed的页呢?
这个原因取决于当在一个进程内发生内存访问和当采用out-of-band(ptrace, /proc/{pid}/mem
内存访问时,内核如何处理页错误的情况。这两种情况最终都会调用handle_mm_fault
来处理页错误。但是后者使用faultin_page
来模拟页错误,页错误直接导致触发MMU,将直接进入中断处理器,之后所有的路径都进入到平台独立的内核处理函数__do_page_fault
中。而在直接写只读内存区域时,hanler将检测到访问违例在函数access_error
中,同时在handle_mm_fault
处理之前,直接触发信号SIGEGV
在函数bad_aea_access_error
中:
然而,faultin_page
会吝啬的处理访问违例,通过创建一个脏的 COWed页返回来使其合理合法(这毕竟是一个只读的,kernel不能如此轻松让你直接返回映射的页),相信kernel将会有一个完美的理由来violate这个访问,没有段错误。
为什么内核采用如此多步骤来提供这种Out-of-band的内存访问呢?为什么内核支持这种侵入式的访问,从一个进程来访问另一个进程的地址空间?
答案很简单,即使每个进程的地址空间是神圣的,私有性很强,等等。但是仍然需要调试器或别的侵入式的程序来有方法访问和获取一个进程的数据。这是一个了不起的实现,不然调试器从一个bug程序中如何设置断点和观察变量。
补丁非常短,整个diff如下:
这个补丁引入了一个新的标志FOLL_COW
对应访问语义。当发生VM_FAULT_WRITE
页错误时不再是简单地去掉FOLL_WRITE
,write的语义将保持原样。但是为了仍然允许break这个retry循环,当下一次retry时,使用新的标志来产生一个dirty COWed页。如果期待的COWed页不在,一个新的页将会被返回来处理原始的拷贝。
所以,不要再撒谎了,这次补丁合理的处理COWed page下一次retry,然而,老的版本只是简单的抛弃了write 标志,寄希望于COWed page能在下次retry仍然存在。
这个故事的寓意有两点:
——————————
原文链接: Dirty COW and why lying is bad even if you are the Linux kernel
本文章由看雪翻译小组 ghostway 翻译
——————————
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)