攻击 Android Binder:对 CVE-2023-20938 的分析和利用

admin 2024年7月31日20:40:59评论70 views字数 20189阅读67分17秒阅读模式

Binder 是 Android 上的主要进程间通信(IPC)通道。它支持各种功能,如在进程边界传递文件描述符和包含指针的对象。它由 Android 平台提供的用户空间库( libbinderlibhwbinder)以及 Android 公共内核中的内核驱动程序组成。因此,它为 Java 和本机代码提供了一个通用的 IPC 接口,可以在 AIDL 中定义。术语“Binder”通常用于指代其实现的许多部分(甚至在 Android SDK 中有一个名为 Binder 的 Java 类),但在本文中,除非另有说明,我们将使用术语“Binder”来指代 Binder 设备驱动程序。

绑定器设备驱动程序 (/dev/binder)

所有 Android 上的不受信任的应用程序都被放置在沙盒中,进程间通信主要通过 Binder 进行。同时,Android 上的 Chrome 渲染器进程被分配了 isolated_app SELinux 上下文,比不受信任的应用程序更加严格。尽管如此,它也可以访问 Binder 和一组有限的 Android 服务。因此,Binder 呈现了一个广泛的攻击面,因为默认情况下每个不受信任的和隔离的应用程序都可以访问它。

Binder 漏洞的历史

以下是最近利用 Binder 中的漏洞实现获取 root 权限的一些利用漏洞的列表:

  • CVE-2019-2025 Waterdrop: 幻灯片,视频
  • CVE-2019-2215 Bad Binder: 博客,视频
  • CVE-2020-0041: 博客
  • CVE-2020-0423 台风山竹: 幻灯片, 视频
  • CVE-2022-20421 Bad Spin: 白皮书,视频

为了提供高性能的 IPC,Binder 由一个极其复杂的对象生命周期、内存管理和并发线程模型组成。为了让大家感受到这种复杂性,我们在实现驱动程序的同一个 6.5k 行文件中计算了三种不同类型的并发同步原语(5 个锁、6 个引用计数器和一些原子变量)。Binder 中的锁定也非常细粒度,出于性能原因,进一步增加了代码的复杂性。

近年来,利用几个安全问题成功对 Binder 进行了多次攻击,主要是由使用后释放漏洞引起的。这些漏洞源于各种根本原因,包括不当的清理逻辑(CVE-2019-2215 和 CVE-2022-20421)、数据竞争(CVE-2020-0423)和对象内越界访问(CVE-2020-0041)。本博客提供了有关 UAF 问题的信息,这是由于在处理 Binder 事务时清理实现不当导致的引用计数错误。

使用 Binder 进行 RPC 调用

本节将描述用户空间程序如何与 Binder 交互。本节提供了 Binder 是什么以及用户空间应用程序如何与 Android 上的 Binder 交互的快速概述,以帮助说明 Binder 中的一些概念。但是,如果您已经熟悉 Binder,请随意跳过这部分并转到漏洞部分。

初始化 Binder 终端

通过 Binder 执行 IPC 的程序开发与使用其他类型的套接字(例如网络套接字等)有些不同。

每个客户端首先 打开 Binder 设备,并使用返回的文件描述符创建内存映射:


  1. int fd = open("/dev/binder", O_RDWR, 0);
  2. void *map = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, ctx->fd, 0);

这个内存将用于 Binder 驱动程序的内存分配器,用于存储所有传入的事务数据。这个映射对客户端是只读的,但对驱动程序是可写的。

发送和接收交易

与调用 sendrecv系统调用不同,客户端通过向 Binder 驱动程序发送 BINDER_WRITE_READ ioctl 来执行大多数 IPC 交互。 ioctl 的参数是一个 structbinder_write_read对象:


  1. struct binder_write_read bwr = {
  2. .write_size = ...,
  3. .write_buffer = ...,
  4. .read_size = ...,
  5. .read_buffer = ...
  6. };
  7. ioctl(fd, BINDER_WRITE_READ, &bwr);

write_buffer指针字段指向一个用户空间缓冲区,其中包含来自客户端发送到驱动程序的命令列表。同时, read_buffer指针字段指向一个用户空间缓冲区,Binder 驱动程序将从驱动程序写入客户端的命令。

注意:这种设计的动机是客户端可以发送一个事务,然后等待一个 ioctl 系统调用的响应。相比之下,使用套接字的 IPC 需要两个系统调用, sendrecv

下面的图表显示了向与 Ref 0 相关联的客户端发送交易时涉及的数据( target.handle),并且交易包含一个 Node 对象( BINDER_TYPE_BINDER):

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

write_buffer 指向一个包含 BC_* 命令及其关联数据的缓冲区。 BC_TRANSACTION 命令指示 Binder 发送一个事务 struct_binder_transaction_dataread_buffer 指向一个已分配的缓冲区,当有传入事务时,Binder 将填充该缓冲区。

structbinder_transaction_data 包含一个目标句柄和两个缓冲区, bufferoffsetstarget.handle 是与接收方关联的 Ref ID,我们将在稍后讨论它是如何创建的。 buffer 指向一个包含 Binder 对象和不透明数据混合的缓冲区。 offsets 指向一个偏移量数组,其中每个 Binder 对象位于 buffer 中的位置。 在使用 BINDER_WRITE_READ ioctl 执行读取后,接收方将在 read_buffer 中接收到此 structbinder_transaction_data 的副本。

