以下内容摘自 提交信息:
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 程序就能修改第一个内存块的头部。下面是具体步骤:
0x4000
的 BPF_MAP_TYPE_RINGBUF
,并在调用 BPF_FUNC_ringbuf_reserve
前将 consumer_pos
修改为 0x3000
。[0x0, 0x3008]
,此时 eBPF 程序可以编辑 [0x8, 0x3008]
。0x3000
,此时会成功分配,因为 consumer_pos
已提前修改,可以通过检查。[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,我们参考了这一技术。
通过观察,我们发现 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
来触发漏洞。通过触发崩溃,它将以高权限执行我们的攻击。
1 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!