CVE-2024-41010 linux内核中网络调度相关的net/sched部分存在释放后使用漏洞 漏洞利用分析

该漏洞存在于 Linux 内核的流量控制和调度部分。通过特定的操作,攻击者能够触发 UAF(Use-After-Free)漏洞。攻击者可以通过合理的构造条件,在内核内存中写入任意数据,从而造成系统崩溃或进一步的安全威胁。

CVE-2024-41010 漏洞利用分析

根本原因分析

观察 tcx_entry 的生命周期,我们可以看到它首先在 ingress_init 等函数初始化新建的 ingress/clsact qdisc 时分配:

static int ingress_init(struct Qdisc *sch, struct nlattr *opt,
            struct netlink_ext_ack *extack)
{
    struct ingress_sched_data *q = qdisc_priv(sch);
    struct net_device *dev = qdisc_dev(sch);
    struct bpf_mprog_entry *entry;
    bool created;
    int err;

    if (sch->parent != TC_H_INGRESS)
        return -EOPNOTSUPP;

    net_inc_ingress_queue();

    entry = tcx_entry_fetch_or_create(dev, true, &created); // [1]
    if (!entry)
        return -ENOMEM;
    tcx_miniq_set_active(entry, true); // [2]
    mini_qdisc_pair_init(&q->miniqp, sch, &tcx_entry(entry)->miniq); // [3]
    if (created)
        tcx_entry_update(dev, entry, true);

    q->block_info.binder_type = FLOW_BLOCK_BINDER_TYPE_CLSACT_INGRESS;
    q->block_info.chain_head_change = clsact_chain_head_change;
    q->block_info.chain_head_change_priv = &q->miniqp; // [4]

    err = tcf_block_get_ext(&q->block, sch, &q->block_info, extack);
    if (err)
        return err;

    mini_qdisc_pair_block_init(&q->miniqp, q->block);

    return 0;
}

在 [1] 处,调用了 tcx_entry_fetch_or_create 来分配或获取已存在的 tcx_entry(被 bpf_mprog_entry 包裹):

static inline struct bpf_mprog_entry *
tcx_entry_fetch_or_create(struct net_device *dev, bool ingress, bool *created)
{
    struct bpf_mprog_entry *entry = tcx_entry_fetch(dev, ingress); // [5]

    *created = false;
    if (!entry) {
        entry = tcx_entry_create();
        if (!entry)
            return NULL;
        *created = true;
    }
    return entry;
}

在 [2] 处,调用 tcx_miniq_set_activetcx_entry->miniq_active 设置为 true,然后在 [3] 处,将 &tcx_entry(entry)->miniq(基本上是指向 tcx_entry 第一个字段的指针)存储到 &q->miniqp,然后在 [4] 处将其复制到 q->block_info.chain_head_change_priv

如果我们现在再看看释放 ingress qdisc 的过程,会发现一些有趣的地方:

static void ingress_destroy(struct Qdisc *sch)
{
    struct ingress_sched_data *q = qdisc_priv(sch);
    struct net_device *dev = qdisc_dev(sch);
    struct bpf_mprog_entry *entry = rtnl_dereference(dev->tcx_ingress);

    if (sch->parent != TC_H_INGRESS)
        return;

    tcf_block_put_ext(q->block, sch, &q->block_info);

    if (entry) {
        tcx_miniq_set_active(entry, false);
        if (!tcx_entry_is_active(entry)) {
            tcx_entry_update(dev, NULL, true);
            tcx_entry_free(entry);
        }
    }

    net_dec_ingress_queue();
}

ingress_destroy 中,如果存在与网络设备绑定的 tcx_entry,它会将 tcx_entry->miniq_active 设置为 false,然后调用 tcx_entry_is_active(entry) 来判断 tcx_entry 是否仍在使用。如果没有使用,则从网络设备中移除引用并释放 tcx_entry。接下来看看 tcx_entry_is_active 的实现:

static inline bool tcx_entry_is_active(struct bpf_mprog_entry *entry)
{
    ASSERT_RTNL();
    return bpf_mprog_total(entry) || tcx_entry(entry)->miniq_active;
}

