译自exploit-database-papers中收录的kernel-driver-mmap-handler-exploitation,详见原文。鉴于译者水平有限,不免有错误存在,如是,望读者斧正。本文同步在我的博客.
在实现Linux内核驱动中,开发者可以注册一个设备驱动文件,该文件常常在/dev/目录下完成注册。该文件可以支持所有的常规文件方法,比如opening, reading, writing, mmaping, closing等等。设备驱动文件支持的操作由包含了一组函数指针的结构体file_operations
描述,每个指针描述一个操作。在4.9版本内核中可以找到如下的定义。
如同上面展示,可以实现非常多的文件操作,本文的主角是mmap handler的实现。
file_operations
结构体的安装示例以及相关联的函数可以在下面看到('/fs/proc/softirqs.c'):
上述代码可以在'proc_softirqs_operations'结构体中看到,它允许调用open, read, llseek和close函数。当一个应用程序试图去打开一个'softirqs'文件时就会调用'open'系统调用,进而会调用到指向的'softirqs_open'函数。
如上文提及,内核驱动可以实现自己的mmap handler。主要目的在于mmap handler可以加速用户空间进程和内核空间的数据交换。内核可以共享一块内核buffer或者直接共享某些物理内存地址范围给用户空间。用户空间进程可以直接修改这块内存而无需调用额外的系统调用。
一个简单(并且不安全)的mmap handler实现例子如下:
当打开上面的驱动时,dev_open
会被调用,它简单的分配0x10000字节的buffer并且将其保存在private_data
指针域。此后如果进程在对该文件描述符调用mmap时,就会调用到simple_mmap
。该函数简单的调用remap_pfn_range
函数来创建一个进程地址空间的新映射,将private_data
指向的buffer和vma->vm_start
开始的尺寸为vma->vm_end-vma->vm_start
大小的地址空间关联起来。
一个请求对应文件mmap的用户空间程序样例:
上面的代码对/dev/MWR_DEVICE
驱动文件调用了mmap,大小为0x1000,文件偏移设置为0x1000,目标地址设置为0x42424000。可以看到一个成功的映射结果:
到目前为止,我们已经见过了最简单的mmap操作的实现体,但是如果mmap handler是个空函数的话,会发生什么?
让我们考虑这个实现:
如我们所见,函数中只有log信息,这是为了让我们观察到函数被调用了。当empty_mmap
被调用时,我们毫不夸张的可以猜测到什么都不会发生,mmap会引发失败,毕竟此时并没有remap_pfn_range
或其他类似的函数。然而,事实并非如此。让我们运行一下用户空间代码,看看究竟会发生什么:
在dmesg log中我们可以看到空的handler被成功调用:
看看内存映射,有没有什么异常:
我们并没有调用remap_pfn_range
函数,然而映射却如同此前情景那样被创建了。唯一的不同在于映射是无效的,因为我们实际上并没有映射任何的物理内存给这块虚拟地址。这样的一个mmap实现中,一旦访问了映射的地址空间,要么引起进程崩溃,要么引起整个内核的崩溃,这取决于具体使用的内核。
让我们试试访问这块内存:
如我们所愿,进程崩溃了:
然而在某些3.10 arm/arm64 Android内核中,类似的代码会引起kernel panic。
所以说,作为一个开发者,你不应该假定一个空的handler可以按预期表现,在内核中始终使用一个可用的返回码来控制给定的情形。
在mmap操作中,有办法在已分配内存区间上使用vm_operations_struct
结构体来指派多种其他操作的handler(例如控制unmapped memory, page permission changes等)。
vm_operations_struct
在kernel 4.9中的定义如下:
如上文描述,这些函数指针可以用于实现特定的handler。关于此的详细描述在《Linux Device Drivers(Linux设备驱动)》一书中可以找到。
在实现内存分配器时,一个通俗可见的主流的行为是开发者实现了一个'fault' handler。例如,看看这一段:
上述代码中我们可以看到simple_vma_ops_mmap
函数用于控制mmap调用。它什么都没做,除了指派了simple_remap_vm_ops
结构体作为虚拟内存操作的handler。
让我们看看下列代码在该driver上运行的效果:
dmesg的结果:
进程地址空间的映射:
如我们所见,simple_vma_ops_mmap
函数被调用了,内存映射也创建了。例子中simple_vma_fault
函数没有被调用。问题在于,我们有了个地址范围为0x42424000-0x42425000的地址空间却不清楚它指向何处。我们没有为它关联物理内存,因此当进程试图访问这段地址的任一部分时,simple_vma_fault
都会执行。
所以让我们看看这段用户空间代码:
代码唯一的改变在于使用了printf函数来访问映射内存。因为内存区域无效所以我们的simple_vma_fault
例程被调用了。dmesg的输出可以看到:
在simple_vma_fault
函数中,我们可以观察到offset
变量使用了指向一个没有被映射的地址的vmf->virtual_address
进行了计算。我们这里就是addr[0]
的地址。下一个page结构体由virt_to_page
宏得到,该宏将新获取的page赋值给vmf->page
变量。这一赋值意味着当fault handler返回时,addr[0]
会指向由simple_vma_fault
计算出来的某个物理地址。该内存可以被用户进程所访问而无需其他任何代码。如果程序试图访问addr[513]
(假定sizeof(unsigned long)为8),fault handler会被再次调用,这是由于addr[0]
和addr[513]
在两个不同的内存页上,而此前仅有一个内存页被映射过。
这就是源码:
生成内核log:
让我们看看前面的mmap handler例子:
前面展示的代码展示了一个通用的实现mmap handler的途径,相似的代码可以在《Linux设备驱动》一书中找到。示例代码主要的议论点在于vma->vm_end
和vma->vm_start
的值从未检查有效性。取而代之的,它们被直接传递给remap_pfn_range
作为尺寸参数。这意味着一个恶意进程可以用一个不受限的尺寸来调用mmap。在我们这里,允许一个用户空间进程去mmap所有的在filp->private_data
buffer之后的物理内存地址空间。这包括所有的内核内存。这意味着恶意进程能够从用户空间读写整个内核内存。
另一个流行的用法如下:
上面的代码中我们可以看到用户控制的offset vma->vm_pgoff
被直接传递给了remap_pfn_range
函数作为物理地址。这会使得恶意进程有能力传递一个任意物理地址给mmap,也就在用户空间拥有了整个内核内存的访问权限。在一些对示例进行微小改动的情景中经常可以看到,要么offset有了掩码,要么使用了另外一个值来计算。
经常可以看到开发者试图使用复杂的计算、按位掩码、位移、尺寸和偏移和等方法去验证映射的尺寸和偏移(size and offset)。不幸的是,这常常导致了创建的复杂性以及不寻常的计算和验证过程晦涩难懂。在对size和offset值进行少量fuzzing后,找到可以绕过有效性检查的值并非不可能。
让我们看看这段代码:
上面的代码展示了一个典型的整数溢出漏洞,它发生在一个进程调用mmap2系统调用时使用了size为0xfffa000以及offset为0xf0006的情况。0xfffa000和0xf0006000的和等于0x100000000。由于最大的unsigned int值为0xffffffff,最高符号位会被清掉,最终的和会变成0x0。这种情况下,mmap系统调用会成功绕过检查,进程会访问到预期buffer外的内存。如上文提到的,有两个独立的系统调用mmap
和mmap2
。mmap2
使得应用程序可以使用一个32位的off_t
类型来映射大文件(最大为2^44字节),这是通过支持使用一个大数offset参数实现的。有趣的是mmap2
系统调用通常在64位内核系统调用表中不可用。然而,如果操作系统同时支持32位和64位进程,他就通常在32位进程中可用。这是因为32位和64位进程各使用独立的系统调用表。
另一个老生常谈的议题就是size变量的有符号类型。让我们看看这段代码:
上述代码中,用户控制数据存储在vma_size
和offset
变量中,它们都是有符号整型。size和offset检查是这一行:
不幸的是,因为vma_size
被声明为有符号整型数,一种攻击手法是通过使用负数诸如0xf0000000来绕过这个检查。这回引起0xf0000000字节被映射到用户空间地址。
到此我们理解了如何去实现一个可以获取任意内存地址(通常是内核内存)访问权的mmap handler。现在的问题是:我们如何用现有的知识来获取root权限?我们考虑两种基本情景:
当我们了解了物理内存布局后,我们可以轻易地查看我们映射了内存的那个区域,也可以试图去把想要的内存区域与虚拟地址进行关联。这允许我们对信令(creds)/函数指针执行精准的覆写。
更有意思的在于完成黑盒模型的情景。它可以工作在多版本内核和CPU架构,且一旦写成了exploit,它对不同的驱动来说都会更为的可靠。为了写这样的exp,我么需要找出内存中的一些pattern,这些pattern可以直接告诉我们找到的东西是否有用。当我们开始考虑我们可以搜索到什么时,我们就迅速的找到了实现方法:“有一些我们可以搜索的明显pattern,至少16字节,既然是全部内存我们应该可以几乎找到任何东西”。如果我们看一下credential结构体(struct cred)的话,就可以看到一些有意思的数据:
cred结构体用于控制我们线程的信令。这意味着我们可以掌握此结构体的大部分值,可以通过简单的读/proc/<pid>/status
或者通过系统调用获取。
查看结构体定义可以观察到有8个连续的整型变量,我们对此很熟悉(uid,gid,suid,sgid等)。紧随其后是一个4字节的securebits变量,再后面是4或5个(实际数量取决于内核版本)long long int(cap_inheritable等)。
我们获取root权限的计划是:
在某些情况下,这一方案不奏效,例如:
在安全模块的情况下,cred
结构体的security
域拥有一个指向内核使用的特殊安全模块定义的结构体。例如,对SELinux来说他是指向一个包含下列结构体的内存区域:
我们可以替换security
域的指针为一个我们已经控制的地址(如果给定架构(如arm, aarch64)允许我们在内核中直接访问用户空间映射的话,我们可以提供用户空间映射),然后brute force sid值。进程应该相对快速因为大部分权限标签例如内核或初始化时会将该值设置为0到512之间。
为了绕过SELinux我们需要尝试下列步骤:
这一部分我们将会尝试开发一个完整root权限的exp,针对下面的代码:
代码有2个漏洞:
我们exp开发的第一步就是,创建触发漏洞的代码,使用它创建一个非常大的内存映射:
上面的代码会打开有漏洞的驱动并且调用mmap,传递了0xf0000000字节作为size,0作为offset。下面我们会看到log中记载了我们的调用成功了:
我们可以通过查看内存映射来验证:
与此同时,dmesg中也可以看到mmap成功了:
如果我们检查物理地址空间,我们可以看到有了这个映射后我们可以访问下面00000000-e0ffffff间的所有地址。这是因为我们传递了0作为物理地址定位、0xf0000000作为字节数:
我们可以选择增大映射的尺寸来涵盖所有的物理地址空间。然而,我们这里不会如此做,这样一来我们可以展示一些当我们没有能力访问全部系统内存时所面对的限制。
下一步去实现在内存中搜索cred
结构体。我们按4.1节中所说的进行操作。我们会轻量的修改进程因为我们仅仅需要搜索8个包含我们的uid值的整型数。一个简单的实现看起来如下:
在我们的exp输出中,可以看到找到了一些潜在的cred
结构体:
下一步是去找到哪个cred
结构体属于我们的进程,修改它的uid/gid:
我们运行exp可以看到:
可以看到我们成功get root权限,检查一下这是否是真的:
我们可以看到我们的UIDs和GIDs都已经从1000改成了0,我们的exp有效果,现在我们几乎就是一个root用户。
如果我们多次运行exp就可以发现,并不是总是能够获取root。成功率几乎是4/5,也就是80%左右。我们前面提到了我们仅仅映射了部分的物理地址。exp失败的原因在于,20%的情况下我们没能扫描整个内核内存(最后100000000-11fffffff也是system RAM,结构分配到了这里):
再次查看物理内存布局就会看到System RAM区域超出了我们映射的可控的范围。经常会有这种情况,我们在面对mmap handler输入检查时值是受限的。例如,我们可能有能力mmap 1GB内存但是却不能控制这以外的物理地址。可以使用一个cred
喷射来轻易解决这个问题。我们创建100-1000个子进程,每一个都会检查是否有权限变更。一旦一个子进程获取了root权限就会通知父进程并终止循环扫描。剩下的提权步骤由这个单一子进程完成即可。
我们忽略cred
喷射的修改以保持exp代码的整洁,取而代之的,这作为给读者的一个挑战。我们强烈推荐你实现一个cred
喷射作为实践并看看这多么的简单有效。
到此,让我们回头去完成exp代码:
上面的代码会覆盖5个capabilities变量并且开启一个交互式的shell。下面是exp的结果:
本例子我们将利用mmap的fault handler。既然我们已经知道了如何利用有漏洞的mmap handler去获取root权限,我们将焦点转移到信息泄露。
这一次我们的驱动只读:
使用下面的代码:
拥有一个只读的驱动意味着我们没有能力去映射可写的内存,我们仅仅能读而已。
我们以分析驱动代码开始,可以看到驱动的open操作,函数为dev_open
,它简单的分配了0x1000字节的缓冲区。在simple_vma_ops_mmap
中mmap handler可以看到没有任何的安检,一个虚拟内存操作结构体被指派给了需要的内存区域。在该结构体中我们可以找到simple_vma_fault
这个fault handler的实现。
simple_vma_fault
函数一开始计算了内存页的偏移,此后,它通过此前额外分配的缓冲区(vma->vm_private_data
)以及offset变量来找到内存页。最后,找到的内存页被指派给了vmf->page
域。这会引起在错误发生时,该page会被映射到虚拟地址。
然而,在页返回之前,有一个安检:
上面的检查会查看fault触发时,是否会返回一个超过0x10000的地址,如果是的话,就会禁止对该页的访问。
如果我们检查驱动buffer的size的话,就会看到这个值是小于0x10000的,该值实际上是前面分配的0x1000字节:
这就允许一个恶意进程去请求驱动buffer后面的0x9000个字节,泄露内核内存地址。
让我们使用下面的代码来完成驱动的exp:
代码看起来和标准的驱动使用方法很像。我们先打开一个设备,映射0x10000字节内存并转储该映射内存(hexDump
函数打印十六进制表示的缓冲区到stdout)。
让我们看看exp的输出:
在输出中可以看到,0x2000偏移有一些数据。驱动缓冲区在0x1000处截止所以读超出这个buffer就意味着我们可以成功的泄露内核内存。
更进一步,我们可以看到dmesg的输出中,我们已经成功访问到了不止一页的内存:
让我们假定开发者引入了前面代码中simple_vma_ops_mmap
函数的一个修改。如下面所见,新的代码检查了映射的尺寸是否小于0x1000。理论上,这会阻止前面的exp生效。
然而,代码依然是可以利用的,尽管我们不能再利用mmap创建一个非常大的映射内存。我们可以分割映射进程成两步:
这意味着一开始我们创建一个小的0x1000字节的映射,它会顺利的通过安检。此后我们利用mremap增大尺寸。最终,我们可以像此前那样转储内存:
我们的exp输出如下。有一次看到了转储的内存内容中包含了本不该独到的内容:
通常当分析mmap handler时,我们可以找到一大堆位掩码、位移以及算术操作。这些操作可以使得错过具体的魔数更为容易,这允许一个攻击者绕过输入安检并获取到预料之外的具体内存区域访问权限。有两个值需要我们去挖掘;映射的offset和size。仅有两个值需要挖掘意味着我们可以挖掘该驱动相对快一点,允许我们尝试一个范围的数,确保我们彻底的测试所有可能的边缘情况。
本文中我们描述了使用remap_pfn_range
函数以及它的fault handler来创建内存映射。然而,这并不是唯一的可以被本方式利用的函数,有一大堆其他的函数在滥用的情况下也会导致内存区域的任意修改。你无法仅通过一个单一函数的使用而保证某个驱动是安全的。其他潜在的有意思的函数可能是:
不同内核版本中,函数列表不完全一致。
本文中我们描述了设备驱动在实现mmap handler时的一种漏洞。然而,几乎任何的子系统都实现了一个自定义的mmap handler。proc, sysfs, debugfs, 自定义文件系统, sockets以及任何提供了文件描述符的子系统,它们都可能实现了一个有漏洞的mmap handler。
此外,remap_pfn_range
可能被任何系统调用所调用,不只是mmap
。你也可以在ioctl的handlers中找到该函数。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)