用户可以通过在事务数据中包含一个 structflat_binder_object,并将 type字段设置为 BINDER_TYPE_BINDER来发送一个 Node。Node 是一种 Binder 对象的类型,我们将在下一节中更详细地讨论。

与另一个进程建立连接

Binder 使用对象,如节点和引用,来管理进程之间的通信通道。

如果一个进程想要允许另一个进程与其通信,它会向该进程发送一个节点。然后 Binder 在目标进程中创建一个新的引用,并将其与节点关联起来,建立连接。稍后,目标进程可以使用引用向拥有与引用关联的节点的进程发送事务。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

上面的图像说明了 App A 如何为 App B 与自身建立连接的步骤,以便 App B 可以向 App A 发送交易以执行 RPC 调用。

步骤如下:

  1. App A 发送一个包含 ID 为 0xbeef 的节点的交易给 App B。该交易类似于上面显示的交易,并且该节点由 structflat_binder_object数据表示。
  2. Binder 将 Node 0xbeef 与 App A 内部关联,并初始化一个引用计数器,以跟踪引用它的 Refs 数量。在实际实现中,在底层 Node 数据结构( structbinder_node)中有 4 个引用计数器,我们稍后会介绍。
  3. Binder 在内部创建一个属于 App B 的 Ref 0xbeef,并引用 App A 的 Node 0xbeef。这一步 增加 了 Node 0xbeef 的引用计数 1。
  4. 现在,App B 可以通过在其 structbinder_transaction_data 中使用 0xbeef 作为 target.handle,向 App A 发送交易。当 Binder 处理 B 发送的交易时,它可以发现 Ref 0xbeef 引用了 App A 的 Node 0xbeef,并将交易发送给 App A。

绑定器上下文管理器

有人可能会问一个问题:如果 App A 和 App B 之间一开始没有连接,那么 App A 如何向 App B 发送一个 Node 呢?首先,除了像上面展示的那样从一个进程发送一个 Node 对象到另一个进程之外,也可以以类似的方式发送一个 Ref 对象。例如,假设存在另一个 App C,那么 App B 可以将 Ref(在上述第 3 步创建)发送给 App C。一旦 App C 从 App B 接收到 Ref,它就可以使用 Ref 向 App A 发送交易。

其次,Binder 启用了一种特殊的过程,通过 BINDER_SET_CONTEXT_MGR ioctl 来声明自己为上下文管理器,只有一个单一进程可以担任该角色。上下文管理器是一个特殊的 Binder IPC 端点,始终可通过句柄(Ref)0 访问,作为一个中介,使 Binder IPC 端点可以被其他进程发现。

例如,一个进程客户端 1 向上下文管理器发送一个节点(例如 0xbeef),然后接收到一个引用(0xbeef)。然后,另一个第三个进程客户端 2 启动一个事务到上下文管理器,请求那个引用(0xbeef)。上下文管理器通过返回引用(0xbeef)来响应请求。因此,这建立了两个进程之间的连接,因为客户端 2 现在可以使用引用(0xbeef)向客户端 1 发送事务。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

在 Android 上,ServiceManager 进程在启动期间将自身声明为上下文管理器。系统服务使用 Binder 节点向上下文管理器注册,以便其他应用程序可以发现它们。

弱点

客户端可以在事务中包含一个 Binder 对象 ( structbinder_object),它可以是以下任何一种:

名称 枚举 描述
节点 BINDERTYPEBINDERBINDERTYPEWEAK_BINDER 一个节点
参考 BINDERTYPEHANDLEBINDERTYPEWEAK_HANDLE 对节点的引用
指针 BINDERTYPEPTR 用于传输数据的内存缓冲区的指针
文件描述符 BINDERTYPEFD 文件描述符
文件描述符数组 BINDERTYPEFDA 文件描述符数组

在将所有 Binder 对象发送给接收者之前,Binder 必须在函数中将这些对象从发送者的上下文转换为接收者的上下文:


  1. static void binder_transaction(...)
  2. {
  3. ...
  4. // Iterate through all Binder objects in the transaction
  5. for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
  6. buffer_offset += sizeof(binder_size_t)) {
  7. // Process/translate one Binder object
  8. }
  9. ...
  10. }

例如,考虑这样一个场景:一个客户端通过 Binder 发送文件描述符将文件共享给另一个客户端。为了让接收方能够访问文件,Binder 通过在接收方的任务进程中安装一个新的文件描述符来转换文件描述符。

注意:当接收方调用 BINDER_WRITE_READ ioctl 时,某些对象实际上在接收事务时被翻译,而其他对象在发送事务时由发送方翻译 - 当发送方调用 BINDER_WRITE_READ ioctl 时。

