CVE-2024-41009 Linux内核的bpf ringbuf中存在一个缓冲区重叠漏洞分析与利用

Linux内核的bpf ringbuf中存在一个缓冲区重叠漏洞。可以使得第二个分配的内存块与第一个内存块重叠,结果就是BPF程序能够编辑第一个内存块的头部。一旦第一个内存块的头部被修改,bpf_ringbuf_commit()就会引用错误的页面,可能会导致崩溃。

背景

以下内容摘自 提交信息

BPF 环形缓冲区内部实现为大小为 2 的幂次方的循环缓冲区,并使用两个逻辑且不断递增的计数器:consumer_pos 表示消费者消费数据的位置,producer_pos 表示生产者已保留的数据量。<br><br>
每次预留一个记录时,负责该记录的生产者会推进生产者计数器。每当用户空间读取记录时,消费者会在处理完成后推进消费者计数器。两个计数器存储在不同的内存页中,因此,用户空间只能读 producer_pos(只读),而可以读写 consumer_pos(可读写)。

bpf_ringbuf 的结构布局如下:

struct bpf_ringbuf {
    wait_queue_head_t waitq;
    struct irq_work work;
    u64 mask;
    struct page **pages;
    int nr_pages;
    spinlock_t spinlock ____cacheline_aligned_in_smp;
    atomic_t busy ____cacheline_aligned_in_smp;
    unsigned long consumer_pos __aligned(PAGE_SIZE); // 用户空间可读写
    unsigned long producer_pos __aligned(PAGE_SIZE); // 用户空间只读
    unsigned long pending_pos;
    char data[] __aligned(PAGE_SIZE);
};

BPF_FUNC_ringbuf_reserve 用于从 BPF_MAP_TYPE_RINGBUF 中分配内存。它会预留 8 字节空间,用于记录头部结构:

/* 8 字节的环形缓冲区记录头结构 */
struct bpf_ringbuf_hdr {
    u32 len;
    u32 pg_off;
};

并返回 (void *)hdr + BPF_RINGBUF_HDR_SZ,供 eBPF 程序使用。eBPF 程序无法修改 bpf_ringbuf_hdr,因为它位于内存块外部。

然而,通过故意修改 &rb->consumer_pos,可以使第二次分配的内存块与第一次分配的内存块重叠。这样,eBPF 程序就能修改第一个内存块的头部。下面是具体步骤:

  1. 首先,我们创建一个大小为 0x4000BPF_MAP_TYPE_RINGBUF,并在调用 BPF_FUNC_ringbuf_reserve 前将 consumer_pos 修改为 0x3000
  2. 分配块 A,它位于 [0x0, 0x3008],此时 eBPF 程序可以编辑 [0x8, 0x3008]
  3. 接下来分配块 B,大小为 0x3000,此时会成功分配,因为 consumer_pos 已提前修改,可以通过检查。
  4. 块 B 会位于 [0x3008, 0x6010],eBPF 程序可以编辑 [0x3010, 0x6010]

在内核代码中,检查逻辑如下:

 static void *__bpf_ringbuf_reserve(struct bpf_ringbuf *rb, u64 size)
 {
    ...
    len = round_up(size + BPF_RINGBUF_HDR_SZ, 8);
    ...
    prod_pos = rb->producer_pos;
    new_prod_pos = prod_pos + len;
    /* 检查环形缓冲区是否溢出,确保生产者位置
    * 不会提前超出环形缓冲区的大小
    */
    if (new_prod_pos - cons_pos > rb->mask) {
        // 失败路径
        spin_unlock_irqrestore(&rb->spinlock, flags);
        return NULL;
    }
    // 成功路径
}

由于 cons_pos 的值为 0x3000(通过用户空间修改),new_prod_pos0x6010rb->mask0x4000 - 1,条件满足,因此返回在 [0x3008, 0x6010] 之间分配的缓冲区给 eBPF 程序。

由于环形缓冲区的内存布局是如下分配的:

static struct bpf_ringbuf *bpf_ringbuf_area_alloc(size_t data_sz, int numa_node)
{
    int nr_meta_pages = RINGBUF_NR_META_PAGES;
    int nr_data_pages = data_sz >> PAGE_SHIFT;
    int nr_pages = nr_meta_pages + nr_data_pages;
    ...
    /* 每个数据页面被映射两次,以便“虚拟”连续读取绕过环形缓冲区末尾的数据:
     * ------------------------------------------------------
     * | 元数据页面 | 实际数据页面  | 重复的数据页面  |
     * ------------------------------------------------------
     * |            | 1 2 3 4 5 6 7 8 9 | 1 2 3 4 5 6 7 8 9 |
     * ------------------------------------------------------
     * |            | TA             DA | TA             DA |
     * ------------------------------------------------------
     *                               ^^^^^^^
     *                                  |
     * 在这种布局下,不需要特殊处理绕环数据,因为数据页面被双重映射。这样无论在内核还是用户空间中 mmap 都能正常工作。
     */
    array_size = (nr_meta_pages + 2 * nr_data_pages) * sizeof(*pages);
    pages = bpf_map_area_alloc(array_size, numa_node);
    if (!pages)
        return NULL;

