在 OffensiveCon 2024 上,Android Red Team 做了一个演示(幻灯片),介绍了如何查找和利用 CVE-2023-20938,这是 Android Binder 设备驱动程序中的一个释放后使用漏洞。这篇文章将提供有关此漏洞的技术细节,以及我们的团队如何利用该漏洞在完全更新(在利用时)的 Android 设备上从不受信任的应用程序获得 root 权限。此漏洞影响了所有使用 GKI 内核版本 5.4 和 5.10 的 Android 设备。
该漏洞已修复,补丁作为Android 安全公告(2023 年 2 月和2023 年 7 月)的一部分发布(更多详细信息请参阅博客的补救部分)。
Binder
Binder 是 Android 上的主要进程间通信 (IPC) 通道。它支持多种功能,例如跨进程边界传递文件描述符和包含指针的对象。它由 Android 平台提供的用户空间库(libbinder和libhwbinder)和 Android 通用内核中的内核驱动程序组成。因此,它为 Java 和本机代码提供了一个通用的 IPC 接口,可以在AIDL中定义。术语“Binder”通常用于指代其实现的许多部分(Android SDK 中甚至有一个名为Binder的 Java 类),但在本文中,我们将使用术语“Binder”来指代 Binder 设备驱动程序,除非另有说明。
Binder 设备驱动程序(/dev/binder)
Android 上所有不受信任的应用都经过沙盒处理,进程间通信大多通过 Binder 进行。同时,Android 上的 Chrome 渲染器进程被分配了isolated_appSELinux 上下文,这比不受信任的应用限制更严格。尽管如此,它也可以访问 Binder 和一组有限的Android 服务。因此,Binder 呈现出广泛的攻击面,因为默认情况下每个不受信任和隔离的应用都可以访问它。
Binder 漏洞的历史
以下是近期利用 Binder 漏洞获取 root 权限的攻击列表:
-
CVE-2019-2025 Waterdrop
-
CVE-2019-2215 Bad Binder
-
CVE-2020-0041
-
CVE-2020-0423
-
CVE-2022-20421
为了提供高性能 IPC,Binder 包含极其复杂的对象生命周期、内存管理和并发线程模型。为了了解这种复杂性,我们统计了三种不同类型的并发同步原语(5 个锁、6 个引用计数器和一些原子变量),它们都在实现驱动程序的同一个 6.5k 行文件中使用。出于性能原因,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 的程序与使用其他类型的套接字(例如网络套接字等)略有不同。
每个客户端首先访问openBinder 设备并使用返回的文件描述符创建内存映射:
int fd = open("/dev/binder", O_RDWR, 0);
void *map = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, ctx->fd, 0);
该内存将用于 Binder 驱动程序的内存分配器,用于存储所有传入的事务数据。此映射对于客户端是只读的,但可由驱动程序写入。
发送和接收交易
客户端无需调用send和recv系统调用,而是通过将 ioctl 发送到 Binder 驱动程序来执行大多数 IPC 交互BINDER_WRITE_READ。ioctl 的参数是一个struct binder_write_read对象:
struct binder_write_read bwr = {
.write_size = ...,
.write_buffer = ...,
.read_size = ...,
.read_buffer = ...
};
ioctl(fd, BINDER_WRITE_READ, &bwr);
指针write_buffer字段指向一个用户空间缓冲区,其中包含从客户端到驱动程序的命令列表。同时,指针字段read_buffer指向一个用户空间缓冲区,Binder 驱动程序将在该缓冲区中写入从驱动程序到客户端的命令。
注意:此设计的动机是客户端可以发送事务,然后使用一个 ioctl 系统调用等待响应。相比之下,使用套接字的 IPC 需要两个系统调用,send并且recv。
下图显示了向链接到 Ref 0 的客户端发送交易时所涉及的数据(target.handle),并且该交易包含一个 Node 对象(BINDER_TYPE_BINDER):
指向write_buffer包含命令列表BC_*及其相关数据的缓冲区。BC_TRANSACTION命令指示 Binder 发送事务struct_binder_transaction_data。read_buffer指向分配的缓冲区,当有事务传入时,Binder 将填充该缓冲区。
包含struct binder_transaction_data一个目标句柄和两个缓冲区,buffer以及offsets。target.handle是与接收方关联的 Ref ID,我们将在后面讨论如何创建它。buffer指向包含 Binder 对象和不透明数据混合的缓冲区。指向offsets每个 Binder 对象在 中的偏移量数组。接收方在使用 ioctl 执行读取后,将在 中buffer收到此副本。struct binder_transaction_dataread_bufferBINDER_WRITE_READ
用户可以通过在交易数据中包含一个设置为 的字段来struct flat_binder_object发送Node。Node是一种 Binder 对象,我们将在下一节中进一步讨论。typeBINDER_TYPE_BINDER
与另一个进程建立连接
Binder 使用 Node 和 Ref 等对象来管理进程之间的通信通道。
如果某个进程希望允许另一个进程与其通信,它会向该进程发送一个节点。然后,Binder 会在目标进程中创建一个新的 Ref,并将其与该节点关联,从而建立连接。稍后,目标进程可以使用该 Ref 向拥有与该 Ref 关联的节点的进程发送事务。
上图说明了应用程序 A 如何为应用程序 B 建立与自身的连接,以便应用程序 B 可以向应用程序 A 发送交易以执行 RPC 调用的步骤。
步骤如下:
-
应用程序 A 向应用程序 B 发送一笔交易,其中包含一个 ID 为 0xbeef 的节点。该交易与上面所示的交易类似,节点由数据表示struct flat_binder_object。
-
Binder 在内部将 Node 0xbeef 与 App A 关联起来,并初始化一个引用计数器来跟踪有多少个 Ref 正在引用它。在实际实现中,底层 Node 数据结构 ( struct binder_node) 中有 4 个引用计数器,我们将在后面介绍。
-
Binder 在内部创建一个属于 App B 的 Ref 0xbeef,它引用 App A 的 Node 0xbeef。此步骤将Node 0xbeef 引用计数增加1。
-
现在,应用程序 B 可以使用 0xbeef 作为target.handle其struct binder_transaction_data未来的引用向应用程序 A 发送交易。当 Binder 处理 B 发送的交易时,它可以发现 Ref 0xbeef 引用了应用程序 A 的 Node 0xbeef,并将交易发送给应用程序 A。
Binder 上下文管理器
有人可能会问:如果应用程序 A 和应用程序 B 之间没有连接,那么应用程序 A 如何将节点发送到应用程序 B?首先,除了将节点对象从一个进程发送到另一个进程(如上所示)之外,还可以以类似的方式发送 Ref 对象。例如,假设存在另一个应用程序 C,则应用程序 B 可以将 Ref(在上述步骤 3 中创建)发送到应用程序 C。一旦应用程序 C 从应用程序 B 收到 Ref,它就可以使用该 Ref 将交易发送到应用程序 A。
其次,Binder 允许一个特殊进程使用 ioctl 宣称自己是上下文管理器BINDER_SET_CONTEXT_MGR,并且只有一个进程可以担任该角色。上下文管理器是一个特殊的 Binder IPC 端点,始终可以在句柄 (Ref) 0 处访问,它充当中介,使其他进程可以发现 Binder IPC 端点。
例如,进程客户端 1 向上下文管理器发送一个节点(例如 0xbeef),上下文管理器又接收一个 Ref(0xbeef)。然后,另一个第三个进程客户端 2 向上下文管理器发起一个事务,请求该 Ref(0xbeef)。上下文管理器通过返回 Ref(0xbeef)来响应该请求。因此,这在两个进程之间建立了连接,因为客户端 2 现在可以使用 Ref(0xbeef)向客户端 1 发送事务。
在 Android 上,ServiceManager 进程在启动时将自己声明为上下文管理器。系统服务向上下文管理器注册其 Binder 节点,以便其他应用可以发现它们。
漏洞
客户端可以struct binder_object在事务中包含一个 Binder 对象(),该对象可以是以下任一种:
在将所有 Binder 对象发送给接收者之前,Binder 必须在以下函数中将这些对象从发送者的上下文转换为接收者的上下文binder_transaction:
static void binder_transaction(...)
{
...
// Iterate through all Binder objects in the transaction
for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
buffer_offset += sizeof(binder_size_t)) {
// Process/translate one Binder object
}
...
}
例如,考虑这样一个场景:一个客户端通过 Binder 发送文件描述符将文件共享给另一个客户端。为了允许接收方访问该文件,Binder 通过在接收方的任务进程中安装一个带有共享文件的新文件描述符来翻译该文件描述符。
注意:一些对象实际上是在接收方读取事务时(BINDER_WRITE_READ接收方调用 ioctl 时)进行转换的,而其他对象则是在发送方发送事务时(发送BINDER_WRITE_READ方调用 ioctl 时)进行转换的。
在处理未对齐的事务时,存在错误处理的代码路径 [1] offsets_size。请注意,Binder 跳过 for 循环处理 Binder 对象,因此buffer_offset保留 0,然后将其binder_transaction_buffer_release作为参数传递给函数调用 [2]:
static void binder_transaction(..., struct binder_transaction_data *tr, ...)
{
binder_size_t buffer_offset = 0;
...
if (!IS_ALIGNED(tr->offsets_size, sizeof(binder_size_t))) { // [1]
goto err_bad_offset;
}
...
// Iterate through all Binder objects in the transaction
for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
buffer_offset += sizeof(binder_size_t)) {
// Process a Binder object
}
...
err_bad_offset:
...
binder_transaction_buffer_release(target_proc, NULL, t->buffer,
/*failed_at*/buffer_offset, // [2]
/*is_failure*/true);
...
}
binder_transaction_buffer_release是一个函数,用于撤消 Binder 在事务中处理 Binder 对象后引起的所有副作用。例如,关闭接收方进程任务中打开的文件描述符。在错误处理情况下,Binder 必须仅清理在发生错误之前已经处理过的 Binder 对象。函数中的failed_at和is_failure参数决定了 Binder 必须清理多少个 Binder 对象。
回到 unaligned 的错误处理路径,offsets_size其中failed_at == 0和is_failure == true。在这种情况下,Binder 计算off_end_offset为事务缓冲区的末尾。因此,Binder 会清理事务中的每个 Binder 对象。但是,Binder 一开始并没有处理任何 Binder 对象,因为它遇到了错误并跳过了处理 Binder 对象的 for 循环。
static void binder_transaction_buffer_release(struct binder_proc *proc,
struct binder_thread *thread,
struct binder_buffer *buffer,
binder_size_t failed_at/*0*/,
bool is_failure/*true*/)
{
...
off_start_offset = ALIGN(buffer->data_size, sizeof(void *));
off_end_offset = is_failure && failed_at ? failed_at
: off_start_offset + buffer->offsets_size;
for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
buffer_offset += sizeof(size_t)) {
...
}
...
}
这种逻辑的原因是 的含义failed_at是重载的:代码的其他部分使用此逻辑来清理整个缓冲区。但是,在这种情况下,我们在没有处理任何对象的情况下就命中了此代码路径,这将导致引用计数不一致。在下一节中,我们将演示如何利用此漏洞实现 Binder Node 对象的 use-after-free 并将其转变为特权提升 PoC。
开发
在之前发布的CVE-2020-004 漏洞利用中,Blue Frost Security 利用相同的清理过程获得了 root 权限。但是,他们利用了一个漏洞,该漏洞会在 Binder 处理事务后修改事务中的 Binder 对象。他们发布了一个 PoC,以演示在运行内核版本 4.9 的 Pixel 3 上提升 root 权限。
我们从过去的漏洞中汲取灵感,首先在 Binder 中实现相同的泄漏和取消链接原语。由于较新内核版本中 SLUB 分配器的缓存发生了一些变化,我们使用了不同的方法对受害对象执行释放后使用。我们将在后面的部分中解释这些变化以及我们如何克服它们。
Binder_node 的 UAF
Node( )是事务中的struct binder_nodeBinder 对象( ),其标头类型为或。当客户端将 Node 发送给另一个客户端时,Binder 会在内部创建一个 Node。Binder 还管理 Node 中的多个引用计数器以确定其生命周期。在本节中,我们将演示如何利用上述漏洞在 Node 对象的引用计数器之一中引入不一致,从而导致释放此对象时存在指向它的悬空指针,从而导致释放后使用。struct flat_binder_objectBINDER_TYPE_BINDERBINDER_TYPE_WEAK_BINDER
当binder_transaction_buffer_release函数遍历缓冲区中的所有 Binder 对象并遇到带有标头类型BINDER_TYPE_BINDER或 的节点时BINDER_TYPE_WEAK_BINDER,它会调用该binder_get_node函数来检索binder_node属于接收者进程上下文 ( proc) 且节点 ID 等于fp->binder(下面列表中的 [1])的 。
然后,它调用binder_dec_node函数来减少其引用计数器之一(下面列表中的 [2])。假设我们在交易中有一个带有标头类型的节点BINDER_TYPE_BINDER,则 Binder 调用binder_dec_node函数并将表达式strong == 1和internal == 0作为函数参数传递。
static void binder_transaction_buffer_release(...)
{
...
for (buffer_offset = off_start_offset; buffer_offset < off_end_offset;
buffer_offset += sizeof(size_t)) {
...
case BINDER_TYPE_BINDER:
case BINDER_TYPE_WEAK_BINDER: {
...
// [1]
node = binder_get_node(proc, fp->binder);
...
// [2]
binder_dec_node(node, /*strong*/ hdr->type == BINDER_TYPE_BINDER, /*internal*/ 0);
...
} break;
...
}
...
}
注意:术语 Node 和binder_node可以互换,但该术语binder_node通常指 Binder 中的底层 Node 数据结构。术语 Node 还用于指struct flat_binder_object事务中具有标头类型BINDER_TYPE_BINDER和 的BINDER_TYPE_WEAK_BINDER。
该binder_dec_node函数调用binder_dec_node_nilocked来减少 的一个引用计数器(下面列表中的 [1])binder_node。如果binder_dec_node_nilocked返回 true,该函数将调用binder_free_node来释放binder_node(下面列表中的 [2])。这正是我们想要实现 UAF 的分支。
static void binder_dec_node(struct binder_node *node, int strong /*1*/, int internal /*0*/)
{
bool free_node;
binder_node_inner_lock(node);
free_node = binder_dec_node_nilocked(node, strong, internal); // [1]
binder_node_inner_unlock(node);
if (free_node)
binder_free_node(node); // [2]
}
注意:Binder 中有许多函数带有后缀 *locked,这要求调用者在调用它们之前已获取必要的锁。有关所有后缀的更多详细信息,请参阅 /drivers/android/binder.c 代码的顶部。
在binder_dec_node_nilocked函数中,如果strong == 1和internal == 0,它会减少local_strong_refs中的字段binder_node。
static bool binder_dec_node_nilocked(struct binder_node *node,
int strong /*1*/, int internal /*0*/)
{
...
if (strong) {
if (internal)
...
else
node->local_strong_refs--;
...
} else {
...
}
...
}
因此,为了触发漏洞,我们可以发送一个带有 Node 对象的交易,该交易的标头类型设置为BINDER_TYPE_BINDER,字段设置为我们要减少其引用计数器值的binderNode ( ) 的 ID 。下图显示了一个恶意交易,该交易利用此漏洞两次减少接收方客户端的Node 中的引用计数器。该交易包含两个 Node ( ) 和一个未对齐的。struct binder_nodelocal_strong_refs0xbeefstruct flat_binder_objectoffsets_size
未对齐offsets_size导致 Binder 在函数中采用易受攻击的错误处理路径binder_transaction,从而跳过处理事务中的两个节点。这会利用该binder_transaction_buffer_release函数来清理这两个节点,从而将节点 0xbeef 减少local_strong_refs两次 - 每次针对struct flat_binder_ojbect事务中的两个对象。
现在我们来分析一下函数struct binder_node中需要满足哪些条件才能释放binder_dec_node(即在什么条件下binder_dec_node_nilocked返回true强制binder_dec_node释放binder_node)。根据下面的代码片段,binder_dec_node_nilocked根据中几个字段的值返回true struct binder_node。
static bool binder_dec_node_nilocked(struct binder_node *node,
int strong /*1*/, int internal /*0*/)
{
...
if (strong) {
if (internal)
...
else
node->local_strong_refs--;
if (node->local_strong_refs || node->internal_strong_refs)
return false;
} else {
...
}
if (proc && (node->has_strong_ref || node->has_weak_ref)) {
...
} else {
if (hlist_empty(&node->refs) && !node->local_strong_refs &&
!node->local_weak_refs && !node->tmp_refs) {
...
return true;
}
}
return false;
}
为了确保在减少后binder_dec_node_nilocked返回,我们必须传递满足以下条件的:truelocal_strong_refsnode
// Reference counters in struct binder_node
local_strong_refs == 1 // before `binder_dec_node_nilocked` decrements it
local_weak_refs == 0
internal_strong_refs == 0
tmp_refs == 0
has_strong_ref == 0
has_weak_ref == 0
hlist_empty(&node->refs) == true
因此,要释放函数binder_node中的对象,binder_dec_node我们必须设置一个binder_node没有任何 Refs 引用它的对象,并且除 之外的所有引用计数器都等于零local_strong_refs。然后,我们可以利用此漏洞减少local_strong_refs并使其被 释放binder_free_node。
设置binder_node这样的简单方法如下:
-
客户端 A 和客户端 B 通过 Node 0xbeef 和 Ref 0xbeef 建立连接(参见前面的图表)。Node 以local_strong_refs1 开头,因为只有 Ref 对象引用该 Node。
-
target.handle客户端 B 发送一个设置为 的事务0xbeef。Binder 处理它,binder_buffer在客户端 A 端分配 并将事务数据复制到分配的缓冲区中。此时,Node 的local_strong_refs等于 2,因为 Ref 对象和事务都引用了 Node。
-
客户端 B 关闭 Binder 文件描述符,从而释放 Ref 对象并将其减local_strong_refs1。现在,Node 的值local_strong_ref回到 1,因为只有事务正在引用该 Node。
下图说明了binder_node利用此漏洞释放漏洞之前和之后的设置情况:
利用漏洞释放后,会在中binder_node留下一个悬空指针。在以下部分中,我们将多次利用此释放后使用漏洞来获取利用漏洞获取 Android 设备 root 权限所需的原语。target_nodebinder_buffer
首先,我们获得一个有限的泄漏原语,使我们能够从内核堆泄漏 16 个字节(2 个 8 字节值)。我们在此原语的基础上构建了下一阶段的解除链接原语,使我们能够使用攻击者控制的数据覆盖内核内存。接下来,我们利用泄漏和解除链接原语来获取任意内核内存读取原语,我们使用它来识别我们想要覆盖的内核结构的地址,最终获得设备上的 root 权限。
泄漏原语
binder_node首先,我们利用函数中已释放对象的释放后使用读取漏洞binder_thread_read。当客户端执行BINDER_WRITE_READioctl 以从 Binder 读取传入事务时,Binder 会调用该binder_thread_read函数将传入事务复制回用户空间。此函数将binder_node(ptr和cookie) 中的两个字段复制到事务中 ([1] 和 [2])。然后,Binder 将事务复制回用户空间 [3]。因此,我们可以导致读取后使用从内核堆内存中泄漏两个值。
static int binder_thread_read(...)
{
...
struct binder_transaction_data_secctx tr;
struct binder_transaction_data *trd = &tr.transaction_data;
...
struct binder_transaction *t = NULL;
...
t = container_of(w, struct binder_transaction, work);
...
if (t->buffer->target_node) {
struct binder_node *target_node = t->buffer->target_node;
trd->target.ptr = target_node->ptr; // [1]
trd->cookie = target_node->cookie; // [2]
...
}
...
if (copy_to_user(ptr, &tr, trsize)) { // [3]
...
}
...
}
该binder_node对象是从 kmalloc-128 SLAB 缓存分配的,两个 8 字节泄漏位于偏移量 88 和 96。
gdb> ptype /o struct binder_node
/* offset | size */ type = struct binder_node {
...
/* 88 | 8 */ binder_uintptr_t ptr;
/* 96 | 8 */ binder_uintptr_t cookie;
取消链接原语
函数 [1]中的 unlink 操作可能会出现 UAF binder_dec_node_nilocked。不过,在到达 unlink 操作之前还会进行多次检查。
static bool binder_dec_node_nilocked(struct binder_node *node,
int strong, int internal)
{
struct binder_proc *proc = node->proc;
...
if (strong) {
...
if (node->local_strong_refs || node->internal_strong_refs)
return false;
} else {
...
}
if (proc && (node->has_strong_ref || node->has_weak_ref)) {
...
} else {
if (hlist_empty(&node->refs) && !node->local_strong_refs &&
!node->local_weak_refs && !node->tmp_refs) {
if (proc) {
...
} else {
BUG_ON(!list_empty(&node->work.entry));
...
if (node->tmp_refs) {
...
return false;
}
hlist_del(&node->dead_node); // [1]
...
}
return true;
}
}
return false;
}
函数中实现的取消链接操作__hlist_del基本上是修改两个内核指针,使其相互指向。
static inline void __hlist_del(struct hlist_node *n)
{
struct hlist_node *next = n->next;
struct hlist_node **pprev = n->pprev;
WRITE_ONCE(*pprev, next);
if (next)
WRITE_ONCE(next->pprev, pprev);
}
不失一般性,可以总结如下:
*pprev = next
*(next + 8) = pprev
为了实现取消链接操作,我们必须使用我们控制其数据的binder_node假对象重新分配已释放的binder_node对象。为此,我们可以使用众所周知的sendmsg堆喷射技术在已释放的对象之上分配具有任意数据的对象binder_node。
由于在解除链接操作之前有多个检查,因此我们必须binder_node用正确的数据填充伪造对象才能通过检查。我们必须创建一个binder_node具有以下条件的伪造对象:
node->proc == 0
node->has_strong_ref == 0
node->has_weak_ref == 0
node->local_strong_refs == 0
node->local_weak_refs == 0
node->tmp_refs == 0
node->refs == 0 // hlist_empty(node->refs)
node->work.entry = &node->work.entry // list_empty(&node->work.entry)
最后一个条件很难满足,因为我们必须已经知道释放的地址binder_node才能计算正确的。幸运的是,我们可以使用泄漏原语在利用漏洞释放它之前&node->work.entry泄漏地址。下面是我们如何实现这一点。binder_node
泄露binder_node地址
一个binder_ref对象从 SLAB 缓存中分配kmalloc-128,并且包含一个指向偏移量为 88 处的相应binder_node对象的指针(您还记得我们上面讨论的泄漏原语在释放后使用读取期间在偏移量 88 和 96 处泄漏了两个 8 字节值)。
gdb> ptype /o struct binder_ref
/* offset | size */ type = struct binder_ref {
...
/* 88 | 8 */ struct binder_node *node;
/* 96 | 8 */ struct binder_ref_death *death;
binder_node因此,我们可以按照以下步骤泄露一个地址:
-
利用此漏洞来释放一个binder_node。
-
binder_ref在已释放的顶部分配一个binder_node。
-
使用泄漏原语从 泄漏一个地址binder_node到binder_ref。
一旦我们泄露了释放对象的地址,binder_node 我们就拥有了设置解除链接原语所需的所有数据。在使用重新分配我们的伪造binder_node文件后sendmsg,我们发送一个BC_FREE_BUFFER绑定命令来释放包含悬空的事务binder_node以触发解除链接操作。此时,我们实现了有限的任意写入原语——由于__hlist_del函数的实现细节,我们使用有效的内核指针或 NULL 覆盖内核内存。
任意读取原语
CVE-2020-0041 漏洞利用FIGETBSZioctl 获取任意读取原语。ioctl将对应于成员的FIGETBSZ4 个字节数据 从内核复制回用户空间,如下面的 [1] 中的清单所示。s_blocksizestruct super_block
static int do_vfs_ioctl(struct file *filp, ...) {
...
struct inode *inode = file_inode(filp);
...
case FIGETBSZ:
...
return put_user(inode->i_sb->s_blocksize, (int __user *)argp); // [1]
...
}
ioctl(fd, FIGETBSZ, &value); // &value == argp
下图显示了和结构s_blocksize所引用的字段的位置。struct filestruct inode
我们可以执行取消链接写入来修改指针,inode使其指向struct epitem我们知道地址的指针。由于我们可以直接控制event.datawith 中的字段(位于结构开头偏移 40 个字节处)以struct epitem将epoll_ctl其指向内核地址空间中的任何位置,因此我们可以轻松地i_sb用任意值修改上面显示的字段(也位于偏移 40 处)。
然后,我们可以使用FIGETBSZioctl 和epoll_ctl作为任意读取原语,从内核地址空间中的任何位置读取 4 字节值。但是,我们必须首先知道 astruct file和 astruct epitem对象的内核地址。
泄漏struct file地址
包含struct epitem两个内核指针(next和prev),偏移量分别为 88 和 96。
gdb> ptype /o struct epitem
/* offset | size */ type = struct epitem {
...
/* 88 | 16 */ struct list_head {
/* 88 | 8 */ struct list_head *next
/* 96 | 8 */ struct list_head *prev;
} fllink;
这两个内核指针(next和prev)形成一个对象链表struct epitem。链表的头部位于struct file.f_ep_links。当我们使用泄漏原语将这些内核指针泄漏回用户空间时,其中一个指针将指向一个struct file 对象。
在之前针对内核版本 4.9 的 CVE-2020-0041 漏洞中,struct epitem在释放的 之上分配非常简单。由于缓存别名, 和都从同一 SLAB 缓存分配,并且SLAB 缓存以 FIFO 方式工作。因此,在释放 之后,我们可以从 所在的同一内存位置分配。binder_node
struct epitem
binder_node
kmalloc-128
kmalloc-128
binder_node
struct epitem
binder_node
缓存别名是一项内核功能,它将多个 SLAB 缓存合并为一个 SLAB 缓存以提高效率。当这些 SLAB 缓存保存类似大小的对象并共享类似属性时,就会发生这种情况。有关缓存别名的更多详细信息,请参阅2022 年 Linux 内核堆风水博客。
在内核版本 5.10 中,提交将SLAB_ACCOUNT标志添加到eeventpoll_epi SLAB 缓存中,因此eventpoll_epi和kmalloc-128不再共享同一个 SLAB 缓存。换句话说,astruct epitem不再从 SLAB 缓存中分配,这阻止我们立即kmalloc-128将其分配在已释放的之上。binder_node
跨缓存攻击
跨缓存攻击是一种将一个对象分配到另一个从不同缓存分配的对象之上的技术。这是可能的,因为内核中有多个级别的内存分配器,并且同一级别的缓存共享其层次结构中较高级别的相同内存分配器。SLUB 分配器 ( kmem_cache) 中的缓存从页面分配器获取页面并将其用作 slab。如果kmem_cache将页面释放回页面分配器,则kmem_cache在分配期间需要额外内存的另一个将获取该页面。
注意:页面分配器是一个伙伴分配器,它具有用于不同顺序的连续空闲页面的缓存。不同的kmem_caches 使用不同数量的连续页面作为其 slab。幸运的是,kmalloc-128和eventpoll_epi kmem_caches 都使用顺序为 0(2^0 = 1)的页面作为 slab。因此,我们在执行跨缓存攻击时不必修饰页面分配器,我们可以安全地假设页面分配器以 FIFO 方式处理从其分配和释放的每个页面。
下图显示了如何struct epitem从先前释放的 使用的同一内存区域分配binder_node。
要执行跨缓存攻击,我们必须从后面释放一个 slab(一个 4K 页面)kmalloc-128到页面分配器的每个 CPU 页面缓存,以便可以将其分配给。和eventpoll_epi中的每个 slab都是一个 4K 页面,可以容纳 32 个内核对象(4096/128)。kmalloc-128eventpoll_epi
为了控制整个 slab,我们必须分配 32 个binder_objects。然后,我们利用该漏洞一次性释放所有binder_nodes,并留下指向它们的悬空指针。但是,SLUB 分配器不会立即将空 slab 的页面释放回页面分配器,而是将其放在kmalloc-128缓存的部分列表中并使其冻结(通过设置struct page.frozen字段)。
注意:SLUB 分配器使用struct pageslab 来存储元数据,例如正在使用的对象的数量、部分列表中的下一页等。
在将空的 slab 释放回页面分配器之前,每个kmem_cacheslab 都包含部分列表中的多个 slab,这些 slab 可以是空的,也可以是部分空的。SLUB 分配器跟踪列表中第一个 slab 的页面中部分列表中的空闲槽数(struct page.pobjects)。当 字段的值pobjects大于kmem_cache.cpu_partial字段的值时,SLUB 分配器将解冻并将每个空的 slab 释放回页面分配器。该set_cpu_partial函数确定 的值,kmem_cache.cpu_partial并且 的值是 30 kmalloc_128。
然而,事实证明,部分列表中仅有 30 个空槽不足以将空 slab 释放回页面分配器。在进行 PoC 工作时,SLUB 分配器中存在一个会计错误,导致跟踪部分列表中的 slab 数量而不是空槽。因此,当部分列表中有超过 30 个 slabpobjects时,SLUB 分配器开始将空 slab 释放回页面分配器。kmalloc-128
在我们的漏洞利用中,我们分配 36 * 32(slab 数量 * slab 中的对象数量)binder_node并一次性释放它们。然后,我们分配超过 32 个struct epitem以用尽eventpoll_epi部分列表中的所有空槽,因此eventpoll_epi将从页面分配器分配新页面。
最后,我们在所有悬垂节点上使用泄漏原语来读取偏移量为 88 和 96 处这两个字段的值。如果我们成功地struct epitem在已经释放的 之上分配了一个binder_node,我们将在这些字段中找到内核地址,其中一个是 的内核地址struct file。
Binder 缓冲区分配器
kmalloc-128我们希望用s填充整个slab binder_node,因此我们可以利用此漏洞创建指向 slab 中每个对象的悬空指针,但这有一个挑战。
slab
+---------------+
| *binder_node* |<---- dangling pointer
+---------------+
| *binder_node* |<---- dangling pointer
+---------------+
| ... |
+---------------+
当我们发送事务时,Binder 还会从缓存中分配其他内核对象kmalloc-128,例如struct binder_buffer对象。该binder_buffer对象包含有关事务缓冲区的信息以及指向binder_node接收方客户端所拥有的 的指针binder_proc。利用此漏洞会将该指针变为指向已释放的 的悬空指针binder_node。
slab
+---------------+
| ... |
+---------------+
| binder_buffer |----+
+---------------+ | dangling pointer
| *binder_node* |<---+
+---------------+
| ... |
+---------------+
但是,我们暂时还不能释放它,binder_buffer因为我们需要它来触发泄漏和解除链接原语的释放后使用。因此,我们必须确保不能从与sbinder_buffer相同的 slab 中分配。kmalloc-128binder_node
Binder 实现了自己的内存分配器,为每个传入事务分配内存,并将其映射到接收方的映射内存映射。内存分配器采用最佳匹配分配策略,并使用对象binder_buffer来跟踪所有已分配和空闲的内存区域。在分配新的事务缓冲区时,它会搜索binder_buffer相同大小的空闲空间以供重复使用。如果没有可用的空间,它会将较大的空闲空间分成binder_buffer两部分:一个具有请求的大小,另一个具有剩余的大小。
为了防止 Binderbinder_buffer为每个事务分配一个新空间,我们可以binder_buffer通过造成内存碎片来提前分配许多 free 空间。我们可以通过发送多个大小各异的事务并有选择地释放其中一些来实现这一点。因此,此过程会在内存分配器中产生间隙,从而导致许多 free 空间binder_buffer可在未来的事务中重复使用。
Binder buffer allocator
+-----------------+----------+-----------------+----------+---------+
| free (24) | used (8) | free (24) | used (8) | ... |
+-----------------+----------+-----------------+----------+---------+
以下是演示跨缓存攻击的视频:
ROOT
为了获取root权限,我们执行以下步骤:
1、使用任意读取原语来查找我们的进程task_struct和cred结构。
struct binder_node *node;
struct binder_proc *proc = node->proc;
struct task_struct *task = proc->tsk;
struct task_struct *cred = task->cred;
2、struct cred用 0(根的 UID)覆盖对象中的所有 ID 字段。
3、通过用 0 覆盖字段来禁用 SELinux selinux.enforcing。
4、TIF_SECCOMP在当前任务标志中启用并用 0 覆盖 seccomp 的掩码以绕过 seccomp。
演示
奖励:任意写入原语
虽然在我们的 PoC 中我们不需要任意写入原语来实现 root 权限,但我们想提供有关如何获取写入什么位置原语的信息以供参考。
我们的 unlink 原语可以在内核中任何可写的任意地址上写入,但它只能写入 0 或有效(即可写)内核地址的值。为了实现更强大的任意写入(写入什么位置),我们选择基于360 Alpha Lab 在 Typhoon Mangkhut 漏洞利用链中介绍的技术(幻灯片buf)利用对象中的指针字段。struct seq_file
struct seq_file {
char *buf;
...
};
struct seq_file由使用 Linux 接口实现的文件使用,seq_file例如/proc/self/comm。打开文件时/proc/self/comm,内核创建一个struct seq_file并调用该comm_open函数。将函数comm_open传递comm_show给该single_open函数以定义读取文件时要显示的字符串。
// fs/proc/base.c
static int comm_open(struct inode *inode, struct file *filp)
{
return single_open(filp, comm_show, inode);
}
将comm_show当前任务名称复制到seq_file->buf缓冲区(下面列表中的 [1])。
// fs/proc/base.c
static int comm_show(struct seq_file *m, void *v)
{
...
proc_task_name(m, p, false);
...
}
// fs/proc/array.c
void proc_task_name(struct seq_file *m, struct task_struct *p, bool escape)
{
char *buf;
size_t size;
char tcomm[64];
...
// `tcomm` is filled with the current task name
...
size = seq_get_buf(m, &buf); // buf = m->buf
if (escape) {
...
} else {
ret = strscpy(buf, tcomm, size); // [1]
}
}
我们可以打开/proc/self/comm文件两次,struct seq_file在内核中分配两个实例。然后,我们使用 unlink 原语覆盖struct seq_file->buf第一个实例中的字段,使其指向struct seq_file->buf第二个实例中字段的地址。
因此,struct seq_file->buf通过将当前任务名称更改为目标地址的 8 字节值并调用lseek第一个seq_file文件描述符(下面列表中的 [2]),我们可以覆盖第二个实例中的字段以指向任意内核地址。调用lseek文件描述符将触发comm_show函数,导致struct seq_file->buf使用目标地址覆盖结构的第二个实例中的字段。
// [2] Point `seq_file->buf` to arbitrary kernel address
prctl(PR_SET_NAME,"xefxbexadxdexffxffxffxff�", 0, 0, 0);
lseek(comm_fd1, 1, SEEK_SET); // comm_fd1 = First seq_file's file descriptor
下图显示了指向攻击者选择的地址的struct seq_file字段实例的布局。struct seq_file->buf
然后,我们可以对第二个seq_file文件描述符执行类似的操作,通过设置当前任务名称来写入我们控制的数据。因此,这为我们提供了内核内存中更强大的任意写入(写入什么位置)原语。
补充:Binder Node 引用计数详解
让我们检查一下 的 4 个引用计数器struct binder_node。
和跟踪引用该节点的所有事务中的节点数。请记住,事务中的节点 ( ) 具有与 Binder 内部创建的用于记账的local_strong_refs节点( ) 不同的数据结构。当事务中有引用它的节点时,Binder 可确保每个节点都不会消失。local_weak_refs
struct flat_binder_object
struct binder_node
binder_node
打开 Binder 设备文件后,我们调用mmap来提供 Binder 用于存储传入事务数据的共享内存映射。Binder 实现了一个缓冲区分配器来管理该共享内存映射,该分配器分配一个struct binder_buffer来占用内存映射的一部分以存储传入事务数据。target_node中的字段struct binder_buffer引用binder_node属于接收客户端的 ,这会增加该binder_node的local_strong_refs引用计数。
跟踪internal_strong_refs其他客户端有多少个 Refs 引用了该节点。
下图说明了客户端 A 有一笔包含两个节点的传入交易,而客户端 B 有一个binder_ref引用客户端 A 的节点 0xbeef ( ) 的 Ref 0xbeef ( ) binder_node。最重要的是,它突出显示了这些数据结构如何增加节点 0xbeef 的引用计数器。
当 Binder 将变量分配给指向 的指针时,只要指针在其范围内使用,binder_node它就会使用tmp_refs来保持处于活动状态。以下代码显示了一个基本示例:binder_node
struct binder_node *node = ...;
binder_inc_node_tmpref(node);
// Access `node` safely
binder_dec_node_tmpref(node); // `node` can no longer be used after this.
Otherwise, there can be race conditions.
当至少有一个 Ref 引用 时,Binder 还会设置has_strong_ref和标志。has_weak_refbinder_node
指向binder_node->refsRefs 列表的头部。
补救措施和结论
本博客中描述的问题已在两个 Android 安全公告中得到修复:
-
2023-02-01中的 CVE-2023-20938
-
2023-07-01中的 CVE-2023-21255
CVE-2023-20938 最初在 2023 年 2 月的 Android 安全公告中得到解决,方法是将补丁反向移植到易受攻击的内核。然而,进一步的分析表明,该补丁并没有完全缓解潜在的根本原因,尽管通过不同的路径,但仍有可能找到该漏洞。因此,在 2023 年 7 月的 Android 安全公告中,分配了新的 CVE-2023-21255,并完全缓解了根本原因。
致谢
特别感谢 Carlos Llamas、Jann Horn、Seth Jenkins、Octavian Purdila、Xingyu Jin 和 Farzan Karimi 对技术问题的支持以及对本文的审阅。
Attacking Android Binder: Analysis and Exploitation of CVE-2023-20938
https://androidoffsec.withgoogle.com/posts/attacking-android-binder-analysis-and-exploitation-of-cve-2023-20938/
原文始发于微信公众号(Ots安全):攻击 Android Binder:CVE-2023-20938 的分析与利用
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论