存在一个错误处理的代码路径[1],当处理一个具有不对齐的 offsets_size的事务时。请注意,Binder 跳过了处理 Binder 对象的 for 循环,因此 buffer_offset保持为 0,然后作为参数传递给 binder_transaction_buffer_release函数调用[2]:


  1. static void binder_transaction(..., struct binder_transaction_data *tr, ...)
  2. {
  3. binder_size_t buffer_offset = 0;
  4. ...
  5. if (!IS_ALIGNED(tr->offsets_size, sizeof(binder_size_t))) { // [1]
  6. goto err_bad_offset;
  7. }
  8. ...
  9. // Iterate through all Binder objects in the transaction
  10. for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
  11. buffer_offset += sizeof(binder_size_t)) {
  12. // Process a Binder object
  13. }
  14. ...
  15. err_bad_offset:
  16. ...
  17. binder_transaction_buffer_release(target_proc, NULL, t->buffer,
  18. /*failed_at*/buffer_offset, // [2]
  19. /*is_failure*/true);
  20. ...
  21. }

binder_transaction_buffer_release是一个函数,用于撤销处理事务中 Binder 对象后 Binder 引起的每个副作用。例如,在接收方进程的任务中关闭已打开的文件描述符。在错误处理情况下,Binder 必须仅清理已处理过的 Binder 对象,然后才能处理错误。函数中的 failed_atis_failure参数确定 Binder 必须清理多少个 Binder 对象。

回到未对齐的 offsets_size错误处理路径,其中 failed_at==0is_failure==true。在这种情况下,Binder 计算 off_end_offset为事务缓冲区的末尾。因此,Binder 清理事务中的每个 Binder 对象。然而,Binder 实际上没有处理任何 Binder 对象,因为它遇到错误并跳过了处理 Binder 对象的 for 循环。


  1. static void binder_transaction_buffer_release(struct binder_proc *proc,
  2. struct binder_thread *thread,
  3. struct binder_buffer *buffer,
  4. binder_size_t failed_at/*0*/,
  5. bool is_failure/*true*/)
  6. {
  7. ...
  8. off_start_offset = ALIGN(buffer->data_size, sizeof(void *));
  9. off_end_offset = is_failure && failed_at ? failed_at
  10. : off_start_offset + buffer->offsets_size;
  11. for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
  12. buffer_offset += sizeof(size_t)) {
  13. ...
  14. }
  15. ...
  16. }

这种逻辑的原因是 failed_at的含义被重载:代码的其他部分使用这种逻辑来清理整个缓冲区。然而,在这种情况下,我们已经进入了这段代码路径,而没有处理任何对象,这将在引用计数中引入不一致性。在接下来的部分中,我们将演示如何利用这个漏洞来实现对 Binder 节点对象的使用后释放,并将其转化为特权升级 PoC。

剥削

在之前发布的 CVE-2020-004 漏洞中,蓝霜安全利用了相同的清理过程来实现根权限。然而,他们利用了一个漏洞,在 Binder 处理完事务后修改了 Binder 对象。他们发布了一个 PoC 来演示他们在运行内核版本 4.9 的 Pixel 3 上的根权限提升。

我们从过去的这次利用中汲取灵感,首先在 Binder 中实现了相同的泄漏和解除链接原语。由于新内核版本中 SLUB 分配器缓存的一些变化,我们采用了不同的方法对受害对象执行释放后使用。我们将在后面的部分解释这些变化以及我们是如何克服它们的。

绑定器节点的 UAF

一个节点( structbinder_node)是一个在事务中具有标题类型 BINDER_TYPE_BINDERBINDER_TYPE_WEAK_BINDER的 Binder 对象( structflat_binder_object)。当客户端向另一个客户端发送一个节点时,Binder 在内部创建一个节点。Binder 还在节点中管理几个引用计数器,以确定其生命周期。在本节中,我们演示如何利用上述描述的漏洞来引入节点对象一个引用计数器的不一致性,从而导致释放该对象同时仍具有指向它的悬空指针,从而导致使用后释放。

binder_transaction_buffer_release函数遍历缓冲区中的所有 Binder 对象并遇到具有头部类型 BINDER_TYPE_BINDERBINDER_TYPE_WEAK_BINDER的节点时,它调用 binder_get_node函数来检索属于接收进程上下文( proc)并具有节点 ID 等于 fp->binderbinder_node(下面的清单中的[1])。

然后,它调用 binder_dec_node函数来减少其引用计数器之一([2]在下面的清单中)。假设我们在事务中有一个带有标题类型 BINDER_TYPE_BINDER的节点,那么 Binder 调用 binder_dec_node函数,并传递表达式 strong==1internal==0作为函数参数。


  1. static void binder_transaction_buffer_release(...)
  2. {
  3. ...
  4. for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
  5. buffer_offset += sizeof(size_t)) {
  6. ...
  7. case BINDER_TYPE_BINDER:
  8. case BINDER_TYPE_WEAK_BINDER: {
  9. ...
  10. // [1]
  11. node = binder_get_node(proc, fp->binder);
  12. ...
  13. // [2]
  14. binder_dec_node(node, /*strong*/ hdr->type == BINDER_TYPE_BINDER, /*internal*/ 0);
  15. ...
  16. } break;
  17. ...
  18. }
  19. ...
  20. }

注意:术语 Node 和 binder_node是可互换的,但术语 binder_node通常指 Binder 中的基础 Node 数据结构。术语 Node 也用于指代具有标题类型 BINDER_TYPE_BINDERBINDER_TYPE_WEAK_BINDER的事务中的 structflat_binder_object