它会检查该条目是否有任何附加的 bpf 程序,因为这些程序会引用该条目,或者检查 miniq_active 是否为 true,表示 qdisc 视图仍然存在。但是,销毁 qdisc 时会将其设置为 false,如果没有附加任何程序,这个函数总是会返回 false,从而进入释放路径。

这个问题引发了一个问题:如果另一个 qdisc 获取了相同的条目并引用了它,然后我们删除第一个 qdisc,导致该条目也被删除,会发生什么?值得注意的是,在我的理解中,不能在同一网络设备上共存两个 ingress qdisc,创建一个新的 ingress qdisc 会删除旧的 qdisc。然而,令人感兴趣的是,创建新 ingress qdisc 会在调用 ingress_destroy 删除旧的 qdisc 之前调用 ingress_init(),因此可以通过连续创建两个 ingress qdisc 来复制引用。在第一个 qdisc 被删除之后,第二个 qdisc 仍然持有指向 tcx_entry 的指针,这个指针会在随后的调用链中的 mini_qdisc_pair_swap() 函数中被解引用:

[...]
static void tcf_chain0_head_change(struct tcf_chain *chain,
                   struct tcf_proto *tp_head)
{
    struct tcf_filter_chain_list_item *item;
    struct tcf_block *block = chain->block;

    if (chain->index)
        return;

    mutex_lock(&block->lock);
    list_for_each_entry(item, &block->chain0.filter_chain_list, list)
        tcf_chain_head_change_item(item, tp_head);
    mutex_unlock(&block->lock);
}
[...]
static void tcf_chain_head_change_item(struct tcf_filter_chain_list_item *item,
                       struct tcf_proto *tp_head)
{
    if (item->chain_head_change)
        item->chain_head_change(tp_head, item->chain_head_change_priv);
}
[...]
static void clsact_chain_head_change(struct tcf_proto *tp_head, void *priv)
{
    struct mini_Qdisc_pair *miniqp = priv;

    mini_qdisc_pair_swap(miniqp, tp_head);
};

这可以通过向该 qdisc 添加或删除流量控制过滤器来触发。

漏洞触发

  1. 创建一个新的 ingress/clsact qdisc
  2. 创建第二个 ingress/clsact qdisc,这将导致以下事件发生:
    • 重用 tcx_entry 引用
    • 删除 tcx_entry
    • 删除第一个 qdisc
  3. 向第二个 qdisc 添加过滤器,这会解引用悬空指针,并将 UAF 对象的第一个 qword 覆盖为指向 qdisc 结构体或 NULL 的指针

悬空指针在 mini_qdisc_pair_swap 函数中被解引用:

void mini_qdisc_pair_swap(struct mini_Qdisc_pair *miniqp,
              struct tcf_proto *tp_head)
{
    /* 被 chain0->filter_chain_lock 保护。
     * 不能直接访问链表,因为 tp_head 可能为 NULL。
     */
    struct mini_Qdisc *miniq_old =
        rcu_dereference_protected(*miniqp->p_miniq, 1); // [1]
    struct mini_Qdisc *miniq;

    if (!tp_head) {
        RCU_INIT_POINTER(*miniqp->p_miniq, NULL); // [2]
    } else {
        miniq = miniq_old != &miniqp->miniq1 ? 
            &miniqp->miniq1 : &miniqp->miniq2;

        /* 确保读者在我们修改 miniq 时看不到它。
         * 确保至少经过一个 RCPU 协调周期,确保 miniq 被标记为非活跃状态。
         */
        if (IS_ENABLED(CONFIG_PREEMPT_RT))
            cond_synchronize_rcu(miniq->rcu_state);
        else if (!poll_state_synchronize_rcu(miniq->rcu_state))
            synchronize_rcu_expedited();

        miniq->filter_list = tp_head;
        rcu_assign_pointer(*miniqp->p_miniq, miniq); // [3]
    }

    if (miniq_old)
        /* 这是与上面的 rcu 同步的对等操作。我们需要阻止潜在的新用户
         * 访问 miniq_old,直到所有的读者都不再看到它。
         */
        miniq_old->rcu_state = start_poll_synchronize_rcu(); // [4]
}

有限的 UAF

