本文是Kernel Driver mmap Handler Exploitation的翻译文章。
(文章有点长,请善用目录)
在实施Linux内核驱动程序期间,开发人员会注册一个设备驱动程序文件,该文件通常会在/dev/directory中注册。该文件可能支持普通文件的所有常规功能包括:opening,reading,writing,mmaping,closing等。
设备驱动程序文件支持的操作在file_operations
结构中描述,其中包含许多函数指针,每个指针操作一个文件。内核4.9的结构定义可以在下面找到:
struct file_operations {
struct module *owner;
loff_t(*llseek) (struct file *, loff_t, int);
ssize_t(*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t(*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t(*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t(*write_iter) (struct kiocb *, struct iov_iter *);
int(*iterate) (struct file *, struct dir_context *);
int(*iterate_shared) (struct file *, struct dir_context *);
unsigned int(*poll) (struct file *, struct poll_table_struct *);
long(*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long(*compat_ioctl) (struct file *, unsigned int, unsigned long);
int(*mmap) (struct file *, struct vm_area_struct *);
int(*open) (struct inode *, struct file *);
int(*flush) (struct file *, fl_owner_t id);
int(*release) (struct inode *, struct file *);
int(*fsync) (struct file *, loff_t, loff_t, int datasync);
int(*fasync) (int, struct file *, int);
int(*lock) (struct file *, int, struct file_lock *);
ssize_t(*sendpage) (struct file *, struct page *, int, size_t, loff_t *,
int);
unsigned long(*get_unmapped_area)(struct file *, unsigned long, unsigned
long, unsigned long, unsigned long);
int(*check_flags)(int);
int(*flock) (struct file *, int, struct file_lock *);
ssize_t(*splice_write)(struct pipe_inode_info *, struct file *, loff_t *,
size_t, unsigned int);
ssize_t(*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
size_t, unsigned int);
int(*setlease)(struct file *, long, struct file_lock **, void **);
long(*fallocate)(struct file *file, int mode, loff_t offset,loff_t len);
void(*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned(*mmap_capabilities)(struct file *);
#endif
ssize_t(*copy_file_range)(struct file *, loff_t, struct file *, loff_t,
size_t, unsigned int);
int(*clone_file_range)(struct file *, loff_t, struct file *, loff_t,u64);
ssize_t(*dedupe_file_range)(struct file *, u64, u64, struct file *, u64);
};
如上所示,可以实现大量的文件操作,但为了本文的目的,我们将仅关注mmap处理程序的实现。
下面是一个file_operations
结构和相关函数的设置示例
(/fs/proc/softirqs.c
):
static int show_softirqs(struct seq_file *p, void *v)
{
int i, j;
seq_puts(p, " ");
for_each_possible_cpu(i)
seq_printf(p, "CPU%-8d", i);
seq_putc(p, '\n');
for (i = 0; i < NR_SOFTIRQS; i++) {
seq_printf(p, "%12s:", softirq_to_name[i]);
for_each_possible_cpu(j)
seq_printf(p, " %10u", kstat_softirqs_cpu(i, j));
seq_putc(p, '\n');
}
return 0;
}
static int softirqs_open(struct inode *inode, struct file *file)
{
return single_open(file, show_softirqs, NULL);
}
static const struct file_operations proc_softirqs_operations = {
.open = softirqs_open,
.read = seq_read,
.llseek = seq_lseek,
.release = single_release,
};
static int __init proc_softirqs_init(void)
{
proc_create("softirqs", 0, NULL, &proc_softirqs_operations);
return 0;
}
在上面的代码中可以看到,proc_softirqs_operations
结构将允许调用
'open','read','llseek'和'close'函数在其上执行。
当应用程序尝试打开时
一个softirqs
文件,然后open
系统调用将被调用,它指向的softirqs_open
函数
就会被执行。
如前所述,内核驱动程序可以实现它们自己的mmap处理程序。
mmap处理程序的主要目的是加快用户程序和内核空间之间的数据交换。 内核可能直接与用户地址空间共享一个内核缓冲区或一些物理范围的内存。 用户空间进程可以直接修改这个内存,而不需要额外的系统调用。
下面是一个简单的(并且不安全的)mmap处理程序的示例实现:
static struct file_operations fops =
{
.open = dev_open,
.mmap = simple_mmap,
.release = dev_release,
};
int size = 0x10000;
static int dev_open(struct inode *inodep, struct file *filep)
{
printk(KERN_INFO "MWR: Device has been opened\n");
filep->private_data = kzalloc(size, GFP_KERNEL);
if (filep->private_data == NULL)
return -1;
return 0;
}
static int simple_mmap(struct file *filp, struct vm_area_struct *vma)
{
printk(KERN_INFO "MWR: Device mmap\n");
if ( remap_pfn_range( vma, vma->vm_start, virt_to_pfn(filp->private_data),
vma->vm_end - vma->vm_start, vma->vm_page_prot )
)
{
printk(KERN_INFO "MWR: Device mmap failed\n");
return -EAGAIN;
}
printk(KERN_INFO "MWR: Device mmap OK\n");
return 0;
}
在上面列出的驱动程序打开期间,会调用dev_open
函数,它将简单地分配一个0x10000字节的缓冲区,并在private_data
字段中存储一个指针。 之后,如果该进程在该文件描述符上调用mmap,则将使用simple_mmap
函数来处理mmap调用。
该函数将简单地调用remap_pfn_range
函数,这个函数会在进程地址空间中创建一个新的映射,该映射将private_data
缓冲区链接到vma-> vm_start
地址,其大小定义为vma-> vm_end
- vma-> vm_start
。
在这个文件上请求mmap的示例用户空间程序如下:
int main(int argc, char * const * argv)
{
int fd = open("/dev/MWR_DEVICE", O_RDWR);
if (fd < 0)
{
printf("[-] Open failed!\n");
return -1;
}
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, 0x1000,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0x1000);
if (addr == MAP_FAILED)
{
perror("Failed to mmap: ");
close(fd);
return -1;
}
printf("mmap OK addr: %lx\n", addr);
close(fd);
return 0;
}
上面的代码在/dev/MWR_DEVICE
驱动文件上调用mmap,大小等于0x1000,文件偏移设置为0x1000,目标地址设置为'0x42424000'。 成功的映射结果如下:
# cat /proc/23058/maps
42424000-42425000 rw-s 00001000 00:06 68639 /dev/MWR_DEVICE
到目前为止,我们已经看到了mmap操作的最简单实现,但是如果我们的mmap处理函数只是一个空函数,会发生什么呢?
来看下面的实现:
static struct file_operations fops =
{
.open = dev_open,
.mmap = empty_mmap,
.release = dev_release,
labs.mwrinfosecurity.com| © MWR InfoSecurity 5
};
static int empty_mmap(struct file *filp, struct vm_area_struct *vma)
{
printk(KERN_INFO "MWR: empty_mmap\n");
return 0;
}
正如我们所看到的,只有日志功能被调用,以便我们可以观察到处理程序被调用。 当调用empty_mmap
函数时,假设没有任何事情会发生,mmap将会失败,因为没有调用remap_pfn_range
函数或类似的东西。 然而,这并不是事实。
让我们来运行我们的用户空间代码并检查发生了什么:
int fd = open("/dev/MWR_DEVICE", O_RDWR);
unsigned long size = 0x1000;
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0x1000);
在dmesg
日志中,可以看到我们的空处理程序已按照我们的预期成功调用:
[ 1119.393560] MWR: Device has been opened 1 time(s)
[ 1119.393574] MWR: empty_mmap
查看内存映射会看到一些意外的输出:
# cat /proc/2386/maps
42424000-42426000 rw-s 00001000 00:06 22305
我们还没有调用remap_pfn_range
函数,但映射的创建过程与之前的情况相同。 唯一的区别是这个映射是'无效'的,因为我们没有将任何物理内存映射到该地址范围。但是我们试图在该范围内访问内存,根据所使用的内核,这种mmap的实现会导致进程崩溃或整个内核崩溃。
来尝试使用以下代码访问该范围内的一些内存:
int fd = open("/dev/MWR_DEVICE", O_RDWR);
unsigned long size = 0x1000;
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0x1000);
printf("addr[0]: %x\n", addr[0]);
正如预期的那样,程序崩溃了:
./mwr_client
Bus error
然而,据观察,在某些3.10 arm/arm64 Android内核中,类似的代码导致内核恐慌。
总之,作为一名开发人员,您不应该认为空处理程序会表现出可预测的性能,请始终使用正确的返回代码来处理内核中的给定情况
在mmap操作期间,可以使用vm_operations_struct
结构为分配的内存区域上的多个其他操作(如处理未映射的内存,处理页面权限更改等)分配处理程序。
内核4.9的vm_operations_struct
结构(/include/linux/mm.h
)定义如下:
struct vm_operations_struct {
void(*open)(struct vm_area_struct * area);
void(*close)(struct vm_area_struct * area);
int(*mremap)(struct vm_area_struct * area);
int(*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
int(*pmd_fault)(struct vm_area_struct *, unsigned long address, pmd_t *,
unsigned int flags);
void(*map_pages)(struct fault_env *fe, pgoff_t start_pgoff, pgoff_t
end_pgoff);
int(*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
int(*pfn_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
int(*access)(struct vm_area_struct *vma, unsigned long addr, void *buf, int
len, int write);
const char *(*name)(struct vm_area_struct *vma);
#ifdef CONFIG_NUMA
int(*set_policy)(struct vm_area_struct *vma, struct mempolicy *new);
struct mempolicy *(*get_policy)(struct vm_area_struct *vma, unsigned long
addr);
#endif
struct page *(*find_special_page)(struct vm_area_struct *vma, unsigned long
addr);
};
如上所示,有许多函数指针可以实现自定义处理函数。 这些例子在Linux设备驱动程序手册中有详细描述。
常见的行为是开发人员在实现内存分配时实施“fault”处理程序。 比如,看下面的代码:
static struct file_operations fops =
{
.open = dev_open,
.mmap = simple_vma_ops_mmap,
.release = dev_release,
};
static struct vm_operations_struct simple_remap_vm_ops = {
.open = simple_vma_open,
.close = simple_vma_close,
.fault = simple_vma_fault,
};
static int simple_vma_ops_mmap(struct file *filp, struct vm_area_struct *vma)
{
printk(KERN_INFO "MWR: Device simple_vma_ops_mmap\n");
vma->vm_private_data = filp->private_data;
vma->vm_ops = &simple_remap_vm_ops;
simple_vma_open(vma);
printk(KERN_INFO "MWR: Device mmap OK\n");
return 0;
}
void simple_vma_open(struct vm_area_struct *vma)
{
printk(KERN_NOTICE "MWR: Simple VMA open, virt %lx, phys %lx\n",
vma->vm_start, vma->vm_pgoff << PAGE_SHIFT);
}
void simple_vma_close(struct vm_area_struct *vma)
{
printk(KERN_NOTICE "MWR: Simple VMA close.\n");
}
int simple_vma_fault(struct vm_area_struct *vma, struct vm_fault *vmf)
{
struct page *page = NULL;
unsigned long offset;
printk(KERN_NOTICE "MWR: simple_vma_fault\n");
offset = (((unsigned long)vmf->virtual_address - vma->vm_start) + (vma>vm_pgoff
<< PAGE_SHIFT));
if (offset > PAGE_SIZE << 4)
goto nopage_out;
page = virt_to_page(vma->vm_private_data + offset);
vmf->page = page;
get_page(page);
nopage_out:
return 0;
在上面的代码中,可以看到simple_vma_ops_mmap
函数用于处理mmap调用。 除了将一个simple_remap_vm_ops
结构赋值为一个虚拟内存操作处理程序外,它什么也不做。
让我们考虑使用上面提供的代码在驱动程序上运行以下代码:
int fd = open("/dev/MWR_DEVICE", O_RDWR);
unsigned long size = 0x1000;
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0x1000);
在dmesg中给出了以下输出:
[268819.067085] MWR: Device has been opened 2 time(s)
[268819.067121] MWR: Device simple_vma_ops_mmap
[268819.067123] MWR: Simple VMA open, virt 42424000, phys 1000
[268819.067125] MWR: Device mmap OK
映射进程地址空间:
42424000-42425000 rw-s 00001000 00:06 140215 /dev/MWR_DEVICE
我们可以看到,调用了simple_vma_ops_mmap
函数,并根据请求创建了内存映射。
在这个例子中,simple_vma_fault
函数没有被调用。 问题是,我们在地址范围'0x42424000'-'0x42425000'中有一个映射,但它指向哪里? 我们还没有定义这个地址范围指向物理内存的地方,所以如果进程试图访问'0x42424000'- '0x42425000'的任何一个部分,那么将会运行simple_vma_fault
错误处理程序。
那么让我们看下面的用户空间代码:
int fd = open("/dev/MWR_DEVICE", O_RDWR);
unsigned long size = 0x2000;
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0x1000);
printf("addr[0]: %x\n", addr[0]);
上面代码中唯一的变化是我们用printf
函数访问映射的内存。 由于内存位置无效,因此我们调用simple_vma_fault
处理程序,如下面的dmesg输出所示:
[285305.468520] MWR: Device has been opened 3 time(s)
[285305.468537] MWR: Device simple_vma_ops_mmap
[285305.468538] MWR: Simple VMA open, virt 42424000, phys 1000
[285305.468539] MWR: Device mmap OK
[285305.468546] MWR: simple_vma_fault
在simple_vma_fault
函数内部,我们可以观察到offset
变量是使用vmf>virtual_address
来计算的,该变量指向在内存访问期间未映射的地址。在我们的例子中,这是'addr [0]'的地址。
下一页结构是通过使用virt_to_page
宏来获得的,该宏导致新获得的页面被分配给vmf-> page
变量。 这个赋值意味着当错误处理程序返回时,'addr [0]'将指向由simple_vma_fault
函数计算出的一些物理内存。 这个内存可以被用户空间程序访问,而不需要任何额外的成本。
如果程序试图访问'addr [513]'(假设sizeof(无符号长整数)等于8),那么错误处理程序将被再次调用,因为'addr [0]'和'addr [513]'分别位于两个不同的 内存页面,并且只有一页内存已被映射。
因此下面的代码:
int fd = open("/dev/MWR_DEVICE", O_RDWR);
unsigned long size = 0x2000;
unsigned long * addr = (unsigned long *)mmap((void*)0x42424000, size, PROT_READ |
PROT_WRITE, MAP_SHARED, fd, 0x1000);
printf("addr[0]: %x\n", addr[0]);
printf("addr[513]: %x\n", addr[513])
将生成以下内核日志:
[286873.855849] MWR: Device has been opened 4 time(s)
[286873.855976] MWR: Device simple_vma_ops_mmap
[286873.855979] MWR: Simple VMA open, virt 42424000, phys 1000
[286873.855980] MWR: Device mmap OK
[286873.856046] MWR: simple_vma_fault
[286873.856110] MWR: simple_vma_fault
让我们来考虑一下前面的mmap处理程序示例:
static int simple_mmap(struct file *filp, struct vm_area_struct *vma)
{
printk(KERN_INFO "MWR: Device mmap\n");
if ( remap_pfn_range( vma, vma->vm_start, virt_to_pfn(filp->private_data),
vma->vm_end - vma->vm_start, vma->vm_page_prot ) )
{
printk(KERN_INFO "MWR: Device mmap failed\n");
return -EAGAIN;
}
printk(KERN_INFO "MWR: Device mmap OK\n");
return 0;
}
这个代码是实现mmap处理程序的常用方法,类似的代码可以在Linux设备驱动程序手册中找到。
这个示例代码的主要问题是,vma-> vm_end
和vma-> vm_start
的值从不验证,而是直接作为大小参数传递给remap_pfn_range
。 这意味着恶意进程可能会调用无限大小的mmap。
在我们的例子中,这将允许用户空间进程映射位于filp-> private_data
缓冲区之后的所有物理内存地址空间,包括所有的内核内存。 这意味着恶意进程将能够从用户空间读取/写入内核内存。
下面是另一个流行的用例:
static int simple_mmap(struct file *filp, struct vm_area_struct *vma)
{
printk(KERN_INFO "MWR: Device mmap\n");
if ( remap_pfn_range( vma, vma->vm_start, vma->vm_pgoff,
vma->vm_end - vma->vm_start, vma->vm_page_prot ) )
{
printk(KERN_INFO "MWR: Device mmap failed\n");
return -EAGAIN;
}
printk(KERN_INFO "MWR: Device mmap OK\n");
return 0;
}
在上面的代码中,我们可以看到用户控制的偏移vma-> vm_pgoff
作为物理地址直接传递给remap_pfn_range
函数。
这会导致恶意进程能够将任意物理地址传递给mmap,从而允许从用户空间访问所有内核内存。 这种情况经常会发生轻微的修改,例如在偏移被屏蔽或使用另一个值计算的情况下。
经常看到,开发人员将尝试使用复杂的计算,位掩码,位移,大小和偏移之和等来验证映射的大小和偏移量。然而不幸的是,这往往会创建一些复杂且不寻常的计算和验证程序,导致这些程序难以阅读。经过少量的size和offset的模糊处理后,可以找到绕过这些验证检查的值。
我们来看下面的代码:
static int integer_overflow_mmap(struct file *filp, struct vm_area_struct *vma)
{
unsigned int vma_size = vma->vm_end - vma->vm_start;
unsigned int offset = vma->vm_pgoff << PAGE_SHIFT;
printk(KERN_INFO "MWR: Device integer_overflow_mmap( vma_size: %x, offset:
%x)\n", vma_size, offs