binder_dec_node函数调用 binder_dec_node_nilocked来减少 binder_node的引用计数之一([1]在下面的清单中)。如果 binder_dec_node_nilocked返回 true,则该函数将调用 binder_free_node来释放 binder_node([2]在下面的清单中)。这正是我们希望采取的分支,以实现 UAF。


  1. static void binder_dec_node(struct binder_node *node, int strong /*1*/, int internal /*0*/)
  2. {
  3. bool free_node;
  4. binder_node_inner_lock(node);
  5. free_node = binder_dec_node_nilocked(node, strong, internal); // [1]
  6. binder_node_inner_unlock(node);
  7. if (free_node)
  8. binder_free_node(node); // [2]
  9. }

注意:Binder 中有许多带有后缀 *locked 的函数,这些函数期望调用者在调用它们之前已经获取了必要的锁。有关所有后缀的更多详细信息可以在 /drivers/android/binder.c 代码的顶部找到。

binder_dec_node_nilocked函数中,如果 strong==1并且 internal==0,它会递减 local_strong_refs字段在 binder_node


  1. static bool binder_dec_node_nilocked(struct binder_node *node,
  2. int strong /*1*/, int internal /*0*/)
  3. {
  4. ...
  5. if (strong) {
  6. if (internal)
  7. ...
  8. else
  9. node->local_strong_refs--;
  10. ...
  11. } else {
  12. ...
  13. }
  14. ...
  15. }

因此,要触发漏洞,我们可以发送一个带有头部类型设置为 BINDER_TYPE_BINDERbinder 字段设置为节点 ID( structbinder_node)的 Node 对象的交易,以减少其 local_strong_refs 引用计数的值。下面的图表显示了一笔恶意交易,利用漏洞两次减少接收客户端节点 0xbeef 中的引用计数。该交易包含两个节点( structflat_binder_object)和一个不对齐的 offsets_size

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

未对齐的 offsets_size导致 Binder 在 binder_transaction函数中采取易受攻击的错误处理路径,跳过处理事务中的两个节点。这利用 binder_transaction_buffer_release函数清理这两个节点,这会将节点 0xbeef 的 local_strong_refs减少两次 - 每个事务中的 2 个 structflat_binder_ojbect对象各减少一次。

现在,让我们分析在 structbinder_node需要满足哪些条件才能在 binder_dec_node函数中被释放(即在什么条件下 binder_dec_node_nilocked返回 true强制 binder_dec_node释放 binder_node)。根据下面的代码片段, binder_dec_node_nilocked根据 structbinder_node中几个字段的值返回 true。


  1. static bool binder_dec_node_nilocked(struct binder_node *node,
  2. int strong /*1*/, int internal /*0*/)
  3. {
  4. ...
  5. if (strong) {
  6. if (internal)
  7. ...
  8. else
  9. node->local_strong_refs--;
  10. if (node->local_strong_refs || node->internal_strong_refs)
  11. return false;
  12. } else {
  13. ...
  14. }
  15. if (proc && (node->has_strong_ref || node->has_weak_ref)) {
  16. ...
  17. } else {
  18. if (hlist_empty(&node->refs) && !node->local_strong_refs &&
  19. !node->local_weak_refs && !node->tmp_refs) {
  20. ...
  21. return true;
  22. }
  23. }
  24. return false;
  25. }

为了确保在减少 localstrongrefs 后 binderdecnode_nilocked 返回 true,我们必须传递一个满足以下条件的节点:


  1. // Reference counters in struct binder_node
  2. local_strong_refs == 1 // before `binder_dec_node_nilocked` decrements it
  3. local_weak_refs == 0
  4. internal_strong_refs == 0
  5. tmp_refs == 0
  6. has_strong_ref == 0
  7. has_weak_ref == 0
  8. hlist_empty(&node->refs) == true

因此,要在函数中释放对象,我们必须设置一个没有任何引用指向它且所有引用计数都等于零,除了 local_strong_refs之外的。然后,我们可以利用漏洞来减少 local_strong_refs并导致它被释放。

一种设置 binder_node的简单方法如下:

  1. 客户 A 和客户 B 使用节点 0xbeef 和 Ref 0xbeef(参考先前的图表)之间建立连接。节点以 local_strong_refs开始,因为只有 Ref 对象在引用节点。
  2. 客户端 B 发送一个带有 target.handle设置为 0xbeef的事务。Binder 处理它,在客户端 A 的一侧分配一个 binder_buffer,并将事务数据复制到分配的缓冲区中。此时,节点的 local_strong_refs等于 2,因为 Ref 对象和事务都在引用该节点。
  3. 客户端 B 关闭 Binder 文件描述符,释放 Ref 对象并将 local_strong_refs减 1。现在,节点的 local_strong_ref回到 1,因为只有事务在引用节点。

下面的图表说明了在利用漏洞释放之前和之后的的设置:

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

在利用漏洞释放 binder_node 后,这会在 target_nodebinder_buffer 中留下一个悬空指针。在接下来的部分中,我们多次利用此 use-after-free 来获取我们的利用程序所需的基本操作,以获取 Android 设备的 root 权限。

首先,我们获得了一个有限的泄漏原语,使我们能够从内核堆中泄漏 16 字节(2 个 8 字节值)。我们在此基础上构建了下一阶段的解链原语,使我们能够用攻击者控制的数据覆盖内核内存。接下来,我们利用泄漏和解链原语来获得任意内核内存读取原语,用于识别我们想要覆盖的内核结构的地址,最终在设备上获得 root 权限。