需要注意的是,miniqpstruct tcx_entry 的第一个字段,miniqstruct Qdisc 的一个字段,因此 miniqp->p_miniq 是指向 qdisc 中部的一个指针,而 tcx_entry 位于 kmalloc-2k 中。
注意到,在 [2] 处,如果 tp_head 为 NULL,它会将 NULL 写入 miniqp->p_miniq,这正好位于 tcx_entry 的开始位置。如果 tp_head 不为 NULL,则会在 [3]中写入 miniq,即将实际的结构体写入悬空指针位置。

通过触发 UAF 攻击,可以绕过验证并修改内核内存的特定部分,进而对系统安全造成威胁。

升级为任意释放 (Arbitrary Free)

我的方法是通过跨缓存(cross-caching)从 kmalloc-2kkmalloc-cg-2k,借此访问 msg_msgseg 对象,并将 msg_msgseg->next 指针替换为 miniq 指针。这在我们的案例中特别有趣,因为 struct mini_Qdisc 的第一个字段是 filter_list,它是一个绑定到过滤器的 struct tcf_proto 对象的链表,而这些对象我们可以随意分配和释放。结合 msg_msgseg 的特性,读取 msg_msgseg 会释放 msg_msgseg->next 直到它找到 NULL,这使得我们有了一个非常强大的任意释放工具。

void free_msg(struct msg_msg *msg)
{
    struct msg_msgseg *seg;

    security_msg_msg_free(msg);

    seg = msg->next;
    kfree(msg);
    while (seg != NULL) {
        struct msg_msgseg *tmp = seg->next;

        cond_resched();
        kfree(seg);
        seg = tmp;
    }
}

