CVE-2023-2598 io_uring内核提权分析
CVE-2023-2598 io_uring内核提权分析
原创 unr4v31 山石网科安全技术研究院 2024-01-02 10:52
CVE-2023-2598 为 io_uring API 组件中的一处 OOB 漏洞,可越界读写物理内存。本文
[1] 对提权利用方式进行分析,从 io_uring 的工作原理、Linux 内存管理新特性——folio、sock 对象配合 call_usermodehelper_exec 的利用方法等方面,详细介绍这一强大的利用原语。
漏洞描述
从漏洞描述可知,此漏洞位于io_uring / rsrc.c的io_sqe_buffer_register函数中,可导致物理内存的越界读写。要分析此漏洞,首先要了解io_uring的基本工作原理。
一、io_uring的工作原理
1. io_uring是什么?
简单来说, io_uring 就是Linux的系统调用接口,于 2019 年在上游 Linux 内核版本 5.1 中首次引入,它使应用程序可以异步执行的系统调用。最初,io_uring仅支持简单的 I/O 系统调用,如 read() 和 write() ,但对更多系统调用的支持正在不断增长,而且速度很快,最终可能支持大多数系统调用。
2. 为什么要用它?
io_uring是一种异步IO,是为了减少原生AIO存在的阻塞和开销问题。举个栗子,在原生AIO执行read系统调用时,应用程序会等待内核执行完成read系统调用,才会执行后续的系统调用。而io_uring的好处就是可以批量提交系统调用,这些系统调用是异步的,不会阻塞系统调用。
原生AIO的拷贝字节时的开销,取决单次IO的字节大小。如果单次拷贝量大,那么拷贝开销可以忽略。但如果在大量的小IO的情况下,对于拷贝开销影响就比较大了。io_uring的另一个好处是,对于单次小的IO拷贝,减少系统调用内核上下文频繁切换所带来的性能开销。
尽管在大多数情况下,阻塞、上下文切换或复制字节时的开销可能并不明显,但在高性能应用程序中,就变得非常重要。比如对于与服务器/后端相关的应用程序特别有用,其中很大一部分应用程序时间都花在等待 I/O 上。因此io_uring是一个重要的优化。
3. 如何使用它?
最直接的方式就是使用io_uring系统调用:
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);
}
但由于io_uring非常复杂,所以直接使用系统调用是一项非常艰巨的工作。好在io_uring的首席开发人员编写了用户空间的库liburing,提供了简化的API来与内核组件进行交互。liburing会随着io_uring的变动而更新,但并没有做版本控制,因此要使用liburing,还需要检查当前内核是否支持这些功能。并且io_uring更新太快,liburing的文档和用例都已经过时了,还是需要自己查找使用方法。
这篇文章
[2] 通过io_uring创建了一个“零系统调用”的服务器,可以作为参考。
4. 它是如何工作的?
io_uring包含两个环形缓冲区,分别是提交队列(Submission Queue,SQ)和完成队列(Completion Queue,CQ)。其中SQ环形缓冲区存放的是许多系统调用请求,每个系统调用请求的描述信息称为提交队列条目(Submission Queue Entries,SQE)。CQ存放的是已经完成的系统调用请求。SQ与CQ在用户态与内核态之间的共享内存中完成信息交换。
对于每个请求,都会填写一个 SQE 并将其放入SQ中。单个 SQE 描述了应该执行的系统调用操作。当应用程序进行io_uring_enter系统调用时,内核会收到SQ中有工作的通知。或者使用IORING_SETUP_SQPOLL来创建一个内核线程对SQ队列进行轮询,而无需重新执行io_uring_enter。
当完成每个SQE时,内核首先会判断是否异步执行该操作,如果操作可以在不阻塞的情况下完成,则它将在调用线程的上下文中同步完成。否则,它被放入内核异步工作队列中,并由工作线程异步完成。在这两种情况下,调用线程都不会阻塞,区别在于操作是由调用线程立即完成还是稍后完成。
当操作完成时,每处理一个SQE,则会在CQ中都会放置一个完成队列条目CQE。应用程序可以轮询 CQ 以查找新的 CQE,从而得知相应的操作已经完成。SQE 可以按任何顺序完成,但如果需要特定的完成顺序,则可以将它们相互链接。
关于更多的io_uring细节内容,可参考Lord of the io_uring
[3]。
二、Linux内存管理新特性——folio
在了解了io_uring的工作原理之后,要对此漏洞进行利用,还需要了解在5.16版本内核引入的一个新的内存管理特性,folio。
1. 什么是复合页?
随着计算机内存的不断扩大,4G以上几乎成了标配,目前甚至有几十上百G的内存,而操作系统仍然使用4KB大小页面的基本单位显得有些滞后。
简单来说,复合页(compound page)就是将两个或更多的物理页面组合成一个单元。在许多方面可将其视为单个更大的页面。
举个例子,当采用4KB大小页面时,想象一下当应用程序分配2MB内存,并进行访问时,共有512个页面,操作系统会经历512次TLB miss和512次缺页中断后,才可以把这2M地址空间全部映射到物理内存上。然而如果使用2MB大小的复合页,那么只需要一次TLB miss和一次缺页中断。
当__alloc_pages分配标志GFP FLAGS指定了__GFP_COMP,那么内核必须将这些页组合成复合页,第一个页称为head page,其余的所有页称为tail page,所有的tail pages都有指向head page的指针。
由于多个page组合成复合页,这些page之间会有关联,那么就带来了几个问题:
– N个page是否组成了一个整体?
-
这些page哪些是head?
-
这些page哪些是tail?
-
这些page一共有多少个?
在没有引入folio之前,内核由如下方法来解决上述问题:
– 在由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?
在folio出现之前,都使用page结构来处理数据,伴随着两个比较混乱的问题:
1. 根据tail page的页描述符很容易找到复合页的head page,内核的很多函数利用这个特性,但是产生歧义:如果给函数传递一个tail page的页描述符的指针,那么这个函数应该操作这个tail page还是把复合页作为一个整体操作?
- 如果一个函数可能被传入一个tail page,但是它必须处理整个复合页,那么它必须调用内联函数compound_head()获取复合页的head page的页描述符的地址。在函数之间传递tail page,每个函数都要调用内联函数compound_head(),造成的后果是内核变大和运行速度变慢。
对于上述问题,比较容易想到的处理方式就是,我们难道不可以直接创建一些专用的函数,来处理复合页中的对应页吗?比如创建get_page、get_xxx、get_yyy等函数。实际上这种混乱的局面,很容易对程序员进行错误的引导,因为程序员写代码的时候,究竟在操作xxx,还是yyy,自己都拎不清了。
为了解决复合页产生的问题,Linux 5.16引入概念“folio”,folio表示0阶页或者一个复合页的首页。给函数传递一个folio,函数将会操作整个复合页,没有歧义。folio本质上可以看作是一个集合,是物理连续、虚拟连续的2^n次的PAGE_SIZE的一些bytes的集合,n可以是0,也就是说单个页也算是一个folio。folio把一些page里面常用字段,提取到了和page同等位置的union里面。folio结构与page结构并列可以更直观的看出它们之间的差异:
folio有一点是确定的,它必然不会是一个tail page,从而避免了前面的xxx、yyy的语义混乱。所以新的内核中有了两组不同的API来处理复合页:
void folio_get(struct folio *folio);
void get_page(struct page *page);
void folio_lock(struct folio *folio);
void lock_page(struct page *page);
复合页的改进实际上是一个非常繁重的工作,它涉及到大量的驱动代码与文件系统代码的更改。起初多数内核开发者认为这种更改会带来更复杂的问题,并且更改代码代价太大,是否真的有必要这样做。但Linus认为folio的优势也是明显的,能够更直白的处理复合页,避免一些混乱的问题,最终folio被采用。
三、漏洞分析
现在对漏洞进行分析,将io_uring与复合页联系起来。
通过NVD的漏洞描述,得知此漏洞位于io_sqe_buffer_register函数中,而次函数被__io_uring_register函数调用,__io_uring_register函数的调用者则是io_uring_register系统调用。
在io_uring中,可以通过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)
它是在/io_uring/rsrc.c中定义的。这个函数的作用是将用户空间的一段内存(由ubuf和len指定)锁定在物理内存中,并返回对应的物理页的指针数组。io_pin_pages的参数如下:
– 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数量是否大于1,即判断是否为复合页。然后使用page_folio宏定义,将page[0],也就是head page的page结构转换为folio结构。并遍历复合页,检查每一个page的head page是否与复合页相同,而漏洞点就在此处。
回顾folio的介绍,它表示在物理内存、虚拟内存都连续的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来自于用户态,是我们可控的:
也就是说,我们可以利用io_uring的其他功能,越界读写当前物理页之后的物理页,这是一个相当强大的利用原语。
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)));
在sock对象中有sk_pacing_rate与sk_max_pacing_rate成员,这两个成员可以通过setsockopt的SO_MAX_PACING_RATE操作进行设置。逻辑如下:
上述代码里,sk_pacing_rate与sk_max_pacing_rate设置的值都来自于用户态传入的值,因此可以设置一些特殊标记,在查找sock对象的时候可以通过这两个标记来确定是否命中了sock对象。至于为什么需要同时设置这两个成员的值而不是一个,是因为通过实验发现,只判断一个成员有很大概率不是sock对象,同时设置两个可以提高判断的精确性。
另外,在命中了sock对象后,还需要知道这个socket的描述符。这也可以通过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)
要满足val > SOCK_MIN_SNDBUF很简单,只需要将socket对象的描述符加上SOCK_MIN_SNDBUF 的值即可,在命中sock对象后,再将sk_sndbuf位置的值减去SOCK_MIN_SNDBUF就是socket对象的描述符。
在命中了sock对象后,可以泄露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 */
....
那么我们就可以泄露然后劫持其中一个函数指针,操作socket对象,来提升权限。
3. exploit
原exploit可以在这里
[4] 找到。
在我的环境没有复现成功,原因有两点:
1. qemu模拟给的内存不够,导致exp在执行mmap时内存不足,触发unable to handle page fault问题,导致kernel panic
- 如果将泄露的页数减少,或者减少mmap映射的内存,会导致很难命中sock对象。
综合这两点来看,这个漏洞利用成功率还是比较低的,下面分析原exploit的整体利用思路。
1. 通过匿名文件映射内存,然后通过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
[2]
https://web.archive.org/web/20221125154504/https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html
[3]
https://web.archive.org/web/20221125154504/https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html
[4]
https://web.archive.org/web/20221125154504/https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html
[5]
https://chompie.rip/Blog+Posts/Put+an+io_uring+on+it+-+Exploiting+the+Linux+Kernel