影响Android多个高权限服务的严重漏洞详情披露(CVE-2018-9411)
lucywang 嘶吼专业版
0x00 前言
媒体框架是安卓系统组件中经常被发现安全漏洞的组件,所以每次谷歌发布月度例行更新时经常会有它的身影。Google最近发现的媒体框架的漏洞是远程代码执行漏洞,攻击者可以制作特定的文件利用特权进程执行任意代码。目前Google已将其命名为CVE-2018-9411,危险等级定位危急,并在7月安全更新(2018-07-01补丁)中对其进行了修补,包括9月安全更新(2018-09-01补丁)中的一些附加补丁。
我还为此漏洞编写了一个概念验证利用,演示了如何使用它来从常规非特权应用程序的上下文中提升权限。
本文,我将介绍该漏洞和利用此漏洞的技术细节。首先我将介绍与漏洞相关的一些背景信息,然后再详细介绍漏洞本身。在介绍如何利用此漏洞的过程中,我将选择一个特定服务作为攻击目标,而不是受漏洞影响的其他服务。另外,我还将分析与漏洞相关的一些服务。最后,我将介绍我编写的概念验证漏洞利用的详细信息。
Project Treble
什么是Project Treble?简单的说就是谷歌为了整理安卓的碎片化,为了让手机厂商适配安卓版本更轻松,推出的新架构。
Project Treble对Android内部运作方式进行了大量更改,其中的一个巨大的变化是许多系统服务的分离。以前,Android服务包含AOSP(Android开源项目)和供应商代码。在Project Treble出现之后,这些服务会被分为一个AOSP服务和一个或多个供应商服务,称为HAL服务。更多背景信息,请点此。
HIDL
Project Treble的服务的分离增加了IPC(进程间通信)的量,以前在AOSP和供应商代码之间的同一进程中传递的数据,现在必须通过AOSP和HAL服务之间通过IPC。由于Android中的大多数IPC都要经过Binder,谷歌决定新的IPC也应该这样做。
但仅仅使用现有的Binder代码是满足不了新的IPC的,Google决定对其进行一些修改。首先,Google引入了多个Binder域,以便将这种新型IPC与其他域分开。更重要的是,他们引入了HIDL,这是一种通过Binder IPC传递的数据的全新格式。这种新格式由一组新的库支持,专用于AOSP和HAL服务之间的IPC新Binder域,其他Binder域仍使用旧格式。
与旧的HIDL格式相比,新HIDL格式的操作有点像层,新旧两种情况下的底层都是Binder内核驱动程序,但顶层是不同的。对于HAL和AOSP服务之间的通信,使用新的库;对于其他类型的通信,使用旧的库。这两种库包含的代码都非常相似,以至于新的HIDL库中某些原始代码会直接从旧库中复制到。虽然每个库的用法并不完全相同(你不能简单地用一个替换另一个),但它们仍然非常相似。
这两组库都以c++对象的形式表示Binder事务中传输的数据,从相对简单的对象(比如表示字符串的对象)到更复杂的实现(比如文件描述符或对其他服务的引用),这意味着HIDL为许多类型的对象引入了新的实现方式。
共享内存
Binder IPC的一个重要功能就是可以共享内存,为了保持简单性和良好性能,Binder将每个事务限制为最大1MB。对于进程希望通过Binder在彼此之间共享大量数据的情况,使用共享内存。
为了通过Binder共享内存,进程利用Binder的共享文件描述符的功能。使用mmap可以将文件描述符映射到内存,这允许多个进程通过共享一个文件描述符来共享同一个内存区域,常规Linux(非Android)的一个问题是,文件描述符通常由文件支持,如果进程想要共享匿名内存区域怎么办?出于这个原因,Android采用了Ashmem匿名共享内存机制,它允许进程在没有涉及实际文件的情况下分配内存,来备份文件描述符。
是否是通过Binder共享内存处理对象,是HIDL和旧库之间的一个区别。在这两种情况下,最终操作都是相同的,一个进程将ashmem文件描述符映射到其内存空间,通过Binder将该文件描述符传输到另一个进程,而另一个进程将其映射到自己的内存空间。不过,在处理对象的实现方式上是不同的。
在HIDL的情况下,共享内存的一个重要对象是hidl_memory,如源代码中所述:“hidl_memory是一种结构,可以用于在进程之间传输共享内存”。
漏洞介绍
让我们来看看hidl_memory的组成内容:
其中mHandle是一个句柄,它是一个HIDL对象,它包含文件描述符(在本文所举的样本中只有一个文件描述符)。mSize 表示要共享的内存大小,mName应该代表内存的类型,但是只有ashmem类型与此相关。
当通过HIDL中的Binder传输这样的结构时,复杂对象(比如hidl_handle或hidl_string)有自己的用于写入和读取数据的自定义代码,而简单类型(比如整数)则没有自定义代码。这意味着代码大小会被转换为64位整数,而在旧的库中,则使用32位整数。
这看起来很奇怪,为什么内存的大小应该是64位?为什么不像旧的库那样,用32位进程处理这个问题呢?让我们看一下映射hidl_memory对象(用于ashmem类型)的代码:
其中,没有任何关于32位进程的内容,甚至没有提到64位进程。
那其中到底发生了什么?mmap签名中的length字段的类型是size_t,这意味着它的位数与进程的位数相匹配。在64位进程中没有问题,一切都只是64位。另一方面,在32位进程中,大小被截断为32位,因此仅使用较低的32位。
这意味着,如果32位进程接收到大小大于UINT32_MAX(0xFFFFFFFF)的hidl_memory,则实际的映射内存区域将不够用。例如,对于大小为0x100001000的hidl_memory,内存区域的大小将仅为0x1000。在这种情况下,如果32位进程是基于hidl_memory大小执行边界检查,它们将会失败,因为它们将错误地表明内存区域跨越的范围超过整个内存空间,这就是漏洞。
寻找攻击目标
现在我们试着找到一个攻击目标,寻找符合以下标准的HAL服务:
1.编译为32位;
2.把对共享内存的接收作为输入;
3.在共享内存上执行边界检查时,不会截断大小。例如,以下代码不容易受到攻击,因为它对截断的size_t执行边界检查:
以上都是此漏洞的基本要求,但我认为还有一些更重要的要求:
4.在AOSP中有默认实现,虽然供应商最终会负责所有HAL服务,但AOSP确实包含某些供应商可以使用的默认实现。我发现在许多情况下,当存在这样的实现时,供应商不愿意修改它,只是按原样使用它。这使得这样的目标更有趣,因为它可能与多个供应商相关,而不是特定于某个供应商的服务。
你应该注意的一件事是,尽管HAL服务应该只能由其他系统服务访问,但事实并非如此。有一些精选的HAL服务实际上可以由常规的非特权应用程序访问。因此,最后一个要求是:
5.可以从无特权的应用程序直接访问,否则漏洞利用将实现不了,下面我们将讨论的一个漏洞,只有在你已经破坏了另一个服务的情况下才能访问它。
幸运的是,我找到了一个满足所有这些要求的HAL服务:android.hardware.cas,又称为MediaCasService。
CAS
CAS代表条件访问系统,简单来说它与DRM类似。简单地说,它的功能与DRM相同,有需要解密的加密数据。
MediaCasService
首先,MediaCasService确实允许应用程序解密加密数据。如果你阅读我以前的文章,就会知道我是如何利用名为MediaDrmServer的服务中的漏洞。你可能会奇怪,我为什么要与DRM进行比较?因为MediaCasService与MediaDrmServer(负责解密DRM媒体的服务)从其API到内部运行方式都非常相似。
需要注意的是,MediaDrmServer这个API被称为descramble,而不是decrypt(尽管它们最终也会在内部对其进行解密)。
让我们看看descramble是如何运作的:
不出所料,数据通过共享内存共享,有一个缓冲区指示共享内存的相关部分(称为srcBuffer,但是对于源和目标都是相关的)。在此缓冲区上,服务从其中读取源数据以及将目标数据写入的位置都存在偏移量。此时,源数据不是加密的,在这种情况下,服务只需将数据从源复制到目标,而无需修改它。
这看起来很脆弱,至少,如果服务仅使用hidl_memory大小来验证它是否完全适合共享内存,而不是其他参数,则会如此。在这种情况下,通过让服务相信我们的小内存区域跨越了它的整个内存空间,我们就可以绕过边界检查,并将源和目标偏移量放在我们喜欢的任何地方。这将使我们能够对服务内存进行完整的读写访问,因为我们可以从任何地方读取到共享内存,从共享内存写入任何地方。注意,负偏移量也应利用此漏洞,因为即使是0xFFFFFFFF(-1)也会小于hidl_memory大小。
让我们通过查看descramble的代码来验证这一点,请注意,函数validateRangeForSize只检查“first_param + second_param <= third_param”,而忽略可能的溢出。
可以看到,代码根据hidl_memory大小检查srcBuffer是否位于共享内存中。在此之后,不再使用hidl_memory,其余的检查将针对srcBuffer本身执行。至此,为了实现完整的读写访问,我们需要做的就是使用这个漏洞,然后将srcBuffer的大小设置为大于0xFFFFFFFF。这样,源和目标偏移量的任何值都是有效的。
使用漏洞进行越界读取
使用漏洞进行越界写入
TEE设备
在使用这个原语编写漏洞之前,让我们先想好这个漏洞要实现的目标。查看此服务的SELinux规则,就可以看到它实际上受到严格限制,并且没有很多权限。不过,它还有一个普通的非特权应用程序没有的有趣权限,就是对TEE(可信执行环境)设备的访问。
此权限非常有趣,因为它允许攻击者访问各种各样的内容,比如不同供应商的不同设备驱动程序、不同的信任区域操作系统和大量信任。在我之前的文章中,我已经讨论过这个权限有多危险了。
虽然访问TEE设备确实可以验证很多事情,但我只想证明我可以获得此访问权限。因此,我的目标是执行一个需要访问TEE设备的简单操作。在Qualcomm TEE设备驱动程序中,有一个相当简单的ioctl,用于查询设备上运行的QSEOS版本。因此,构建MediaCasService漏洞时的目标是运行此ioctl并获取其结果。
漏洞利用
到目前为止,我们对目标进程内存进行了完全读取和写入。虽然这是一个很好的开始,但有两个问题需要解决:
1.ASLR:虽然我们有完全的读访问权限,但它只与共享内存映射的位置相关。我们并不知道它与内存中的哪些数据进行比较。理想情况下,我们希望找到共享内存的地址以及其他有趣数据的地址。
2.漏洞在每次执行时,共享内存都会被映射,然后在操作后取消映射。不能保证每次都将共享内存映射到同一个位置,在执行期间完全有可能会有另一个内存区域取代原来的映射位置。
让我们看一下这个特定构建的服务内存空间中链接器的一些内存映射:
如上说示,链接器恰好在linker_alloc_small_objects和linker_alloc之间创建了2个内存页(0x2000)的小差距。这些存储器映射的地址相对较高,此进程加载的所有库都映射到较低的地址。这意味着这个差距是内存中最高的差距。由于mmap的行为是尝试将低地址映射到高地址,因此任何映射2页或更少内存区域的尝试都应映射到此差距中。幸运的是,该服务通常不会映射这么小的内容,这意味着这个差距应该留在那里。这就解决了我们的第二个问题,因为这是内存中的确定性位置,我们的共享内存将始终映射在这个位置。
让我们直接查看差距之后的linker_alloc中的数据:
这里的链接器数据恰好对我们有用,它包含的地址可以很容易的指示linker_alloc内存区域的地址。由于漏洞提供了相对读取,并且我们已经得出结论,共享内存将在linker_alloc之前被直接映射,因此我们可以使用它来确定共享内存的地址。如果我们取偏移量为0x40的地址并将其减少0x10,就将得到linker_alloc地址,减少共享内存本身的大小将导致共享内存地址。
到目前为止,我们解决了第二个问题,但第一个问题只是部分解决了。虽然我们确实有共享内存的地址,但没有其他有趣数据的地址,我们感兴趣的其他数据还有哪些呢?
劫持一个线程
MediaCasService API的一部分功能是客户端为事件提供监测的能力,如果客户端提供侦听器,则会在发生不同CAS事件时通知它。客户端也可以自己触发事件,然后将其发送回侦听器。Binder和HIDL的工作方式是,当服务向侦听器发送事件时,它将等待侦听器完成对事件的处理,等待侦听器的线程将被阻塞。
触发事件的流程
此时,我们可以在已知的预定线程中阻止服务中的线程发生阻塞。一旦我们有一个处于这种状态的线程,就可以修改它的堆栈来劫持它,只有在我们完成后,才能通过完成处理事件来恢复线程。不过,我们如何在内存中找到线程堆栈?
由于我们的确定性共享内存地址很高,该地址与阻塞线程堆栈的可能位置之间的距离很大。由于ASLR的影响,试图从确定性地址相对地查找线程堆栈太不可靠,所以我们使用了另一种方法,即尝试使用更大的共享内存,并在阻塞的线程堆栈之前映射它,这样我们就能够通过漏洞访问它。
此时,我们得到多个(5)线程,而不是只有一个线程处于阻塞状态。这会导致创建更多线程,并分配更多线程堆栈。通过执行此操作,如果内存中存在少量线程堆栈大小的空白,则应填充它们,并且阻塞线程中的至少一个线程堆栈应映射到低地址,而不在其之前映射到任何库。 mmap是在低地址之前映射高地址的区域,然后,理想情况下,如果我们使用大型共享内存,则应在此之前进行映射。
填充差距并映射共享内存后的MediaCasService内存映射
不过缺点是,有可能其他意想不到的内容(比如jemalloc heap)可能会被映射到其中,因此被阻塞的线程堆栈将不会是我们期望的。可能有多种方法可以解决这个问题。我决定简单地利用服务崩溃(使用漏洞来写入未映射的地址)再试一次,因为每次服务崩溃时它都会重新启动。无论如何,这种情况通常不会发生,即使发生了,一次重试通常就足够了。
一旦我们的共享内存在被阻塞的线程堆栈之前被映射,我们就可以使用该漏洞从线程堆栈中读取两种地址:
1.线程堆栈地址,使用pthread元数据,它位于堆栈本身之后的同一内存区域中。
2.libc映射到的地址,以便稍后使用libc中的gadget 框架和符号构建ROP链(libc具有足够的gadget 框架)。我们通过读取libc中特定位置的返回地址来实现这一点,libc位于线程堆栈中。
从线程堆栈读取的数据
至此,我们就可以使用漏洞读取和写入线程堆栈。由于我们既有确定性共享内存位置的地址,也有线程堆栈的地址,因此通过使用地址之间的差异,我们可以从共享内存(具有确定性位置的小内存)到达线程堆栈。
ROP链
我们可以完全访问我们可以恢复的被阻塞的线程堆栈,因此下一步是执行ROP链。由于我们要准确知道ROP链覆盖堆栈的哪个部分,因此必须时刻关注线程被阻塞的确切状态。覆盖部分堆栈后,我们可以恢复线程,从而执行ROP链。
遗憾的是,SELinux对此过程的限制使我们无法将此ROP链完全转换为任意代码来执行。没有execmem权限,因此无法将匿名内存映射为可执行文件,并且我们无法控制可以映射为可执行文件的文件类型。在本文的示例中,目标非常简单(运行单个ioctl),所以我只是编写了一个ROP链来执行此操作。从理论上讲,如果你想要执行更复杂的操作,那仍然可以利用这个原语。例如,如果你想根据函数的结果执行复杂的逻辑,你可以执行多阶段ROP:执行一个运行该函数的ROP链并将其结果写入某处,读取结果,执行复杂的逻辑,然后基于此运行另一个ROP链。
如上所述,由于目标是获得QSEOS版本,下面是ROP链执行的代码。
stack_addr是堆栈内存区域的地址,它只是一个我们知道的可写的地址,不会被覆盖(堆栈从底部开始,不靠近顶部),所以我们可以将结果写入该地址然后使用此漏洞读取它。在最后的休眠时,线程不会在运行ROP链后立即崩溃,所以我们可以读取结果。
构建ROP链本身非常简单, libc中有足够的gadget来执行它,所有的符号也都在libc中,且我们已经拥有了libc的地址。
完成漏洞利用后,我们就完成了劫持一个线程来执行ROP链,因此进程处于一个不稳定的状态。为了使所有内容都处于不被感染的状态,我们只是使用漏洞(通过写入未映射的地址)使服务崩溃,以便让它重新启动。
总结
正如我之前的文章所讲的那样,虽然谷歌宣称Project Treble有利于Android的安全性,但我们在本文中所找到的这个漏洞,就可以说明Project Treble并不是无懈可击的。这个漏洞本身就是Project Treble的一个组成部分,且它不存在于以前的源代码库中,仅仅出现在新库中。由于这个漏洞会出现在一个常用的库中,因此它影响了许多高权限服务。
GitHub上提供了完整的漏洞利用代码,注意:本文所讲的漏洞仅用于教育或防御目的,它不适用于任何恶意或攻击性用途。
漏洞发现的时间脉络
2018.5.3:发现漏洞;
2018.5.7:我们将漏洞详情及 PoC反馈给Google;
2018.7.2:Google发布了一组补丁;
2018.7.13:谷歌要求我们推迟发布此文章;
2018.9.4:Google发布了一组额外的补丁;