一个缓冲区重叠漏洞分析与利用
背景
以下内容摘自 提交信息:
BPF 环形缓冲区内部实现为大小为 2 的幂次方的循环缓冲区,并使用两个逻辑且不断递增的计数器:consumer_pos 表示消费者消费数据的位置,producer_pos 表示生产者已保留的数据量。
每次预留一个记录时,负责该记录的生产者会推进生产者计数器。每当用户空间读取记录时,消费者会在处理完成后推进消费者计数器。两个计数器存储在不同的内存页中,因此,用户空间只能读 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 程序就能修改第一个内存块的头部。下面是具体步骤:
首先,我们创建一个大小为 0x4000 的 BPF_MAP_TYPE_RINGBUF,并在调用 BPF_FUNC_ringbuf_reserve 前将 consumer_pos 修改为 0x3000。
分配块 A,它位于 [0x0, 0x3008],此时 eBPF 程序可以编辑 [0x8, 0x3008]。
接下来分配块 B,大小为 0x3000,此时会成功分配,因为 consumer_pos 已提前修改,可以通过检查。
块 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_pos 为 0x6010,rb->mask 为 0x4000 - 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_off 在 bpf_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 来触发漏洞。通过触发崩溃,它将以高权限执行我们的攻击。