泄漏原始

首先,我们利用在 binder_node对象的 binder_thread_read函数中使用已释放的 use-after-free 读漏洞。当客户端执行 BINDER_WRITE_READ ioctl 以从 Binder 读取传入事务时,Binder 调用 binder_thread_read函数将传入事务复制回用户空间。此函数将 binder_node的两个字段( ptrcookie)复制到事务中([1]和[2])。然后,Binder 将事务复制回用户空间[3]。因此,我们可以导致 use-after-read 从内核堆内存泄漏两个值。


  1. static int binder_thread_read(...)
  2. {
  3. ...
  4. struct binder_transaction_data_secctx tr;
  5. struct binder_transaction_data *trd = &tr.transaction_data;
  6. ...
  7. struct binder_transaction *t = NULL;
  8. ...
  9. t = container_of(w, struct binder_transaction, work);
  10. ...
  11. if (t->buffer->target_node) {
  12. struct binder_node *target_node = t->buffer->target_node;
  13. trd->target.ptr = target_node->ptr; // [1]
  14. trd->cookie = target_node->cookie; // [2]
  15. ...
  16. }
  17. ...
  18. if (copy_to_user(ptr, &tr, trsize)) { // [3]
  19. ...
  20. }
  21. ...
  22. }

binder_node 对象是从 kmalloc-128 SLAB 缓存中分配的,两个 8 字节的泄漏位于偏移量 88 和 96。


  1. gdb> ptype /o struct binder_node
  2. /* offset | size */ type = struct binder_node {
  3. ...
  4. /* 88 | 8 */ binder_uintptr_t ptr;
  5. /* 96 | 8 */ binder_uintptr_t cookie;

取消原始连接

binder_dec_node_nilocked函数中的 unlink 操作可能存在使用后释放的情况[1]。但在执行 unlink 操作之前还有多个检查。


  1. static bool binder_dec_node_nilocked(struct binder_node *node,
  2. int strong, int internal)
  3. {
  4. struct binder_proc *proc = node->proc;
  5. ...
  6. if (strong) {
  7. ...
  8. if (node->local_strong_refs || node->internal_strong_refs)
  9. return false;
  10. } else {
  11. ...
  12. }
  13. if (proc && (node->has_strong_ref || node->has_weak_ref)) {
  14. ...
  15. } else {
  16. if (hlist_empty(&node->refs) && !node->local_strong_refs &&
  17. !node->local_weak_refs && !node->tmp_refs) {
  18. if (proc) {
  19. ...
  20. } else {
  21. BUG_ON(!list_empty(&node->work.entry));
  22. ...
  23. if (node->tmp_refs) {
  24. ...
  25. return false;
  26. }
  27. hlist_del(&node->dead_node); // [1]
  28. ...
  29. }
  30. return true;
  31. }
  32. }
  33. return false;
  34. }

__hlist_del函数中实现的取消链接操作基本上是修改两个内核指针指向彼此。


  1. static inline void __hlist_del(struct hlist_node *n)
  2. {
  3. struct hlist_node *next = n->next;
  4. struct hlist_node **pprev = n->pprev;
  5. WRITE_ONCE(*pprev, next);
  6. if (next)
  7. WRITE_ONCE(next->pprev, pprev);
  8. }

可以总结为:


  1. *pprev = next
  2. *(next + 8) = pprev

要达到取消链接操作,我们必须重新分配已释放的 binder_node对象,使用一个我们控制数据的假 binder_node对象。为此,我们可以使用众所周知的 sendmsg堆喷射技术,在已释放的 binder_node上方分配一个带有任意数据的对象。

由于在取消链接操作之前有多个检查,我们必须填充正确的数据到假的 binder_node中以通过它们。我们必须创建一个具有以下条件的假的 binder_node对象:


  1. node->proc == 0
  2. node->has_strong_ref == 0
  3. node->has_weak_ref == 0
  4. node->local_strong_refs == 0
  5. node->local_weak_refs == 0
  6. node->tmp_refs == 0
  7. node->refs == 0 // hlist_empty(node->refs)
  8. node->work.entry = &node->work.entry // list_empty(&node->work.entry)

最后一个条件很难满足,因为我们必须已经知道释放的的地址才能计算正确的 &node->work.entry。幸运的是,我们可以使用我们的泄漏原语在利用漏洞释放之前泄漏地址。以下是我们可以完成此操作的方法。

泄漏一个 binder_node 地址

一个 binder_ref对象是从 kmalloc-128 SLAB 缓存中分配的,并且包含一个指向相应 binder_node对象的指针,确切地位于偏移量 88 处(正如您记得我们上面讨论的泄漏原语,在使用后释放读取期间泄漏了偏移量 88 和 96 处的两个 8 字节值)。


  1. gdb> ptype /o struct binder_ref
  2. /* offset | size */ type = struct binder_ref {
  3. ...
  4. /* 88 | 8 */ struct binder_node *node;
  5. /* 96 | 8 */ struct binder_ref_death *death;

因此,我们可以通过以下步骤向 binder_node泄露地址:

  1. 利用漏洞释放一个 binder_node
  2. 在释放的上分配一个。
  3. 使用泄漏原语将地址泄漏到 binder_nodebinder_ref

一旦我们泄漏了已释放的 binder_node对象的地址,我们就拥有设置解链原语所需的所有必要数据。在用 sendmsg重新分配我们的伪造 binder_node后,我们发送一个 BC_FREE_BUFFER binder 命令来释放包含悬空 binder_node的事务,以触发解链操作。在这一点上,由于 __hlist_del函数的实现细节,我们实现了有限的任意写入原语-我们用有效的内核指针或 NULL 覆盖内核内存。

任意读取原始数据

CVE-2020-0041 的利用程序利用 FIGETBSZ ioctl 来获取任意读取原语。 FIGETBSZ ioctl 将与 s_blocksize成员对应的 4 个字节数据从内核复制回用户空间,如下所示[1]。


  1. static int do_vfs_ioctl(struct file *filp, ...) {
  2. ...
  3. struct inode *inode = file_inode(filp);
  4. ...
  5. case FIGETBSZ:
  6. ...
  7. return put_user(inode->i_sb->s_blocksize, (int __user *)argp); // [1]
  8. ...
  9. }
  10. ioctl(fd, FIGETBSZ, &value); // &value == argp

下面的图表显示了由 s_blocksize字段引用的 structfilestructinode结构体的位置。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

我们可以执行一个 unlink 写操作来修改 inode指针,使其指向我们知道地址的 structepitem。由于我们可以直接控制 event.data字段(位于结构体开头偏移 40 字节处)中的 structepitem,通过 epoll_ctl将其指向内核地址空间的任何位置,然后我们可以轻松地修改上面显示的 i_sb字段(也位于偏移 40 处)为任意值。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

然后,我们可以使用 FIGETBSZ ioctl 和 epoll_ctl作为我们的任意读取原语,从内核地址空间的任何位置读取 4 字节值。但是,我们必须首先知道 structfilestructepitem对象的内核地址。

泄漏 structfile 地址

structepitem 包含两个内核指针( nextprev)分别在偏移 88 和 96。


  1. gdb> ptype /o struct epitem
  2. /* offset | size */ type = struct epitem {
  3. ...
  4. /* 88 | 16 */ struct list_head {
  5. /* 88 | 8 */ struct list_head *next
  6. /* 96 | 8 */ struct list_head *prev;
  7. } fllink;

这两个内核指针( nextprev)形成了一个 structepitem对象的链表。链表的头部位于 structfile.f_ep_links。当我们使用泄漏原语将这些内核指针泄漏回用户空间时,其中一个指针将指向一个 structfile对象。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

在以前针对内核版本 4.9 的 CVE-2020-0041 的利用中,将 structepitem分配在已释放的 binder_node之上是直截了当的。由于缓存别名和 kmalloc-128 SLAB 缓存以 FIFO 方式工作,因此 structepitembinder_node都是从同一个 kmalloc-128 SLAB 缓存中分配的。因此,在释放 binder_node后,我们可以从与 binder_node相同的内存位置分配 structepitem

缓存别名是一个内核功能,它将多个 SLAB 缓存合并为一个单一的 SLAB 缓存,以提高效率。当这些 SLAB 缓存持有相似大小的对象并共享相似属性时,就会发生这种情况。有关缓存别名的更多详细信息,请参阅2022 年 Linux 内核堆风水博客。

在内核版本 5.10 中,一个 commit 添加了 SLAB_ACCOUNT 标志到 eventpoll_epi SLAB 缓存,因此 eventpoll_epikmalloc-128 不再共享相同的 SLAB 缓存。换句话说,一个 structepitem 不再从 kmalloc-128 SLAB 缓存中分配,这样就防止我们立即在已释放的 binder_node 之上分配它。

跨缓存攻击

跨缓存攻击是一种技术,用于在从不同缓存分配的另一个对象的顶部分配对象。这是可能的,因为内核中有多个级别的内存分配器,来自同一级别的缓存共享其层次结构中更高的内存分配器。 SLUB 分配器中的缓存( kmem_cache)从页面分配器获取页面并将其用作 slab。如果一个 kmem_cache将页面释放回页面分配器,另一个在分配期间需要额外内存的 kmem_cache将获取它。

注意:页面分配器是一个伙伴分配器,它具有不同顺序的连续空闲页面的缓存。不同的 kmem_cache使用不同数量的连续页面作为其 slab。幸运的是, kmalloc-128eventpoll_epi kmem_cache都使用顺序 0(2^0 = 1)页面作为 slab。因此,在执行跨缓存攻击时,我们不必整理页面分配器,可以安全地假设页面分配器对从中分配和释放的每个页面都以 FIFO 方式操作。

下面的图表显示了如何从先前释放的 binder_node中使用的相同内存区域分配 structepitem

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

要执行跨缓存攻击,我们必须将一个 4K 页从 kmalloc-128 释放回到页面分配器的 per-cpu 页面缓存,以便可以分配给 eventpoll_epi。在 kmalloc-128eventpoll_epi 中的每个 slab 都是一个 4K 页,可以容纳 32 个内核对象(4096 / 128)。

要控制整个块,我们必须分配 32 binder_object。然后,我们利用漏洞一次释放所有 binder_node,并留下指向它们的悬空指针。然而,SLUB 分配器不会立即将空块的页面释放给页面分配器,而是将其放在 kmalloc-128缓存的部分列表上,并将其冻结(通过设置 structpage.frozen字段)。

注意:SLUB 分配器使用 slabstructpage来存储元数据,例如正在使用的对象数量,部分列表中的下一页等。

每个 kmem_cache在部分列表中保存了一些 slab,这些 slab 可以是空的或部分空的,在将空 slab 释放回页分配器之前。SLUB 分配器跟踪部分列表中第一个 slab 的页( structpage.pobjects)中的空闲槽位数量。当 pobjects字段的值大于 kmem_cache.cpu_partial字段的值时,SLUB 分配器会解冻并释放每个空 slab 回页分配器。 set_cpu_partial函数确定 kmem_cache.cpu_partial的值,对于 kmalloc_128,它为 30。

然而,事实证明,在部分列表中有 30 个空槽是不足以使我们的空块被释放回页面分配器的。在进行 PoC 时,SLUB 分配器中存在一个会计错误,导致 pobjects跟踪部分列表上的块数而不是空槽数。因此,当 kmalloc-128在部分列表中有超过 30 个块时,SLUB 分配器开始将空块释放回页面分配器。

在我们的操作中,我们一次性分配 36 * 32(板块数量*板块中的对象数量) binder_node并释放它们。然后,我们分配超过 32 个 structepitem来使用 eventpoll_epi部分列表上的所有空槽位,因此 eventpoll_epi将从页面分配器中分配新页面。

最后,我们使用泄漏原语在所有悬空节点上读取偏移量为 88 和 96 的这两个字段的值。如果我们已经成功地在一个已经释放的 structepitem上分配了一个 binder_node,我们将在这些字段中找到内核地址,其中一个是 structfile的内核地址。

绑定缓冲区分配器

我们想要用填满整个 kmalloc-128slab,这样我们就可以利用这个漏洞创建到 slab 中每个对象的悬空指针,但是这里有一个挑战。


  1. slab
  2. +---------------+
  3. | *binder_node* |<---- dangling pointer
  4. +---------------+
  5. | *binder_node* |<---- dangling pointer
  6. +---------------+
  7. | ... |
  8. +---------------+

当我们发送一个事务时,Binder 还会从 kmalloc-128 缓存中分配其他内核对象,比如 structbinder_buffer 对象。 binder_buffer 对象保存有关事务缓冲区的信息以及指向由接收方客户端的 binder_proc 拥有的 binder_node 的指针。利用漏洞会将该指针转换为指向已释放的 binder_node 的悬空指针。


  1. slab
  2. +---------------+
  3. | ... |
  4. +---------------+
  5. | binder_buffer |----+
  6. +---------------+ | dangling pointer
  7. | *binder_node* |<---+
  8. +---------------+
  9. | ... |
  10. +---------------+

然而,我们目前无法释放这个 binder_buffer,因为我们需要它来触发泄漏和取消链接原语的使用后释放。因此,我们必须确保 binder_buffer不能从与 binder_node相同的 kmalloc-128内存池中分配。

Binder 实现了自己的内存分配器,为每个传入的事务分配内存,并将它们映射到接收者的映射内存映射中。内存分配器采用最佳适配分配策略,并使用 binder_buffer对象来跟踪所有已分配和空闲的内存区域。在分配新的事务缓冲区时,它会搜索一个相同大小的空闲 binder_buffer以便重用。如果没有可用的,则将一个较大的空闲 binder_buffer分成两部分:一个是请求的大小,另一个是剩余的大小。

为了防止 Binder 为每个事务分配一个新的,我们可以通过引起内存碎片化提前分配许多空闲的。我们可以通过发送多个不同大小的事务并有选择地释放其中一些来实现这一点。因此,这个过程在内存分配器中创建了间隙,这导致了许多可用于将来事务中重用的空闲。


  1. Binder buffer allocator
  2. +-----------------+----------+-----------------+----------+---------+
  3. | free (24) | used (8) | free (24) | used (8) | ... |
  4. +-----------------+----------+-----------------+----------+---------+

root

要获取 root 权限,我们执行以下步骤:

  1. 使用任意读取原语来查找我们进程的 task_structcred结构。

  1. struct binder_node *node;
  2. struct binder_proc *proc = node->proc;
  3. struct task_struct *task = proc->tsk;
  4. struct task_struct *cred = task->cred;
  1. 覆盖 structcred对象中的所有 ID 字段为 0(root 用户的 UID)。
  2. 通过用 0 覆盖 selinux.enforcing字段来禁用 SELinux。
  3. 启用 TIF_SECCOMP在当前任务标志中,并用 0 覆盖 seccomp 的掩码以绕过 seccomp。

演示

奖金:任意写原始数据

虽然我们在 PoC 中不需要任意写入原语来实现 root 权限,但我们想提供如何获取 write-what-where 原语的信息供参考。

我们的 unlink 原语可以在内核中的任意可写地址写入,但它只能写入 0 或有效(即可写入)内核地址的值。为了实现更强大的任意写入(写入-写入-何处),我们选择利用基于 360 Alpha Lab 的台风 Mangkhut 利用链中提出的技术在 structseq_file对象中的指针字段 buf


  1. struct seq_file {
  2. char *buf;
  3. ...
  4. };

structseq_file被实现为 Linux 的 seq_file接口的文件使用,例如 /proc/self/comm。打开 /proc/self/comm文件时,内核会创建一个 structseq_file并调用 comm_open函数。 comm_opencomm_show函数传递给 single_open函数,以定义文件读取时要显示的字符串。


  1. // fs/proc/base.c
  2. static int comm_open(struct inode *inode, struct file *filp)
  3. {
  4. return single_open(filp, comm_show, inode);
  5. }

comm_show将当前任务名称复制到 seq_file->buf缓冲区中(下面的清单中的[1])。


  1. // fs/proc/base.c
  2. static int comm_show(struct seq_file *m, void *v)
  3. {
  4. ...
  5. proc_task_name(m, p, false);
  6. ...
  7. }
  8. // fs/proc/array.c
  9. void proc_task_name(struct seq_file *m, struct task_struct *p, bool escape)
  10. {
  11. char *buf;
  12. size_t size;
  13. char tcomm[64];
  14. ...
  15. // `tcomm` is filled with the current task name
  16. ...
  17. size = seq_get_buf(m, &buf); // buf = m->buf
  18. if (escape) {
  19. ...
  20. } else {
  21. ret = strscpy(buf, tcomm, size); // [1]
  22. }
  23. }

我们可以打开 /proc/self/comm文件两次,在内核中分配两个实例 structseq_file。然后,我们使用 unlink 原语来覆盖第一个实例中的 structseq_file->buf字段,使其指向第二个实例中 structseq_file->buf字段的地址。

因此,这使我们能够通过将当前任务名称更改为目标地址的 8 字节值并在第一个 seq_file的文件描述符上调用 lseek(下面的列表中的[2])来将第二个实例中的 structseq_file->buf字段覆盖为任意内核地址。在文件描述符上调用 lseek将触发 comm_show函数,导致在结构的第二个实例中覆盖 structseq_file->buf字段与目标地址。


  1. // [2] Point `seq_file->buf` to arbitrary kernel address
  2. prctl(PR_SET_NAME,"xefxbexadxdexffxffxffxff&#x0;", 0, 0, 0);
  3. lseek(comm_fd1, 1, SEEK_SET); // comm_fd1 = First seq_file's file descriptor

下面的图表显示了 structseq_file实例的布局,其中 structseq_file->buf字段指向攻击者选择的地址。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

然后,我们可以对第二个 seq_file的文件描述符执行类似的操作,通过设置当前任务名称来写入我们控制的数据。因此,这为我们在内核内存中提供了一个更强大的任意写入(写入-写入-何处)原语。

奖金:Binder 节点引用计数解释

让我们来检查 structbinder_node的 4 个引用计数器。

local_strong_refslocal_weak_refs跟踪所有引用节点的事务中节点数量。请记住,事务中的节点( structflat_binder_object)具有与 Binder 内部为簿记而创建的节点( structbinder_node)不同的数据结构。Binder 确保每个 binder_node在具有对其引用的事务中的节点存在时不会消失。

在打开 Binder 设备文件后,我们调用 mmap 来提供一个共享内存映射,Binder 用来存储传入事务的数据。Binder 实现了一个缓冲区分配器来管理该共享内存映射,它分配一个 structbinder_buffer 来占据内存映射的一部分,用来存储传入事务数据。在 structbinder_buffer 中的 target_node 字段引用了属于接收客户端的 binder_node,这会增加该 binder_nodelocal_strong_refs 引用计数。

internal_strong_refs跟踪其他客户端引用节点的 Ref 数量。

下面的图表说明了一个场景,其中客户端 A 有一个包含两个节点的传入交易,客户端 B 有一个引用 0xbeef( binder_ref)指向客户端 A 的节点 0xbeef( binder_node)。最重要的是,它突出显示了这些数据结构如何增加节点 0xbeef 的引用计数器。

攻击 Android Binder:对 CVE-2023-20938 的分析和利用

当 Binder 将一个变量分配给一个指向 binder_node的指针时,它使用 tmp_refs来保持 binder_node在指针在其范围内使用时保持活动。下面的代码显示了一个基本示例:


  1. struct binder_node *node = ...;
  2. binder_inc_node_tmpref(node);
  3. // Access `node` safely
  4. binder_dec_node_tmpref(node); // `node` can no longer be used after this.
  5. Otherwise, there can be race conditions.

Binder 还在至少有一个引用指向时,设置 has_strong_refhas_weak_ref标志。

binder_node->refs指向一个 Refs 列表的头部。

修复和结论

此博客中描述的问题已在两个 Android 安全公告中得到解决:

  • CVE-2023-20938 in 2023-02-01
  • CVE-2023-21255 in 2023-07-01

CVE-2023-20938 最初是在 2023 年 2 月的 Android 安全公告中通过将 一个补丁 回溯到受影响的内核来解决的。然而,进一步的分析显示,该补丁并未完全减轻潜在的根本原因,仍然有可能通过不同的路径到达漏洞。因此,新的 CVE-2023-21255 被分配,并且根本原因在 2023 年 7 月的 Android 安全公告中被 完全减轻。


声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与技术交流之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任。

原文始发于微信公众号(车联网攻防日记):攻击 Android Binder:对 CVE-2023-20938 的分析和利用

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年7月31日20:40:59
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   攻击 Android Binder:对 CVE-2023-20938 的分析和利用http://cn-sec.com/archives/3020499.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息