    for (i = 0; i < nr_pages; i++) {
        page = alloc_pages_node(numa_node, flags, 0);
        if (!page) {
            nr_pages = i;
            goto err_free_pages;
        }
        pages[i] = page;
        if (i >= nr_meta_pages)
            pages[nr_data_pages + i] = page;
    }

    rb = vmap(pages, nr_meta_pages + 2 * nr_data_pages,
          VM_MAP | VM_USERMAP, PAGE_KERNEL);
    ...
}

[0x0, 0x4000][0x4000, 0x8000] 指向相同的数据页面。这意味着我们可以通过 [0x4000, 0x4008] 访问块 B,这将指向块 A 的头部。

利用

BPF_FUNC_ringbuf_submit/BPF_FUNC_ringbuf_discard 使用头部的 pg_off 来定位元数据页面。

bpf_ringbuf_restore_from_rec(struct bpf_ringbuf_hdr *hdr)
{
    unsigned long addr = (unsigned long)(void *)hdr;
    unsigned long off = (unsigned long)hdr->pg_off << PAGE_SHIFT;

    return (void*)((addr & PAGE_MASK) - off);
}
static void bpf_ringbuf_commit(void *sample, u64 flags, bool discard)
{
    unsigned long rec_pos, cons_pos;
    struct bpf_ringbuf_hdr *hdr;
    struct bpf_ringbuf *rb;
    u32 new_len;

    hdr = sample - BPF_RINGBUF_HDR_SZ;
    rb = bpf_ringbuf_restore_from_rec(hdr);

pg_offbpf_ringbuf_hdr 中是环形缓冲区块的页面偏移量,因此,bpf_ringbuf_restore_from_rec 会通过减去 pg_off 来从环形缓冲区块地址定位到 bpf_ringbuf 对象。我们可以再次看到 bpf_ringbuf_hdr 结构:

struct bpf_ringbuf {
    ...
    unsigned long consumer_pos __aligned(PAGE_SIZE); // 用户空间可读写
    unsigned long producer_pos __aligned(PAGE_SIZE); // 用户空间只读
    unsigned long pending_pos;
    char data[] __aligned(PAGE_SIZE);
}

假设块 A 位于 rb->data 的第一页,块 A 地址与 rb->consumer_pos 的距离为 2。通过利用漏洞,我们将块 A 的 pg_off 修改为 2,然后通过 bpf_ringbuf_restore_from_rec 计算出来的元数据页面会指向 rb->consumer_pos。我们可以在用户空间 mmap rb->consumer_pos 并控制其内容。

通过构造 bpf_ringbuf 中的 work 字段,并在调用 bpf_ringbuf_commit 时传入 BPF_RB_FORCE_WAKEUP,会触发调用我们构造的 irq_work 对象,并将其排入 irq_work_queue

static void bpf_ringbuf_commit(void *sample, u64 flags, bool discard)
{
    ...
    rb = bpf_ringbuf_restore_from_rec(hdr);
    ...

    if (flags & BPF_RB_FORCE_WAKEUP)
        irq_work_queue(&rb->work);
  ...

构造的 irq_work 会在 irq_work_single 中被处理,并执行我们控制的函数指针。

void irq_work_single(void *arg)
{
    struct irq_work *work = arg;
    int flags;

    flags = atomic_read(&work->node.a_flags);
    flags &= ~IRQ_WORK_PENDING;
    atomic_set(&work->node.a_flags, flags);

    ...
    lockdep_irq

_work_enter(flags);
    work->func(work); // [1]
    lockdep_irq_work_exit(flags);
    ...
}

KASLR 绕过

为了绕过 kASLR,我们参考了这一技术

ROP 链

通过观察,我们发现 RBX/RDI 会包含 work 字段的地址,且我们可以控制从 RDI + 0x18 开始的 ROP 数据。接下来,我们使用此 ROP 小工具进行堆栈跳转到我们的控制数据。

0x00000000004b78b1 : push rbx ; or byte ptr [rbx + 0x41], bl ; pop rsp ; pop r13 ; pop rbp ; ret

然后,我们继续执行 ROP 有效负载,通过覆盖 core_pattern 来触发漏洞。通过触发崩溃,它将以高权限执行我们的攻击。

参考

https://github.com/google/security-research/blob/master/pocs/linux/kernelctf/CVE-2024-41009_lts_cos/docs/vulnerability.md

  • 发表于 2025-01-09 09:00:00
  • 阅读 ( 1883 )
  • 分类:代码审计

0 条评论

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

1 篇文章

站长统计