通过这个方法,我们可以做以下操作:

  • 使 tcx_entry 变为悬空指针
  • 将它替换为 msg_msgseg
  • 向 qdisc 添加过滤器(这会把一个 tcf_proto 添加到以 miniq 为头的链表中,并将 miniq 指针写入 msg_msgseg->next
  • 读取 msg_msgseg,这会触发释放,并遍历 filter_list,直到它找到 NULL,释放所有的对象,从而释放我们位于 kmalloc-128 区域的 tcf_proto

达到 kmalloc-cg-1k

我的漏洞利用的最终目标是获得一个在 kmalloc-cg-1k 中的双重释放(double free),从而使 pipe_bufferskbuf->data 重叠,达到控制内存的目的。为此,我需要通过一些中间步骤,跨缓存,逐步接近 kmalloc-cg-1k

获取 kmalloc-cg-512 指针

一旦我在 kmalloc-128 中获得了任意释放的能力,我决定再次跨缓存,这次从 kmalloc-128kmalloc-cg-128,通过喷洒 msg_msgseg 并重用我们的任意释放原语来释放 kmalloc-cg-128 中的一个 msg_msgseg 对象。在这一点上,我的目标是获得一个指向 kmalloc-cg-1k 的对象,以便将我们现在完全可控的 msg_msgseg->next 与其重叠。我的第一个想法是将其与一个 msg_msg 对象重叠,但这会导致 msg_msgseg 被链接到 msg_msg 的循环链表中,在释放时会无限迭代,导致死锁。后来,我通过分析结构体,发现了 struct in_ifaddr

struct hlist_node   hash;
struct in_ifaddr    __rcu *ifa_next;
struct in_device    *ifa_dev;
struct rcu_head     rcu_head;
__be32          ifa_local;
__be32          ifa_address;
__be32          ifa_mask;
__u32           ifa_rt_priority;
__be32          ifa_broadcast;
unsigned char       ifa_scope;
unsigned char       ifa_prefixlen;
unsigned char       ifa_proto;
__u32           ifa_flags;
char            ifa_label[IFNAMSIZ];

__u32           ifa_valid_lft;
__u32           ifa_preferred_lft;
unsigned long       ifa_cstamp; /* created timestamp */
unsigned long       ifa_tstamp; /* updated timestamp */

这个结构体特别有趣的原因主要有两个:

  • 我可以读取 ifa_dev 指针,这将允许我泄露 kmalloc-512 区域中一个 struct in_device 对象的指针。
  • 在喷洒过程中,我可以使用 ifa_address 字段作为标识符,从而知道哪个喷洒的对象与 msg_msgseg 重叠。

虽然 kmalloc-512 指针并不是我最初的目标,但它已经足够好。因为 kmalloc-cg-512kmalloc-512 属于同一个页面级别,所以它们通常在同一页面附近。基于这一点,我在分配 in_device 之前,先分配了大量的 kmalloc-cg-512 类型的 skbuf->data,这样,当我收到泄露的指针时,可以通过减去一个任意的偏移量,将指针定位到 skbuf->data 喷洒区域的中间。

从 kmalloc-cg-128 到 kmalloc-cg-512

现在,我们有了 skbuf->data 对象在 kmalloc-cg-512 中的指针,我们需要通过某种方式将它写入我们可控的 msg_msgseg,以便释放它。虽然许多方便的数据喷洒技术似乎已经失效,但我最终发现了一个(看起来是新颖的)对象,能够完成这个工作:

static int rtnl_alt_ifname(int cmd, struct net_device *dev, struct nlattr *attr,
               bool *changed, struct netlink_ext_ack *extack)
{
    char *alt_ifname;
    size_t size;
    int err;

    err = nla_validate(attr, attr->nla_len, IFLA_MAX, ifla_policy, extack);
[...]
    alt_ifname = nla_strdup(attr, GFP_KERNEL_ACCOUNT);
[...]
    kfree(alt_ifname);
    if (!err)
        *changed = true;
    return err;
}

alt_ifname 对象是一个临时缓冲区,用来存储用户数据,分配时使用了 GFP_KERNEL_ACCOUNT。需要注意的是,rtnl_alt_ifname 只在获得 rtnl 锁的上下文中被调用,因此我们不能通过多线程来喷洒和竞争临时缓冲区。我们必须确保要覆盖的对象先于其他对象进入空闲链表。

一种方法是逐步分配其他 msg_msgseg 对象,并检查我们被篡改的 msg_msgseg,使用 MSG_COPY 读取它,而不释放它,从而知道哪个新分配的 msg_msgseg 对象刚好覆盖了目标对象,然后用 alt_ifname 缓冲区替换它,最终使我们可以将 msg_msgseg->next 指向 kmalloc-cg-512 中的 skbuf->data 对象,并释放它。

从 kmalloc-cg-512 到 kmalloc-cg-1k

kmalloc-cg-512 已经比 kmalloc-cg-128 更好。我们现在可以将 msg_msg 与我们的 skbuf->data 对象重叠。如果我们在与该 msg_msg 对象相同的队列中发送另一个消息,它会将第二个消息的指针放在第一个消息的第一个 qword 中。通过发送一个在 kmalloc-cg-1k 中分配的消息,我们可以泄露一个指针,并通过读取我们的 skbuf->data 来获取这个指针。如果我们稍微增加指针,它会指向另一个由我们的喷洒操作分配的 msg_msg,该对象属于一个不同的队列,这样我们就得到了一个指向 kmalloc-cg-1kmsg_msg 的重复引用,可以用来进行双重释放。关于如何操作 msg_msg 来实现这个目标,已经在这里详细讨论过:Google CVE-2021-22555 writeup

pipe_buffer->page 物理读写 > 获取权限

一旦我们到达 kmalloc-cg-1k,我们可以将 skbuf->datapipe_buffer 重叠,控制 pipe_buffer->page 指针,进而实现物理读写。这项技术已经在 Pipe Buffer 中详细讨论过。

使用这个原语,我可以覆盖 modprobe_path 字符串,将其指向一个通过 memfd_create 创建的内存文件,该文件位于 /proc/<pid>/fd/<n> 下。我还暴力破解了 exploit 进程的 pid,绕过了沙盒化命名空间的限制。有关更多细节,可以参考 Nftables techniques

参考文章

https://github.com/google/security-research/blob/master/pocs/linux/kernelctf/CVE-2024-41010_lts/docs/exploit.md

  • 发表于 2025-02-10 10:02:57
  • 阅读 ( 4980 )
  • 分类:Web应用

0 条评论

请先 登录 后评论
吃不饱的崽
吃不饱的崽

4 篇文章

站长统计