为什么使用 Android GPU 驱动程序
虽然该漏洞本身是一个相当标准的使用后释放漏洞,涉及 GPU 驱动程序中的严格竞争条件,并且这篇文章主要关注如何绕过设备而非 GPU 上的许多缓解措施,但仍然值得给出一些动机来解释为什么 Android GPU 成为攻击者的有吸引力的目标。
正如 Maddie Stone 在文章“你知道的越多,你知道的越多,你不知道的越多”中提到的,在 2021 年检测到的七个被利用的 Android 0-day 中,有五个针对的是 GPU 驱动程序。截至撰写本文时,另一个被利用的漏洞CVE-2021-39793(于 2022 年 3 月披露)也针对的是 GPU 驱动程序。
除了大多数 Android 设备使用 Qualcomm Adreno 或 ARM Mali GPU(这使得能够以相对较少的错误获得普遍覆盖)这一事实之外(Maddie Stone 的文章中提到了这一点),GPU 驱动程序也可以从所有 Android 设备中的不受信任的应用沙箱访问,从而进一步减少了完整链中所需的错误数量。GPU 驱动程序具有吸引力的另一个原因是,大多数 GPU 驱动程序还处理 GPU 设备和 CPU 之间相当复杂的内存共享逻辑。这些通常涉及相当复杂的内存管理代码,这些代码容易出现错误,可被滥用以实现对物理内存的任意读写或绕过内存保护。由于这些错误使攻击者能够滥用 GPU 内存管理代码的功能,因此其中许多错误也无法检测到内存损坏,并且不受现有缓解措施的影响,这些缓解措施主要旨在防止控制流劫持。一些例子是Guan Gong和Ben Hawkes的工作,他们利用处理 GPU 操作码时的逻辑错误来获得任意内存读写。
漏洞
该漏洞是在 Qualcomm msm 5.4 内核的 5.4 分支中引入的,当时引入了新的kgsl 时间线功能以及一些与之相关的新 ioctl。msm 5.4 内核对内核图形支持层 (kgsl) 驱动程序(位于drivers/gpu/msm下,这是 Qualcomm 的 GPU 驱动程序)进行了一些相当大的重构,并引入了一些新功能。这些新功能和重构都导致了许多回归和新的安全问题,其中大多数问题都是在内部发现和修复的,然后在公告中作为安全问题公开披露(赞扬 Qualcomm 没有默默修补安全问题),其中一些问题看起来相当容易被利用。
kgsl_timeline`可以通过 ioctl`IOCTL_KGSL_TIMELINE_CREATE`和创建和销毁对象`IOCTL_KGSL_TIMELINE_DESTROY`。对象在字段中`kgsl_timeline`存储对象列表。ioctl和可用于将对象添加到此列表。添加的对象是引用计数对象,并使用标准方法减少其引用计数。`dma_fence``fences``IOCTL_KGSL_TIMELINE_FENCE_GET``IOCTL_KGSL_TIMELINE_WAIT``dma_fence``dma_fence``dma_fence_put
有趣的是timeline->fences
,它实际上并不为栅栏保留额外的引用计数。相反,为了避免释放dma_fence
in ,使用自定义函数在释放之前将其删除。timeline->fences``release``timeline_fence_release``dma_fence``timeline->fences
dma_fence
当中存储的的引用计数kgsl_timeline::fences
减少为零时,timeline_fence_release
将调用 方法来删除 ,dma_fence
以便kgsl_timeline::fences
它不再能被 引用kgsl_timeline
,然后dma_fence_free
调用 来释放对象本身:
static void timeline_fence_release(struct dma_fence *fence)
{
...
spin_lock_irqsave(&timeline->fence_lock, flags);
/* If the fence is still on the active list, remove it */
list_for_each_entry_safe(cur, temp, &timeline->fences, node) {
if (f != cur)
continue;
list_del_init(&f->node); //<----- 1. Remove fence
break;
}
spin_unlock_irqrestore(&timeline->fence_lock, flags);
...
kgsl_timeline_put(f->timeline);
dma_fence_free(fence); //<------- 2. frees the fence
}
fence`尽管from的删除`timeline->fences`受到 的正确保护`timeline->fence_lock`,`IOCTL_KGSL_TIMELINE_DESTROY`但可以在其引用计数达到零之后、但在从in 中删除之前获取对`dma_fence`a的引用:`fences``fences``timeline_fence_release
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock(&timeline->fence_lock); //<------------- a.
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
dma_fence_get(&fence->base);
list_replace_init(&timeline->fences, &temp);
spin_unlock(&timeline->fence_lock);
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) { //<----- b.
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
在 中kgsl_ioctl_timeline_destroy
,当销毁时间轴时, 中的栅栏timeline->fences
首先被复制到另一个列表,temp
然后从 中删除timeline->fences
(点 a.)。由于timeline->fences
没有保存栅栏的额外引用,因此引用计数会增加以阻止它们在 中被释放temp
。同样, 的操作timeline->fences
在这里受到保护。但是,如果当达到上面的timeline->fence_lock
时栅栏的引用计数已经为零,但还不能将其从 中删除(它还没有到达 中的点 1. 在 中包含的代码片段中),那么将被移动到,尽管它的引用会增加,但已经太晚了,因为当它到达 点 2. 时将释放,而不管引用计数是多少。因此,如果事件按以下顺序发生,则可能会在点 b. 触发释放后使用:a``timeline_fence_release``timeline->fences``timeline_fence_release``dma_fence``temp``timeline_fence_release``dma_fence
在上图中,红色块表示持有相同锁的代码,这意味着这些块的执行是互斥的。虽然事件的顺序可能看起来有些牵强(当你试图说明竞争条件时,它总是如此),但实际的时间并不难实现。由于 中的timeline_fence_release
移除来自 的dma_fence
代码timeline->fences
无法在 中的代码kgsl_ioctl_timeline_destroy
访问时运行timeline->fence
(两者都持有),通过向timeline->fence_lock
中添加大量,我可以增加运行 中的红色代码块所需的时间。如果我在线程一中的红色代码块运行时将线程二中最后一个 的引用计数减少为零,我可以在增加线程一中这个 的引用计数之前触发。由于线程二中的红色代码块也需要获取,所以它不能在线程一中的红色代码块完成后才移除来自。到那时,所有的都已移动到列表。这也意味着当线程二中的红色代码块运行时,是一个空列表,循环很快完成并继续到。简单来说,只要我向中添加足够多的,我就可以在移动到时创建一个很大的竞争窗口。只要我在这个窗口内减少最后一个 的最后引用计数,我就能触发 UAF 漏洞。dma_fence``timeline->fence``kgsl_ioctl_timeline_destroy``dma_fence``timeline->fences``timeline_fence_release``dma_fence_get``dma_fence``timeline->fence_lock``dma_fence``timeline->fences``dma_fence``timeline->fences``temp``timeline->fences``dma_fence_free``dma_fences``timeline->fences``kgsl_ioctl_timeline_destroy``dma_fences``timeline->fences``temp``dma_fence``timeline->fences
缓解措施
虽然触发该漏洞并不太难,但另一方面,利用它则是完全不同的事情。我用来测试此漏洞和开发漏洞利用的设备是三星 Galaxy Z Flip3。运行内核版本 5.x 的最新三星设备可能具有最多的缓解措施,甚至比 Google Pixels 还要多。虽然运行内核 4.x 的旧设备通常具有缓解措施,例如关闭了kCFI(内核控制流完整性)和变量初始化,但所有这些功能都在 5.x 内核分支中打开,最重要的是,还有三星 RKP(实时内核保护),它保护各种内存区域,例如内核代码和进程凭据,即使实现任意内存读写,也很难执行任意代码。在本节中,我将简要解释这些缓解措施如何影响漏洞利用。
碳氢化合物
kCFI 可以说是最难绕过的缓解措施,尤其是与三星虚拟机管理程序结合使用时,后者可以保护内核中的许多重要内存区域。kCFI 通过使用函数签名限制动态调用站点可以跳转到的位置,从而防止控制流被劫持。例如,在当前漏洞中,在释放后,将调用dma_fence
以下函数:dma_fence_signal_locked
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base); //<---- free'd fence is used
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
然后,该函数调用列表内元素的dma_fence_signal_locked
函数。cur->func``fence->cb_list
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) {
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
...
}
如果没有 kCFI,现在释放的fence
对象就可以用假对象替换,这意味着cb_list
及其元素,因此func
,都可以被伪造,从而提供一个现成的原语来调用任意函数,其第一和第二个参数都指向受控数据(fence
和cur
都可以被伪造)。一旦 KASLR 被击败,利用漏洞会非常容易(例如,使用单独的漏洞来泄露内核地址,如本漏洞)。但是,由于 kCFI,func
现在只能用具有 类型的函数替换dma_fence_func_t
,这极大地限制了此原语的使用。
虽然我过去曾写过如何轻松绕过三星的控制流完整性检查(JOPP,面向跳转的编程预防),但绕过 kCFI 却并非易事。绕过 kCFI 的一种常见方法是使用双重释放来劫持释放列表,然后应用内核空间镜像攻击 (KSMA)。这种方法被多次使用,例如在《 Jun Yao 的[Android 内核上空的三片乌云](https://github.com/2freeman/Slides/blob/main/PoC-2020-Three Dark clouds over the Android kernel.pdf)》、 《台风山竹:一键远程通用 root》中,该攻击由Hongli Han、Rong Jian、Xiaodong Wang 和 Peng Zhou 的两个漏洞形成。
而当前的错误在释放dma_fence_put
后调用时也给了我一个双重释放原语:fence
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base); //<----- free'd fence can be freed again
}
spin_unlock_irq(&timeline->lock);
...
}
上述代码减少了伪造fence
对象的引用计数,我可以控制它使其成为一个,这样伪造的栅栏就会再次被释放。然而,这不允许我应用 KSMA,因为它需要覆盖swapper_pg_dir
受三星虚拟机管理程序保护的数据结构。
变量初始化
从 Android 11 开始,内核可以通过启用各种内核构建标志来启用自动变量初始化。例如,以下内容取自 Z Flip3 的构建配置:
# Memory initialization
#
CONFIG_CC_HAS_AUTO_VAR_INIT_PATTERN=y
CONFIG_CC_HAS_AUTO_VAR_INIT_ZERO=y
# CONFIG_INIT_STACK_NONE is not set
# CONFIG_INIT_STACK_ALL_PATTERN is not set
CONFIG_INIT_STACK_ALL_ZERO=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set
# end of Memory initialization
虽然该功能自 Android 11 起可用,但许多运行内核分支 4.x 的设备并未启用该功能。另一方面,运行内核 5.x 的设备似乎已启用这些功能。除了此功能可以防止明显的未初始化变量漏洞之外,它还使对象替换变得更加困难。特别是,不再可能执行部分对象替换,即仅替换对象的前几个字节,而对象的其余部分保持有效。因此,例如,Jann Horn 的“缓解措施也是攻击面”中“堆喷射”一节下的堆喷射技术类型在自动变量初始化下不再可能。在当前错误的背景下,这种缓解措施限制了我拥有的堆喷射选项,在我们讨论漏洞利用时,我将进一步解释。
释放rcu
这根本不是一个安全缓解措施,但在这里提到它仍然很有趣,因为它与一些已提出的 UAF 缓解措施具有类似的效果。fence
此错误中的 UAF 对象在dma_fence_free
调用时被释放,它kfree
使用而不是正常的kfree_rcu
。简而言之,kfree_rcu
不会立即释放对象,而是安排在满足某些条件时释放它。这有点像延迟释放,会在释放对象的时间上引入不确定性。有趣的是,这种效果与 Scudo 分配器( Android 用户空间进程的默认分配器)中使用的 UAF 缓解非常相似,它在实际释放释放的对象之前隔离它们以引入不确定性。有人为Linux 内核提出了类似的建议(但后来被拒绝)。除了在对象替换中引入不确定性之外,延迟释放还可能在竞争窗口紧密的情况下导致 UAF 问题。因此,从表面上看,使用kfree_rcu
对于利用当前错误来说相当成问题。但是,有许多原语可以操纵竞争窗口的大小,例如在与时间赛跑 - 命中一个微小的内核竞争窗口和[利用 [古老] Linux 上的竞争条件](https://static.sched.com/hosted_files/lsseu2019/04/LSSEU2019 - Exploiting race conditions on Linux.pdf)中详细介绍的原语(均由 Jann Horn 撰写,较旧的技术用于利用当前的错误),任何紧密的竞争窗口都可以足够大,以允许延迟kfree_rcu
以及随后的对象替换。至于不确定性,这似乎也不会造成很大的问题。在利用这个错误时,我实际上必须执行kfree_rcu
两次对象替换,第二次甚至不知道释放将在哪个 CPU 核心上发生,但即使有了这个和所有其他移动部件,一个相当未优化的漏洞仍然在测试的设备上以合理的可靠性(~70%)运行。虽然我相信第二次对象替换kfree_rcu
(释放对象的 CPU 不确定)可能是不可靠性的主要来源,但我认为可靠性损失更多地归因于缺乏 CPU 知识,而不是延迟释放。在我看来,当存在允许操纵调度程序的原语时,延迟释放可能不是一种非常有效的 UAF 缓解措施。
三星 RKP(实时内核保护)
Samsung RKP 保护内存的各个部分不被写入。这可以防止进程覆盖自己的凭据以成为 root,也可以保护 SELinux 设置不被覆盖。它还可以防止内核代码区域和其他重要对象(如内核页表)被覆盖。但在实践中,一旦实现了任意内核内存读写(受 RKP 限制),就有办法绕过这些限制。例如,可以通过覆盖 avc 缓存来修改 SELinux 规则(例如,请参阅 Valentina Palmiotti 的此漏洞),而获取 root 可以通过劫持以 root 身份运行的其他进程来实现。在当前漏洞的背景下,Samsung RKP 主要与 kCFI 配合使用,以防止调用任意函数。
在这篇文章中,我将利用启用了所有这些缓解措施的漏洞。
利用漏洞
现在我将开始研究该漏洞的利用。这是一个相当典型的释放后使用漏洞,涉及竞争条件和可能相当强大的原语,既有可能进行任意函数调用,又有可能进行双重释放,这并不罕见。除此之外,这是一个典型的漏洞,就像内核中发现的许多其他 UAF 一样。因此,使用这个漏洞来衡量这些缓解措施如何影响标准 UAF 漏洞的开发似乎是合适的。
添加dma_fence
到timeline->fences
在“漏洞”一节中,我解释了该漏洞依赖于将dma_fence
对象添加到对象fences
的列表中kgsl_timeline
,然后在kgsl_timeline
销毁时将其引用计数减少到零。有两种方法可以将dma_fence
对象添加到kgsl_timeline
,第一种是使用IOCTL_KGSL_TIMELINE_FENCE_GET
:
long kgsl_ioctl_timeline_fence_get(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
timeline = kgsl_timeline_by_id(device, param->timeline);
...
fence = kgsl_timeline_fence_alloc(timeline, param->seqno); //<----- dma_fence created and added to timeline
...
sync_file = sync_file_create(fence);
if (sync_file) {
fd_install(fd, sync_file->file);
param->handle = fd;
}
...
}
这将创建一个dma_fence
并将kgsl_timeline_fence_alloc
其添加到。然后,调用者获取与相对应的 的timeline
文件描述符。当关闭 时, 的引用计数将减少为零。sync_file``dma_fence``sync_file``dma_fence
第二种选择是使用IOCTL_KGSL_TIMELINE_WAIT
:
long kgsl_ioctl_timeline_wait(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
fence = kgsl_timelines_to_fence_array(device, param->timelines,
param->count, param->timelines_size,
(param->flags == KGSL_TIMELINE_WAIT_ANY)); //<------ dma_fence created and added to timeline
...
if (!timeout)
ret = dma_fence_is_signaled(fence) ? 0 : -EBUSY;
else {
ret = dma_fence_wait_timeout(fence, true, timeout); //<----- 1.
...
}
dma_fence_put(fence);
...
}
这将dma_fence
使用 创建对象kgsl_timelines_to_fence_array
并将它们添加到timeline
。如果timeout
指定了值,则调用将进入dma_fence_wait_timeout
(路径标记为 1),它将等待,直到超时到期或线程收到中断。dma_fence_wait_timeout
完成后,dma_fence_put
将调用以dma_fence
将 的引用计数减少为零。因此,通过指定较大的超时,dma_fence_wait_timeout
将阻塞直到它收到中断,然后释放dma_fence
已添加到 的timeline
。
虽然IOCTL_KGSL_TIMELINE_FENCE_GET
乍一看似乎更容易使用和控制,但在实践中,关闭 产生的开销sync_file
使得 的销毁时机dma_fence
不太可靠。因此,对于漏洞,我使用IOCTL_KGSL_TIMELINE_FENCE_GET
创建并添加持久dma_fence
对象来填充timeline->fences
列表以扩大竞争窗口,而用于 UAF 漏洞的最后一个dma_fence
对象是使用IOCTL_KGSL_TIMELINE_WAIT
和 添加的,当我向调用 的线程发送中断信号时,它会被释放IOCTL_KGSL_TIMELINE_WAIT
。
扩大微小的比赛窗口
回顾一下,为了利用该漏洞,我需要从以下代码块中标记的第一个竞争窗口内的a 列表dma_fence
中删除 a 的引用计数:fences``kgsl_timeline
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
//BEGIN OF FIRST RACE WINDOW
spin_lock(&timeline->fence_lock);
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
dma_fence_get(&fence->base);
list_replace_init(&timeline->fences, &temp);
spin_unlock(&timeline->fence_lock);
//END OF FIRST RACE WINDOW
//BEGIN OF SECOND RACE WINDOW
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
//END OF SECOND RACE WINDOW
...
}
如前所述,可以通过向中添加大量对象来扩大第一个竞争窗口dma_fence
,timeline->fences
从而很容易触发该窗口内的引用计数减少。然而,要利用该漏洞,必须在第二个竞争窗口结束之前完成以下代码以及对象替换:
spin_lock_irqsave(&timeline->fence_lock, flags);
list_for_each_entry_safe(cur, temp, &timeline->fences, node) {
if (f != cur)
continue;
list_del_init(&f->node);
break;
}
spin_unlock_irqrestore(&timeline->fence_lock, flags);
trace_kgsl_timeline_fence_release(f->timeline->id, fence->seqno);
kgsl_timeline_put(f->timeline);
dma_fence_free(fence);
如前所述,由于spin_lock
,上面的代码在第一个竞争窗口结束之前无法启动,但在运行此代码时timeline->fences
已被清空,因此循环将快速运行。但是,由于dma_fence_free
使用kfree_rcu
,实际释放fence
被延迟。除非我们操纵调度程序,否则无法在第二个竞争窗口结束之前替换释放的栅栏。为此,我将使用“利用[[古老] Linux 上的竞争条件](https://static.sched.com/hosted_files/lsseu2019/04/LSSEU2019 - Exploiting race conditions on Linux.pdf)”中的一种技术,我也在另一个Android 漏洞中使用过该技术来扩大此竞争窗口。
我将在这里为不熟悉该技术的读者重述该技术的精髓。
为了确保每个任务(线程或进程)都能公平地共享 CPU 时间,Linux 内核调度程序可以中断正在运行的任务并将其搁置,以便运行另一个任务。这种中断和停止任务的行为称为抢占(被中断的任务被抢占)。任务还可以将自己搁置以允许另一个任务运行,例如当它正在等待某些 I/O 输入时,或者当它调用时sched_yield()
。在这种情况下,我们说该任务是自愿被抢占的。抢占也可以发生在系统调用(例如 ioctl 调用)内部,并且在 Android 上,除了某些关键区域(例如持有自旋锁)之外,任务都可以被抢占。可以使用 CPU 亲和性和任务优先级来操纵此行为。
默认情况下,任务以优先级 运行SCHED_NORMAL
,但也可以使用调用(或线程)SCHED_IDLE
设置较低的优先级。此外,还可以使用 将其固定到 CPU ,这将仅允许它在特定 CPU 上运行。通过将两个任务(一个优先级为 ,另一个优先级为 )固定到同一个 CPU,可以按如下方式控制抢占的时间。sched_setscheduler``pthread_setschedparam``sched_setaffinity``SCHED_NORMAL``SCHED_IDLE
-
首先让 SCHED_NORMAL
任务执行一个系统调用,使其暂停并等待。例如,它可以从没有数据从另一端传入的管道读取数据,然后它会等待更多数据并主动抢占自身,以便任务SCHED_IDLE
可以运行。 -
在 SCHED_IDLE
任务运行时,向任务一直在等待的管道发送一些数据SCHED_NORMAL
。这将唤醒该SCHED_NORMAL
任务并使其抢占该SCHED_IDLE
任务,并且由于任务优先级,该SCHED_IDLE
任务将被抢占并搁置。 -
然后该 SCHED_NORMAL
任务可以运行一个忙循环以防止SCHED_IDLE
任务被唤醒。
在我们的例子中,对象替换顺序如下:
-
IOCTL_KGSL_TIMELINE_WAIT
在线程上运行以将dma_fence
对象添加到kgsl_timeline
。将超时设置为较大的值,并使用sched_setaffinity
此任务将此任务固定到 CPU,将其称为SPRAY_CPU
。dma_fence
添加对象后,任务将变为空闲状态,直到收到中断。 -
设置一个 SCHED_NORMAL
任务并将其固定到另一个DESTROY_CPU
监听空管道的 CPU( )。这将导致此任务最初处于空闲状态,并允许DESTROY_CPU
运行优先级较低的任务。一旦空管道收到一些数据,此任务就会运行一个繁忙循环。 -
设置一个将运行 SCHED_IDLE
的任务,以销毁在第一步中添加的时间线。由于第二步中设置的任务正在等待对空管道的响应,因此将首先运行此任务。DESTROY_CPU``IOCTL_KGSL_TIMELINE_DESTROY``dma_fence``DESTROY_CPU
-
向正在运行的任务发送中断 IOCTL_KGSL_TIMELINE_WAIT
。然后,任务将在第一个竞争窗口内解除阻塞并释放正在运行的dma_fence
任务。IOCTL_KGSL_TIMELINE_DESTROY
-
写入 SCHED_NORMAL
任务正在监听的空管道。这将导致任务SCHED_NORMAL
抢占该SCHED_IDLE
任务。一旦成功抢占该任务,DESTROY_CPU
将运行忙循环,导致SCHED_IDLE
任务被搁置。 -
由于 SCHED_IDLE
正在运行的任务IOCTL_KGSL_TIMELINE_DESTROY
被搁置,现在有足够的时间来克服由 引入的延迟,kfree_rcu
并允许dma_fence
释放和替换步骤四中的 。之后,我可以恢复,IOCTL_KGSL_TIMELINE_DESTROY
以便对现在已释放和替换的对象执行后续操作dma_fence
。
这里需要注意的是,由于当线程持有时不能发生抢占spinlock
,因此IOCTL_KGSL_TIMELINE_DESTROY
只能在自旋锁之间的窗口期间被抢占(由下面的注释标记):
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
spin_lock(&timeline->fence_lock);
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
...
spin_unlock(&timeline->fence_lock);
//Preemption window
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
...
}
spin_unlock_irq(&timeline->lock);
...
}
尽管上面的抢占窗口看起来很小,但在实践中,只要任务SCHED_NORMAL
试图抢占在第一个被持有期间SCHED_IDLE
运行的任务,抢占就会在释放后立即发生,从而更容易在正确的时间成功抢占。IOCTL_KGSL_TIMELINE_DESTROY``spinlock``spinlock``IOCTL_KGSL_TIMELINE_DESTROY
下图说明了理想世界中发生的情况,红色块表示持有spinlock
且因此无法抢占的区域,虚线表示空闲的任务。
对于对象替换,我将使用sendmsg
,这是用受控数据替换 Linux 内核中已释放对象的标准方法。由于该方法相当标准,我不会在这里给出详细信息,但请读者参阅上面的链接。从现在开始,我将假设已释放的对象dma_fence
被任意数据替换。(使用此方法对前 12 个字节有一些限制,但这不会影响我们的利用。)
假设被释放的dma_fence
对象可以被任意数据替换,我们来看看这个假对象是怎么用的。替换之后,它会被如下dma_fence
使用:kgsl_ioctl_timeline_destroy
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
三个不同的函数,dma_fence_set_error
,dma_fence_signal_locked
和dma_fence_put
将使用参数 进行调用fence
。 该函数dma_fence_set_error
将向对象写入错误代码fence
,这可能对合适的对象替换有用,但对sendmsg
对象替换则无用,我不会在这里调查这种可能性。 该函数dma_fence_signal_locked
执行以下操作:
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
if (unlikely(test_and_set_bit(DMA_FENCE_FLAG_SIGNALED_BIT, //<-- 1.
&fence->flags)))
return -EINVAL;
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 2.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 3.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
它首先检查fence->flags
(上面的 1.):如果DMA_FENCE_FLAG_SIGNALED_BIT
设置了标志,则已fence
发出信号,并且函数提前退出。如果fence
尚未发出信号,则list_replace
调用以移除中的对象fence->cb_list
并将它们放置在临时文件中cb_list
(上面的 2.)。之后,cb_list
调用存储在中的函数(上面的 3.)。如“kCFI”部分所述,由于 CFI 缓解,这只允许我调用某种类型的函数;此外,在这个阶段我不知道函数地址,所以如果我走到这条路,很可能会让内核崩溃。所以,在这个阶段,我别无选择,只能DMA_FENCE_FLAG_SIGNALED_BIT
在我的假对象中设置标志,以便dma_fence_signal_locked
提前退出。
这给我留下了一个dma_fence_put
函数,它会减少引用计数,并在引用计数达到零时fence
调用:dma_fence_release
void dma_fence_release(struct kref *kref)
{
...
if (fence->ops->release)
fence->ops->release(fence);
else
dma_fence_free(fence);
}
如果dma_fence_release
被调用,那么最终它会检查fence->ops
并调用fence->ops->release
。这给我带来了两个问题:首先,fence->ops
需要指向有效的内存,否则取消引用将失败,即使取消引用成功,也fence->ops->release
需要为零,或者必须是适当类型的函数的地址。
所有这些让我面临两个选择。我要么遵循标准路径:尝试用fence
另一个对象替换该对象,要么尝试利用dma_fence_put
和dma_fence_set_error
提供的有限写入原语,同时希望我仍然可以控制flags
和字段以refcount
避免内核崩溃。dma_fence_signal_locked``dma_fence_release
或者,我可以尝试别的方法。
终极的假对象存储
在利用另一个漏洞时,我遇到了软件输入输出转换后备缓冲区 (SWIOTLB),这是一个在启动时很早阶段分配的内存区域。因此,SWIOTLB 的物理地址非常固定,仅取决于硬件配置。此外,由于此内存位于“低内存”区域(Android 设备似乎没有“高内存”区域)而不是内核映像中,因此虚拟地址只是具有固定偏移量的物理地址(对细节感兴趣的读者可以例如关注该kmap
函数的实现):
#define __virt_to_phys_nodebug(x) ({
phys_addr_t __x = (phys_addr_t)(__tag_reset(x));
__is_lm_address(__x) ? __lm_to_phys(__x) : __kimg_to_phys(__x);
})
#define __is_lm_address(addr) (!(((u64)addr) & BIT(vabits_actual - 1)))
#define __lm_to_phys(addr) (((addr) + physvirt_offset))
上述定义来自,这是 Android 的相关实现。用于转换地址的arch/arm64/include/asm/memory.h
变量是 中的一个固定常量集:physvirt_offset``arm64_memblock_init
void __init arm64_memblock_init(void)
{...
memstart_addr = round_down(memblock_start_of_DRAM(),
ARM64_MEMSTART_ALIGN);
physvirt_offset = PHYS_OFFSET - PAGE_OFFSET;
...
}
除此之外,SWIOTLB 中的内存可以通过adsp
不受信任的应用程序访问的驱动程序进行访问,因此这似乎是存储虚假对象和重定向虚假指针的好地方。然而,在 5.x 版本的内核中,只有使用标志编译内核时才会分配 SWIOTLB CONFIG_DMA_ZONE32
,而我们的设备并非如此。
然而,还有更好的办法。SWIOTLB 的早期分配为其提供了可预测的地址,这一事实促使我检查启动日志,以查看是否有其他内存区域在启动期间早期分配,结果发现确实有其他内存区域在启动期间很早就分配了。
<6>[ 0.000000] [0: swapper: 0] Reserved memory: created CMA memory pool at 0x00000000f2800000, size 212 MiB
<6>[ 0.000000] [0: swapper: 0] OF: reserved mem: initialized node secure_display_region, compatible id shared-dma-pool
...
<6>[ 0.000000] [0: swapper: 0] OF: reserved mem: initialized node user_contig_region, compatible id shared-dma-pool
<6>[ 0.000000] [0: swapper: 0] Reserved memory: created CMA memory pool at 0x00000000f0c00000, size 12 MiB
<6>[ 0.578613] [7: swapper/0: 1] platform soc:qcom,ion:qcom,ion-heap@22: assigned reserved memory node sdsp_region
...
<6>[ 0.578829] [7: swapper/0: 1] platform soc:qcom,ion:qcom,ion-heap@26: assigned reserved memory node user_contig_region
...
上面的区域Reserved memory
似乎是用于分配离子缓冲区的内存池。
在 Android 上,ion_allocator
用于分配用于 DMA(直接内存访问)的内存区域,允许内核驱动程序和用户空间进程共享相同的底层内存。不受信任的应用程序可以通过文件访问 ion 分配器/dev/ion
,并且ION_IOC_ALLOC
可以使用 ioctl 分配 ion 缓冲区。ioctl 会向用户返回一个新的文件描述符,然后可以在mmap
系统调用中使用该描述符将 ion 缓冲区的后备存储映射到用户空间。
使用 ion 缓冲区的一个特殊原因是用户可以请求具有连续物理地址的内存。这一点尤其重要,因为有些设备(如硬件上的设备,而不是手机本身)直接访问物理内存,而具有连续的内存地址可以大大提高此类内存访问的性能,而有些设备无法处理不连续的物理内存。
与 SWIOTLB 类似,为了确保有一块具有请求大小的连续物理内存可用,Ion 驱动程序会在启动的早期阶段分配这些内存区域,并将它们用作内存池(“划分区域”),然后在稍后请求时使用这些内存池来分配 Ion 缓冲区。Ion 设备中的内存池并非都是连续内存(例如,通用“系统堆”可能不是物理上连续的区域),但用户可以heap_id_mask
在使用时ION_IOC_ALLOC
指定具有特定属性的 Ion 堆(例如,连续的物理内存)。
这些内存池在如此早期的阶段分配意味着它们的地址是可预测的,并且仅取决于硬件的配置(设备树、可用内存、内存起始地址、各种启动参数等)。这特别意味着,如果我使用从很少使用的内存池中分配一个 ion 缓冲区ION_IOC_ALLOC
,则该缓冲区很可能分配在可预测的地址。如果我随后使用mmap
将缓冲区映射到用户空间,我将能够随时访问这个可预测地址的内存!
经过一些实验,似乎user_contig_region
几乎从未使用过,而且我每次都能将整个区域映射到用户空间。因此在漏洞利用中,我使用了这个内存池,并假设我可以分配整个区域以保持简单。(修改漏洞利用以适应部分区域不可用的情况并不难,而且不会影响可靠性。)
现在我能够将受控数据放在可预测的地址,我可以解决之前在漏洞利用中遇到的问题。回想一下,在我的伪造对象dma_fence_release
上调用when :fence
void dma_fence_release(struct kref *kref)
{
...
if (fence->ops->release)
fence->ops->release(fence);
else
dma_fence_free(fence);
}
我遇到了一个问题,我需要fence->ops
指向一个包含全零的有效地址,所以它fence->ops->release
不会被调用(因为我在这个阶段没有与签名匹配的有效函数地址fence->ops->release
,并且采取这条路径会导致内核崩溃)
由于离子缓冲区位于可预测的地址,我只需用零填充它并指向fence->ops
那里即可。这将确保路径dma_fence_free
被采用,然后释放我的假对象,为我提供双重释放原语,同时防止内核崩溃。然而,在继续利用这个双重释放原语之前,还有另一个问题需要先解决。
摆脱无限循环
回想一下,在kgsl_ioctl_timeline_destroy
函数中,fence
对象被销毁和替换后,将执行以下循环:
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
list_for_each_entry_safe
首先将指针next
从 移到 ,list_head
temp
找到fence
列表中的第一个条目,然后通过跟随next
中的指针进行迭代,fence->node
直到next
条目再次指向temp
。如果next
条目没有指向temp
,则循环将next
无限期地继续跟随指针。这是变量初始化使生活变得更加困难的地方。看看 的布局kgsl_timeline_fence
,它嵌入了一个dma_fence
添加到 的对象kgsl_timeline
:
struct kgsl_timeline_fence {
struct dma_fence base;
struct kgsl_timeline *timeline;
struct list_head node;
};
我可以看到node
字段是 中的最后一个字段kgsl_timeline_fence
,而要构造漏洞,我只需要用base
受控数据替换即可。上述问题可以通过部分对象替换轻松解决。如果没有自动变量初始化,如果我仅用kgsl_timeline_fence
大小为 的对象替换已释放的对象dma_fence
,则字段timeline
和node
将保持完整并包含有效数据。这既会导致next
中的指针node
有效,又允许 中的循环kgsl_ioctl_timeline_destroy
正常退出。但是,使用自动变量初始化,即使我kgsl_timeline_fence
用较小的对象替换已释放的对象,整个内存块也会先设置为零,从而擦除 和kgsl_timeline
,node
这意味着我现在必须伪造node
字段以便:
-
指针 next
指向有效地址以避免立即崩溃,事实上,不仅如此,它还需要指向另一个伪造的对象,kgsl_timeline_fence
该对象可以由循环中的函数(dma_fence_set_error
、dma_fence_signal_locked
和dma_fence_put
)操作而不会崩溃。这意味着需要制作更多伪造对象。 -
next
这些虚假对象中的一个指针kgsl_timeline_fence
指回temp
退出循环的列表,这是一个堆栈分配的变量。
第一个要求不太难,因为我现在可以使用离子缓冲液来创建这些假kgsl_timeline_fence
物体。然而,第二个要求就难得多。
从表面上看,这个障碍似乎更像是一个美学问题,而不是一个真正的问题。毕竟,我可以创建假对象,以便列表在假对象内形成循环kgsl_timeline_fence
:
这会导致无限循环并占用 CPU。虽然这很丑陋,但假对象应该会处理取消引用问题并避免崩溃,所以这可能不是一个致命的问题。不幸的是,由于循环在内部运行spinlock
,运行一小段时间后,看门狗似乎会将其标记为 CPU 占用问题并触发内核恐慌。所以,我确实需要找到一种方法来退出循环,并快速退出。
让我们退一步来看看这个函数dma_fence_signal_locked
:
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
struct list_head cb_list;
...
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 1.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 2.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
dma_fence
此函数将针对列表中的每个伪造文件运行temp
(原始的已释放和替换的dma_fence
,以及它在 ion 缓冲区中链接到的伪造文件)。如前所述,如果运行上面 2. 处的代码,则内核可能会崩溃,因为我无法提供有效的func
,因此我仍然想避免运行该路径。
为了能够运行此代码而不是上面 2 中的循环代码,我需要将其初始化fence.cb_list
为一个空列表,以便其next
和都指向自身。对于漏洞释放的prev
初始伪造对象,这是不可能的,因为和的地址因此是未知的,所以我不得不完全避免使用这个第一个伪造对象的代码。但是,由于链接到它的后续伪造对象位于具有已知地址的离子缓冲区中,我现在可以为这些对象创建一个空的,将和指针都设置为字段的地址。然后该函数将执行以下操作:dma_fence``fence``fence.cb_list``list_replace``dma_fence``cb_list``next``prev``fence.cb_list``list_replace
static inline void list_replace(struct list_head *old,
struct list_head *new)
{
//old->next = &(fence->cb_list)
new->next = old->next;
//new->next = &(fence->cb_list) => fence->cb_list.prev = &cb_list
new->next->prev = new;
//new->prev = fence->cb_list.prev => &cb_list
new->prev = old->prev;
//&cb_list->next = &cb_list
new->prev->next = new;
}
我们可以看到,在之后list_replace
,堆栈变量的地址cb_list
已被写入fence->cb_list.prev
,它位于离子缓冲区的某个位置。由于离子缓冲区映射到用户空间,我可以通过轮询离子缓冲区来读取此地址。在分配堆栈变量后,dma_fence_signal_locked
内部运行如下:kgsl_ioctl_timeline_destroy``temp
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
struct list_head temp;
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
//cb_list, is a stack variable allocated inside `dma_fence_signal_locked`
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
有了的地址,cb_list
我就能计算的地址temp
(它将与的地址有一个固定的偏移量cb_list
),因此通过轮询的地址cb_list
,然后使用它来计算的地址temp
并将其写回到离子缓冲区中的next
一个虚假kgsl_timeline_fence
对象的指针中,我可以在看门狗咬住之前退出循环。
劫持空闲列表
现在我能够避免内核崩溃,我可以继续利用前面提到的双重释放原语。回顾一下,一旦触发初始的释放后使用漏洞,并使用 成功将释放的对象替换为受控数据sendmsg
,则替换的对象将在以下循环中使用fence
:
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
具体来说,dma_fence_put
将减少假对象的引用计数,如果引用计数达到零,它将调用dma_fence_free
,然后使用释放对象kfree_rcu
。由于假对象完全受控制,并且我能够解决可能导致内核崩溃的各种问题,我现在假设这是所采用的代码路径,并且假对象将由释放kfree_rcu
。通过再次用另一个对象替换假对象,我可以获得对同一对象的两个引用,我可以随时使用这两个对象句柄释放它们。一般的想法是,当释放一个内存块时,指向下一个空闲块的空闲列表指针将被写入该内存块的前 8 个字节。如果我从一个句柄释放对象,然后使用另一个句柄修改这个释放对象的前 8 个字节,那么我就可以劫持空闲列表指针并让它指向我选择的地址,下一次分配将在该地址发生。 (这是一个过于简化的版本,因为只有当空闲和分配来自同一个 slab 时才会发生这种情况,内存分配器(在本例中为 SLUB 分配器)使用的页面来分配内存块,并通过快速路径完成分配,但这种情况并不难实现。)
为了能够在分配对象后修改其前 8 个字节,我将使用signalfd
“缓解措施也是攻击面”中使用的对象。signalfd
系统调用分配一个 8 字节对象来存储文件的掩码signalfd
,用户可以指定该掩码,但有一些小限制。分配的对象的生存期与signalfd
返回给用户的文件绑定,可以通过关闭文件轻松控制。此外,可以通过使用不同的掩码再次调用来更改此对象的前 8 个字节signalfd
。这非常signalfd
适合我的目的。
为了劫持空闲列表指针,我必须执行以下操作:
-
触发 UAF 漏洞,并用通过 分配的 dma_fence
假对象替换已释放的对象,然后调用它来释放这个假对象。dma_fence``sendmsg``dma_fence_free``kfree_rcu
-
在对象释放后,对堆进行喷射以 signalfd
在与该对象相同的地址处分配另一个对象。sendmsg
-
释放 sendmsg
对象,以便将空闲列表指针写入signalfd
第二步中的对象的掩码中。 -
修改对象的掩码 signalfd
,以便空闲列表指针现在指向我选择的地址,然后再次喷射堆以在该地址分配对象。
如果我将空闲列表指针的地址设置为我控制的 ion 缓冲区的地址,那么后续分配将把对象放入 ion 缓冲区中,然后我可以随时访问和修改它。这给了我一个非常强大的原语,因为我可以读取和修改我分配的任何对象。本质上,我可以在具有读写访问权限的区域中伪造自己的内核堆。
该计划的主要障碍来自于 和 的结合,即在调用后kfree_rcu
,运行的 CPUdma_fence_put
将暂时陷入繁忙循环。回想一下上一节,在我能够通过将列表的地址写入其中一个假对象的指针来退出循环之前,循环将一直运行。这特别意味着,一旦被调用并退出,循环将继续在运行 的 CPU 上处理其他假对象。如前所述,不会立即释放对象,而是安排其移除。大多数情况下,释放实际上会发生在调用 的同一 CPU 上。但是,在这种情况下,由于运行的 CPU通过运行循环在 内保持繁忙,因此几乎肯定不会在同一 CPU 上释放对象。相反,将使用不同的 CPU 来释放对象。这会导致问题,因为对象替换的可靠性取决于用于释放对象的 CPU。当在 CPU 上释放对象时,内存分配器会将其放置在每个 CPU 的缓存中。紧接着在同一 CPU 上的分配将首先在 CPU 缓存中寻找可用空间,并且很可能会替换新释放的对象。但是,如果分配发生在不同的 CPU 上,那么它很可能会替换不同 CPU 缓存中的对象,而不是新释放的对象。不知道哪个 CPU 负责释放对象,再加上不确定何时释放对象(由于引入的延迟),意味着替换对象可能很困难。然而在实践中,我能够使用一个相当简单的方案在测试设备上获得合理的结果(成功率 >70%):只需运行一个循环,在每个 CPU 上喷洒对象,并间歇地重复喷洒以解决时间上的不确定性。这里可能还有改进的空间,以使漏洞利用更加可靠。kfree_rcu``temp``next``kgsl_timeline_fence::node``kfree_rcu``dma_fence_put``dma_fence``kfree_rcu``kfree_rcu``kfree_rcu``kfree_rcu``spinlock``kfree_rcu
sendmsg
漏洞利用中使用的另一个细微修改是,在释放对象后,用另一轮堆喷射替换它们signalfd
。这是为了确保这些sendmsg
对象不会被我无法控制的对象意外替换,从而可能干扰漏洞利用,同时也是为了更容易识别实际损坏的对象。
现在我可以劫持空闲列表并将新的对象分配重定向到我可以随时自由访问的离子缓冲区,我需要将其转变为任意内存读写原语。
设备内存镜像攻击
内核驱动程序通常需要将内存映射到用户空间,因此,通常存在包含指向结构page
或sg_table
结构的指针的结构。这些结构通常包含指向页面的指针,这些页面将在mmap
调用时映射到用户空间。这使它们成为非常好的破坏目标。例如,ion_buffer
我已经使用的对象在所有 Android 设备上都可用。它有一个结构,其中包含有关使用sg_table
时将映射到用户空间的页面的信息。mmap
除了广泛可用且可从不受信任的应用程序访问之外,ion_buffer
对象还可以解决其他一些问题,因此在下文中,我将使用上面的 freelist 劫持原语ion_buffer
在 Ion 缓冲区后备存储中分配一个结构,我可以对该结构进行任意读写访问。通过这样做,我可以自由破坏ion_buffer
已分配的所有结构中的数据。为了避免混淆,从现在开始,我将使用术语“假内核堆”来表示我用作假内核堆的 Ion 缓冲区后备存储,并ion_buffer
使用结构表示我在假堆中分配的结构,以用作破坏目标。
这里的一般思路是,通过ion_buffer
在伪内核堆中分配结构,我将能够修改结构ion_buffer
并将其替换sg_table
为受控数据。该sg_table
结构包含一个scatterlist
表示支持该结构的页面集合的结构ion_buffer
:
struct sg_table {
struct scatterlist *sgl; /* the list */
unsigned int nents; /* number of mapped entries */
unsigned int orig_nents; /* original size of list */
};
struct scatterlist {
unsigned long page_link;
unsigned int offset;
unsigned int length;
dma_addr_t dma_address;
#ifdef CONFIG_NEED_SG_DMA_LENGTH
unsigned int dma_length;
#endif
};
page_link
中的字段是指针scatterlist
的编码形式page
,指示结构的实际page
后备存储位置ion_buffer
:
static inline struct page *sg_page(struct scatterlist *sg)
{
#ifdef CONFIG_DEBUG_SG
BUG_ON(sg_is_chain(sg));
#endif
return (struct page *)((sg)->page_link & ~(SG_CHAIN | SG_END));
}
当mmap
被调用时,被编码的页面page_link
将被映射到用户空间:
int ion_heap_map_user(struct ion_heap *heap, struct ion_buffer *buffer,
struct vm_area_struct *vma)
{
struct sg_table *table = buffer->sg_table;
...
for_each_sg(table->sgl, sg, table->nents, i) {
struct page *page = sg_page(sg);
...
//Maps pages to user space
ret = remap_pfn_range(vma, addr, page_to_pfn(page), len,
vma->vm_page_prot);
...
}
return 0;
}
由于page
指针只是页面物理地址的逻辑移位,后跟一个常量线性偏移量(参见phys_to_page的定义),因此能够控制page_link
允许我将任意页面映射到用户空间。对于许多设备来说,这足以实现任意内核内存读写,因为内核映像映射到固定的物理地址(KASLR 将虚拟地址偏移量从这个固定的物理地址随机化),因此在使用物理地址时无需担心 KASLR。
然而,三星设备对 KASLR 的处理方式不同。它不会将内核映像映射到固定的物理地址,而是将内核映像的物理地址随机化(严格来说,是内核感知到的中间物理地址,它不是真正的物理地址,而是虚拟机管理程序提供的虚拟地址)。所以在我们的例子中,我仍然需要泄露一个地址来击败 KASLR。然而,使用伪内核堆,这很容易实现。一个ion_buffer
对象包含一个指向的指针ion_heap
,它负责为分配后备存储ion_buffer
:
struct ion_buffer {
struct list_head list;
struct ion_heap *heap;
...
};
虽然ion_heap
在内核映像中不是一个全局对象,但是每个对象都ion_heap
包含一个ion_heap_ops
字段,指向特定ion_heap
对象的相应“vtable”:
struct ion_heap {
struct plist_node node;
enum ion_heap_type type;
struct ion_heap_ops *ops;
...
}
ops
上面的字段是内核映像中的全局对象。如果我可以读取,ion_buffer->heap->ops
那么我也能获得一个地址来击败 KASLR,并将内核映像中的地址转换为物理地址。这可以按如下方式完成:
-
首先找到 ion_buffer
伪内核堆中的结构。可以使用flags
以下字段完成此操作ion_buffer
:
struct ion_buffer {
struct list_head list;
struct ion_heap *heap;
unsigned long flags;
...
ION_IOC_ALLOC
这是创建ioctl 时传递的 4 字节值ion_buffer
。我可以将这些设置为特定的“魔法”值,并在伪造的内核堆中搜索它们。
2)ion_buffer
找到结构体后,读取其heap
指针。这将是内核映像之外的低内存区域中的虚拟地址,因此,可以通过应用常量偏移量来获取其物理地址。
ion_heap
3) 一旦获取了相应对象的物理地址,就修改 ,sg_table
使其ion_buffer
后备存储指向包含 的页面ion_heap
。 4. 调用mmap
文件描述符ion_buffer
,这会将包含 的页面映射到ion_heap
用户空间。然后可以直接从用户空间读取此页面以获取ops
指针,这将提供 KASLR 偏移量。
使用该ion_buffer
结构还解决了另一个问题。虽然伪内核堆很方便,但它并不完美。每当释放伪内核堆中的对象时,kfree
都会使用该检查来检查包含该对象的页面是否是来自 SLUB 分配器的单个页面 slab PageSlab
。如果检查失败,则将PageCompound
执行检查以检查该页面是否是更大 slab 的一部分。
void kfree(const void *x)
{
struct page *page;
void *object = (void *)x;
trace_kfree(_RET_IP_, x);
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
page = virt_to_head_page(x);
if (unlikely(!PageSlab(page))) { //<-------- check if the page allocated is a single page slab
unsigned int order = compound_order(page);
BUG_ON(!PageCompound(page)); //<-------- check if the page is allocated as part of a multipage slab
...
}
...
}
由于这些检查是在page
包含页面元数据的结构本身上执行的,因此每当释放对象时,它们都会失败并导致内核崩溃。可以使用任意读写原语来修复此问题,我现在必须覆盖结构中的相应元数据page
(对应于物理地址的结构地址page
只是物理地址的逻辑移位,然后转换固定偏移量,因此我可以将page
包含该结构的空间映射到用户空间并修改其内容)。但是,如果我可以确保占用伪内核堆的对象永远不会被释放,那么事情会更简单。在释放结构page
之前,会调用:ion_buffer``ion_buffer_destroy
int ion_buffer_destroy(struct ion_device *dev, struct ion_buffer *buffer)
{
...
heap = buffer->heap;
...
if (heap->flags & ION_HEAP_FLAG_DEFER_FREE)
ion_heap_freelist_add(heap, buffer); //<--------- does not free immediately
else
ion_buffer_release(buffer);
return 0;
}
如果ion_heap
包含标志ION_HEAP_FLAG_DEFER_FREE
,那么ion_buffer
将不会立即释放,而是将其添加到正在使用的free_list
的中。添加到此列表的对象将只在稍后需要时释放,并且仅当设置了标志时才会释放。当然,通常情况下,在 的生命周期内不会发生变化,但是使用我们的任意内存写入原语,我可以简单地将其添加到,释放,然后再次删除,那么就会卡在 的空闲列表中,永远不会被释放。此外,包含对象的页面已经为击败 KASLR 进行了映射,因此切换标志相当简单。通过喷射伪内核堆,使其充满对象及其依赖项,我可以确保这些对象永远不会被释放并避免内核崩溃。ion_heap``ion_heap_freelist_add``ion_buffer``ION_HEAP_FLAG_DEFER_FREE``ION_HEAP_FLAG_DEFER_FREE``ion_heap``ION_HEAP_FLAG_DEFER_FREE``ion_heap->flags``ion_buffer``ION_HEAP_FLAG_DEFER_FREE``ion_buffer``ion_heap``ion_heap``ion_buffer
绕过 SELinux
启用 SELinux 后,它可以在 模式permissive
或enforcing
模式下运行。在permissive
模式下,它只会审核和记录未经授权的访问,但不会阻止它们。SELinux 运行的模式由变量控制selinux_enforcing
。如果此变量为零,则 SELinux 在permissive
模式下运行。通常,对系统安全至关重要的变量受三星内核数据保护 (KDP) 保护,通过使用__kdp_ro
或属性将它们标记为只读。此属性表示变量位于只读页面中,并且其修改受虚拟机管理程序调用的保护。然而,令我惊讶的是,三星似乎忘记在 5.x 分支 Qualcomm 内核中__rkp_ro
保护这个变量(又来了? ):
//In security/selinux/hooks.c
#ifdef CONFIG_SECURITY_SELINUX_DEVELOP
static int selinux_enforcing_boot;
int selinux_enforcing;
因此,我只需将其覆盖selinux_enforcing
为零并将 SELinux 设置为该permissive
模式即可。虽然还有其他更通用的方法来绕过 SELinux(例如 Valentina Palmiotti 在此漏洞中使用的方法),但此时采用捷径是再好不过的了,因此我只需设置变量即可selinux_enforcing
。
使用 ret2kworker(TM) 运行任意 root 命令
在三星设备上获取 root 权限的一个众所周知的问题是三星 RKP(实时内核保护)所施加的保护。在 Android 设备上获取 root 权限的一种常见方法是用 root 凭据覆盖我们自己进程的凭据。但是,三星的 RKP 写保护每个进程的凭据,所以这里不可能实现。在我上次的漏洞利用中,我能够以 root 身份执行任意代码,因为利用的特定 UAF 导致受控函数指针在以kworker
root 身份运行的 运行的代码中执行。在该漏洞利用中,我能够破坏对象,然后将这些对象添加到工作队列,然后由 使用kworker
并通过运行作为函数指针提供的函数来执行。这使得以 root 身份运行任意函数变得相对容易。
当然,使用任意内存读写原语,可以简单地将对象添加到这些工作队列之一(基本上是包含work
结构的链接列表)并等待kworker
接手工作。事实证明,这些工作队列中的许多确实是静态全局对象,在内核映像中具有固定地址:
ffffffc012c8f7e0 D system_wq
ffffffc012c8f7e8 D system_highpri_wq
ffffffc012c8f7f0 D system_long_wq
ffffffc012c8f7f8 D system_unbound_wq
ffffffc012c8f800 D system_freezable_wq
ffffffc012c8f808 D system_power_efficient_wq
ffffffc012c8f810 D system_freezable_power_efficient_wq
因此,向这些工作队列添加条目并接收kworker
工作相对简单。但是,由于kCFI
,我只能调用具有以下签名的函数:
void (func*)(struct work_struct *work)
问题在于我是否能找到一个足够强大的函数来运行。结果发现这相当简单。函数call_usermodehelper_exec_work
,通常用于内核漏洞来运行 shell 命令,符合要求,并将运行我提供的 shell 命令。因此,通过修改 并向其中system_unbound_wq
添加一个包含指向 的指针的条目call_usermodehelper_exec_work
,我可以绕过三星的 RKP 和 kCFI 以 root 身份运行任意命令。
您可以在此处找到该漏洞及其一些设置说明。
结论
在这篇文章中,我利用了具有相当典型原语的 UAF,并研究了各种缓解措施如何影响该漏洞。虽然最后我能够绕过所有缓解措施并开发出一个漏洞,其可靠性不亚于我去年开发的另一个漏洞,但缓解措施确实迫使该漏洞采取了一条非常不同且更长的路径。
最大的障碍是 kCFI,它将一个相对简单的漏洞利用变成了一个相当复杂的漏洞利用。正如文章中所解释的那样,UAF 漏洞提供了许多原语来执行任意函数指针。再加上单独的信息泄露(我碰巧有,正在等待披露),这个漏洞很容易被利用,就像我去年写的NPU 漏洞一样。相反,三星的 RKP 和 kCFI 的结合让这变得不可能,迫使我寻找一条不那么简单的替代路径。
另一方面,这里介绍的许多技术,例如“终极假对象存储”和“设备内存镜像攻击”中的技术,可以轻松标准化,以将常见原语转变为任意内存读写。正如我们所见,即使受到三星 RKP 的限制,任意内存读写也已经足够强大,可以用于许多目的。在这方面,在我看来,kCFI 的效果可能是将利用技术转向某些原语,而不是使许多漏洞无法利用。毕竟,正如许多人所说,这是一种在流程后期发生的缓解措施。
一个被低估的缓解措施可能是自动变量初始化。虽然这种缓解措施主要针对利用未初始化变量的漏洞,但我们已经看到它还可以防止部分对象替换,这是一种常见的利用技术。事实上,如果不是运气好,我能够泄露堆栈变量的地址(参见“逃离无限循环”),它几乎破坏了漏洞利用。这种缓解措施不仅可以消除一整类错误,还可以破坏一些有用的漏洞利用原语。
我们还看到了操纵内核调度程序的原语如何使我们能够扩大许多竞争窗口,以便有时间进行对象替换。这使我能够克服由延迟释放kfree_rcu
(不是缓解措施)引起的问题,而不会显著损害可靠性。看来,在内核上下文中,通过隔离和延迟释放对象(Scudo 分配器为 Android 用户进程采用的方法)来缓解 UAF 可能并不是那么有效。
原文始发于微信公众号(红云谈安全):Android 内核缓解障碍赛
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论