漏洞描述
io_sqe_buffer_register
函数中,可导致物理内存的越界读写。要分析此漏洞,首先要了解io_uring的基本工作原理。一、io_uring的工作原理
1. io_uring是什么?
read()
和 write()
,但对更多系统调用的支持正在不断增长,而且速度很快,最终可能支持大多数系统调用。2. 为什么要用它?
3. 如何使用它?
int io_uring_setup(u32 entries, struct io_uring_params *p){
return syscall(__NR_io_uring_setup, entries, p);
}
int io_uring_enter(int fd, uint32_t to_submit, uint32_t min_complete, uint32_t flags, sigset_t *sig){
return syscall(__NR_io_uring_enter, fd, to_submit, min_complete, flags, sig, _NSIG / 8);
}
int io_uring_register(int fd, unsigned int opcode, const void *arg, unsigned int nr_args){
return syscall(__NR_io_uring_register, fd, opcode, arg, nr_args);
}
4. 它是如何工作的?
io_uring_enter
系统调用时,内核会收到SQ中有工作的通知。或者使用IORING_SETUP_SQPOLL
来创建一个内核线程对SQ队列进行轮询,而无需重新执行io_uring_enter
。二、Linux内存管理新特性——folio
1. 什么是复合页?
__alloc_pages
分配标志GFP FLAGS指定了__GFP_COMP
,那么内核必须将这些页组合成复合页,第一个页称为head page,其余的所有页称为tail page,所有的tail pages都有指向head page的指针。-
N个page是否组成了一个整体? -
这些page哪些是head? -
这些page哪些是tail? -
这些page一共有多少个?
-
在由N个4KB组成的复合页的第0个page结构体上,安置一个PG_head标记,表示head page:
page->flags |= (1UL << PG_head);
-
在由N个4KB组成的复合页的第1~N-1的page结构体,即tail page的compound_head上的最后一位设置为1,表示tail page:
page->compound_head |= 1UL;
-
由
compound_head
和PageTail
函数取出head page和判断相关的page是否是tail page:#define compound_head(page) ((typeof(page))_compound_head(page))
static inline unsigned long _compound_head(const struct page *page)
{
unsigned long head = READ_ONCE(page->compound_head);
if (unlikely(head & 1))
return head - 1;
return (unsigned long)page;
}
static __always_inline int PageTail(struct page *page)
{
return READ_ONCE(page->compound_head) & 1;
} -
通过
compound_order
函数获得复合页中的page个数:static inline unsigned int compound_order(struct page *page)
{
if (!PageHead(page))
return 0;
return page[1].compound_order;
}
2. 什么是folio?
-
根据tail page的页描述符很容易找到复合页的head page,内核的很多函数利用这个特性,但是产生歧义:如果给函数传递一个tail page的页描述符的指针,那么这个函数应该操作这个tail page还是把复合页作为一个整体操作? -
如果一个函数可能被传入一个tail page,但是它必须处理整个复合页,那么它必须调用内联函数 compound_head()
获取复合页的head page的页描述符的地址。在函数之间传递tail page,每个函数都要调用内联函数compound_head()
,造成的后果是内核变大和运行速度变慢。
void folio_get(struct folio *folio);
void get_page(struct page *page);
void folio_lock(struct folio *folio);
void lock_page(struct page *page);
三、漏洞分析
io_sqe_buffer_register
函数中,而次函数被__io_uring_register
函数调用,__io_uring_register
函数的调用者则是io_uring_register
系统调用。io_uring_register
系统调用和IORING_REGISTER_BUFFERS
注册名为Fixed Buffers的内存空间,并锁定,专用于读写数据,这些内存空间不会被其他进程占用。6.3-rc1 中__io_uring_register
源码如下,代码中当标志位为IORING_REGISTER_BUFFERS
时,将会执行io_sqe_buffers_register
函数:io_sqe_buffers_register
函数会进行遍历,执行io_sqe_buffer_register
函数,注册每一个buffer:io_sqe_buffer_register
函数中,通过io_pin_pages
函数锁定物理页,作为io_uring的共享内存区域,防止被换出:io_pin_pages
的函数原型是:struct page **io_pin_pages(unsigned long ubuf, unsigned long len, int *npages)
-
unsigned long ubuf:指定要锁定内存的起始用户虚拟地址。 -
unsigned long len:指定要锁定内存的长度,单位是字节。 -
int *npages:指定一个指针,用于返回锁定的物理页的个数。
io_pin_pages
的返回值是一个指向物理页的指针数组,如果失败,返回NULL。iov->iov_base
与iov->iov_len
都是结构体iovec
中的成员,而iovec
结构体保存来自用户态的指针和大小:struct iovec
{
void __user *iov_base; /* BSD uses caddr_t (1003.1g requires void *) */
__kernel_size_t iov_len; /* Must be size_t (1003.1g) */
};
page_folio
宏定义,将page[0],也就是head page的page结构转换为folio结构。并遍历复合页,检查每一个page的head page是否与复合页相同,而漏洞点就在此处。nr_pages > 1
,即当前的复合页数量大于1页,是由多个page组成的。而在for循环中的判断if (page_folio(page[i]) ≠ folio)
,只是判断了每一个page是否属于当前的复合页,并没有判断这些page是否相邻。这就导致一个问题:每次的page_folio
的参数实际上都是同一个物理页,而内核则认为它是一片多个页组成的连续内存。imu
参数,imu
是io_mapped_ubuf
类型的结构体,用于支持用户态缓冲区映射到I/O空间:struct io_mapped_ubuf {
u64 ubuf;
u64 ubuf_end;
unsigned int nr_bvecs;
unsigned long acct_pages;
struct bio_vec bvec[];
};
bio_vec
类似于iovec
,但它用于物理内存。bio_vec
定义了物理内存地址的连续范围:struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
};
bvec_set_page
函数传入四个参数,第一个参数就是bio_vec
结构体,第二个参数是物理页的head page,第三个参数实际上是从用户态传入的iov->iov_len
,第四个参数是缓冲区的偏移量。bvec_set_page
函数的功能很简单,就只是对bv
进行了赋值而已:static inline voidbvec_set_page(structbio_vec *bv, structpage *page,
unsigned int len, unsigned int offset)
{
bv->bv_page =page;
bv->bv_len = len;
bv->bv_offset = offset;
}
imu
结构体指针赋值给了pimu
,pimu
来自于io_sqe_buffer_register
的调用函数io_sqe_buffers_register
,即io_uring_register
系统调用的操作。最终改变了来自注册时的ctx结构内容,后续的io_uring操作都会使用这个io_ring_ctx
结构体:四、漏洞利用
1. 利用原语
io_uring_register
注册一个跨越多个虚拟页的缓冲区,由于漏洞的存在,它只会重复映射一个相同的物理页。在虚拟内存中,它们是连续的,但在物理内存中并不是连续的,而当函数检查此物理页是否属于复合页时,检查又会通过,因为这个物理页确实是属于当前的复合页。内核认为连续的虚拟内存一定是一片连续的物理页,但实际上只是一次又一次的分配了同一个物理页,而它的size来自于用户态,是我们可控的:2. 目标对象
由于漏洞可以越界读写许多页的内容,那么就可以不再考虑对象大小和分配的问题,也就是说我们利用的对象可以是任意大小的。sock是一个很好的对象,它包含了许多函数指针和内核地址,随意泄露一个都足以绕过KASLR。
struct sock {
struct sock_common __sk_common; /* 0 136 */
/* --- cacheline 2 boundary (128 bytes) was 8 bytes ago --- */
struct dst_entry * sk_rx_dst; /* 136 8 */
int sk_rx_dst_ifindex; /* 144 4 */
u32 sk_rx_dst_cookie; /* 148 4 */
socket_lock_t sk_lock; /* 152 32 */
atomic_t sk_drops; /* 184 4 */
int sk_rcvlowat; /* 188 4 */
/* --- cacheline 3 boundary (192 bytes) --- */
struct sk_buff_head sk_error_queue; /* 192 24 */
struct sk_buff_head sk_receive_queue; /* 216 24 */
struct {
atomic_t rmem_alloc; /* 240 4 */
int len; /* 244 4 */
struct sk_buff * head; /* 248 8 */
/* --- cacheline 4 boundary (256 bytes) --- */
struct sk_buff * tail; /* 256 8 */
} sk_backlog; /* 240 24 */
int sk_forward_alloc; /* 264 4 */
u32 sk_reserved_mem; /* 268 4 */
unsigned int sk_ll_usec; /* 272 4 */
unsigned int sk_napi_id; /* 276 4 */
int sk_rcvbuf; /* 280 4 */
/* XXX 4 bytes hole, try to pack */
struct sk_filter * sk_filter; /* 288 8 */
union {
struct socket_wq * sk_wq; /* 296 8 */
struct socket_wq * sk_wq_raw; /* 296 8 */
}; /* 296 8 */
struct xfrm_policy * sk_policy[2]; /* 304 16 */
/* --- cacheline 5 boundary (320 bytes) --- */
struct dst_entry * sk_dst_cache; /* 320 8 */
atomic_t sk_omem_alloc; /* 328 4 */
int sk_sndbuf; /* 332 4 */
int sk_wmem_queued; /* 336 4 */
refcount_t sk_wmem_alloc; /* 340 4 */
long unsigned int sk_tsq_flags; /* 344 8 */
union {
struct sk_buff * sk_send_head; /* 352 8 */
struct rb_root tcp_rtx_queue; /* 352 8 */
}; /* 352 8 */
struct sk_buff_head sk_write_queue; /* 360 24 */
/* --- cacheline 6 boundary (384 bytes) --- */
__s32 sk_peek_off; /* 384 4 */
int sk_write_pending; /* 388 4 */
__u32 sk_dst_pending_confirm; /* 392 4 */
u32 sk_pacing_status; /* 396 4 */
long int sk_sndtimeo; /* 400 8 */
struct timer_list sk_timer; /* 408 40 */
/* XXX last struct has 4 bytes of padding */
/* --- cacheline 7 boundary (448 bytes) --- */
__u32 sk_priority; /* 448 4 */
__u32 sk_mark; /* 452 4 */
long unsigned int sk_pacing_rate; /* 456 8 */
long unsigned int sk_max_pacing_rate; /* 464 8 */
// .. many more fields
/* size: 760, cachelines: 12, members: 92 */
/* sum members: 754, holes: 1, sum holes: 4 */
/* sum bitfield members: 16 bits (2 bytes) */
/* paddings: 2, sum paddings: 6 */
/* forced alignments: 1 */
/* last cacheline: 56 bytes */
} __attribute__((__aligned__(8)));
sk_pacing_rate
与sk_max_pacing_rate
成员,这两个成员可以通过setsockopt
的SO_MAX_PACING_RATE
操作进行设置。逻辑如下:sk_pacing_rate
与sk_max_pacing_rate
设置的值都来自于用户态传入的值,因此可以设置一些特殊标记,在查找sock对象的时候可以通过这两个标记来确定是否命中了sock对象。至于为什么需要同时设置这两个成员的值而不是一个,是因为通过实验发现,只判断一个成员有很大概率不是sock对象,同时设置两个可以提高判断的精确性。setsockopt
的SO_SNDBUF
操作进行设置。逻辑如下:SO_SNDBUF
操作的val
值依然来自用户态,但这里需要满足一个条件,即val
要大于宏定义SOCK_MIN_SNDBUF
的值才会被写进sk_sndbuf
成员中。这个SOCK_MIN_SNDBUF
宏定义展开后如下:#define __ALIGN_KERNEL_MASK(x, mask) (((x) + (mask)) & ~(mask))
#define __ALIGN_KERNEL(x, a) __ALIGN_KERNEL_MASK(x, (typeof(x))(a) - 1)
#define L1_CACHE_SHIFT 5
#define L1_CACHE_BYTES (1 << L1_CACHE_SHIFT)
#define ALIGN(x, a) __ALIGN_KERNEL((x), (a))
#define SMP_CACHE_BYTES L1_CACHE_BYTES
#define SKB_DATA_ALIGN(X) ALIGN(X, SMP_CACHE_BYTES)
#define SK_BUFF_SIZE 224
#define TCP_SKB_MIN_TRUESIZE (2048 + SKB_DATA_ALIGN(SK_BUFF_SIZE))
#define SOCK_MIN_SNDBUF (TCP_SKB_MIN_TRUESIZE * 2)
SOCK_MIN_SNDBUF
的值即可,在命中sock对象后,再将sk_sndbuf位置的值减去SOCK_MIN_SNDBUF就是socket对象的描述符。sock.__sk_common
中的成员来泄露内核基址。__sk_common
是一个sock_common
结构体:struct sock_common {
union {
__addrpair skc_addrpair; /* 0 8 */
struct {
__be32 skc_daddr; /* 0 4 */
__be32 skc_rcv_saddr; /* 4 4 */
}; /* 0 8 */
}; /* 0 8 */
union {
unsigned int skc_hash; /* 8 4 */
__u16 skc_u16hashes[2]; /* 8 4 */
}; /* 8 4 */
union {
__portpair skc_portpair; /* 12 4 */
struct {
__be16 skc_dport; /* 12 2 */
__u16 skc_num; /* 14 2 */
}; /* 12 4 */
}; /* 12 4 */
short unsigned int skc_family; /* 16 2 */
volatile unsigned char skc_state; /* 18 1 */
unsigned char skc_reuse:4; /* 19: 0 1 */
unsigned char skc_reuseport:1; /* 19: 4 1 */
unsigned char skc_ipv6only:1; /* 19: 5 1 */
unsigned char skc_net_refcnt:1; /* 19: 6 1 */
/* XXX 1 bit hole, try to pack */
int skc_bound_dev_if; /* 20 4 */
union {
struct hlist_node skc_bind_node; /* 24 16 */
struct hlist_node skc_portaddr_node; /* 24 16 */
}; /* 24 16 */
struct proto * skc_prot; /* 40 8 */
possible_net_t skc_net; /* 48 8 */
......
/* size: 136, cachelines: 3, members: 25 */
/* sum members: 135 */
/* sum bitfield members: 7 bits, bit holes: 1, sum bit holes: 1 bits */
/* last cacheline: 8 bytes */
sock_common
结构体有一个struct proto *skc_prot
,这个proto
对象中存在很多函数指针:struct proto {
void (*close)(struct sock *, long int); /* 0 8 */
int (*pre_connect)(struct sock *, struct sockaddr *, int); /* 8 8 */
int (*connect)(struct sock *, struct sockaddr *, int); /* 16 8 */
int (*disconnect)(struct sock *, int); /* 24 8 */
struct sock * (*accept)(struct sock *, int, int *, bool); /* 32 8 */
int (*ioctl)(struct sock *, int, long unsigned int); /* 40 8 */
int (*init)(struct sock *); /* 48 8 */
void (*destroy)(struct sock *); /* 56 8 */
/* --- cacheline 1 boundary (64 bytes) --- */
void (*shutdown)(struct sock *, int); /* 64 8 */
int (*setsockopt)(struct sock *, int, int, sockptr_t, unsigned int); /* 72 8 */
int (*getsockopt)(struct sock *, int, int, char *, int *); /* 80 8 */
....
3. exploit
-
qemu模拟给的内存不够,导致exp在执行mmap时内存不足,触发unable to handle page fault问题,导致kernel panic -
如果将泄露的页数减少,或者减少mmap映射的内存,会导致很难命中sock对象。
-
通过匿名文件映射内存,然后通过io_uring来实现用户态与内核态内存共享;
-
执行完
setsockopt(sockets[i], SOL_SOCKET, SO_MAX_PACING_RATE, &egg, sizeof(uint64_t)) < 0)
后,在sk_pacing_rate与sk_max_pacing_rate设置了两个egg: -
在执行完
setsockopt(sockets[i], SOL_SOCKET, SO_SNDBUF, &j, sizeof(int)
后,可以看到sk_sndbuf
被设置为了(sockets[i] + SOCK_MIN_SNDBUF)*2。即(4+4544)*2 = 0x2388: -
通过同一物理页的连续地址映射,在io_uring操作之后,检测映射内存中是否命中了sock对象(从这一步开始我的复现失败,无法命中sock对象);
-
判断
sk_pacing_rate
与sk_max_pacing_rate
是否是egg标记。在确定命中sock对象后,通过sock对象计算距离函数指针的偏移,以此泄露sk_data_ready_off
函数地址,从而得到kernel base与sock对象的地址; -
通过
sk_sndbuf
的值,减去SOCK_MIN_SNDBUF
的值 ,可以得到socket的描述符,以便后续劫持函数指针之后,对这个socket进行操作; -
在修改和伪造sock内容之前,先对sock数据进行备份,在之后将其还原,否则会导致kernel panic;
-
为了劫持socket对象的函数指针,需要伪造一个proto对象。为了不影响sock对象,选择将伪造的proto放置在sock对象之后。
-
劫持proto中的
ioctl
为call_usermodehelper_exec
函数,这个函数可以在内核空间启动一个用户态进程。 -
call_usermodehelper_exec
需要两个参数,struct subprocess_info *sub_info
和int wait
,ioctl函数指针是:(*ioctl)(struct sock *, int, long unsigned int);
,它的第一个参数始终指向sock对象,也就是说没办法直接调用ioctl去提权。此外,在proto+0x28位置为ioctl函数指针,我们需要覆盖这个函数指针完成劫持,但调用call_usermodehelper_exec
函数时,其参数subprocess_info
+ 0x28位置是所要执行的用户态程序路径,刚好与ioctl函数指针重叠,这会破坏我们的利用。 -
exploit中提到了一种方法,即利用
work_struct
,这个结构描述一个延迟工作的对象。subprocess_info.work.func
成员是一个函数指针,延迟工作将会调用这个函数指针。struct work_struct {
atomic_long_t data; /* 0 8 */
struct list_head entry; /* 8 16 */
work_func_t func; /* 24 8 */
/* size: 32, cachelines: 1, members: 3 */
/* last cacheline: 32 bytes */
}; -
综合上面的信息,可以将
subprocess_info.work.func
函数指针改写为call_usermodehelper_exec_work
函数,这个函数时负责生成我们的新进程的函数。然后将proto对象放置在subprocess_info.path
位置,由于伪造的proto结构中我们只关心如何伪造ioctl指针,在ioctl之前的函数指针我们并不关心,那么就可以这些位置写为/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0
字符串的指针。 -
伪造完成后,在调用ioctl时,将会触发
call_usermodehelper_exec
函数,延迟执行/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0
,即可获取一个root shell。
References
[1] https://anatomic.rip/cve-2023-2598/#folio
原文始发于微信公众号(山石网科安全技术研究院):CVE-2023-2598 io_uring内核提权分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论